test: 测试按行为命名、server 测试统一到 test/、PG 测试分组并行
- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀, w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名 - vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行); pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s - CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争) - ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b343dddbf6
commit
b61335eaf6
75 files changed
+159
-115
No files matched your search
@@ -1,126 +0,0 @@
|
||||
import { sql } from "@umt/db";
|
||||
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createSchedulesModule } from "../../schedules/index.ts";
|
||||
import { evaluateAcquisition } from "../services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../services/organize.ts";
|
||||
import {
|
||||
createHarness,
|
||||
fakeDisk,
|
||||
type Harness,
|
||||
pgAvailable,
|
||||
seedBase,
|
||||
seedEntity,
|
||||
seedRelease,
|
||||
seedSlot,
|
||||
} from "./harness.ts";
|
||||
|
||||
// W4-D P0:后台循环只编排(W7-C 起由 schedules 调度器驱动模块登记的定时任务)。补入库在巡检里只入队 verifyAndImport;一个 50MB 的入库(复制 + sha256 + 慢 ffprobe)
|
||||
// 进行中时,qB 同步定时器仍按周期打到 mock qB(用 mock qB 的请求时间戳断言)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 50 * 1024 * 1024;
|
||||
const SYNC_MS = 200;
|
||||
const PROBE_DELAY_MS = 2500;
|
||||
|
||||
describe.skipIf(!hasPg)("acquisition-organize 后台循环不被入库阻塞", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
const disk = fakeDisk(10 * 1024 ** 3);
|
||||
const torrents = new Map<string, Uint8Array>();
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
const schedules = createSchedulesModule({ builtinChecks: false, scheduler: { tickMs: 50, lockRetryMs: 100 } });
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
|
||||
cache = await createTempTree({}, { prefix: "umt-cache-" });
|
||||
disk.state.watch = [cache.root];
|
||||
disk.setPct(40);
|
||||
h = await createHarness("bgloop", {
|
||||
diskUsage: disk.fn,
|
||||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||||
// 慢 ffprobe:让一次入库稳定地持续数秒
|
||||
probe: async () => {
|
||||
await new Promise((r) => setTimeout(r, PROBE_DELAY_MS));
|
||||
return { status: "unknown", reason: "ffprobe_unavailable" };
|
||||
},
|
||||
options: { backgroundLoops: true, syncIntervalMs: SYNC_MS, planIntervalMs: SYNC_MS, reconcileIntervalMs: 0 },
|
||||
});
|
||||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
await h.setSettings("retention", { seedRetentionDays: 0 });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await schedules.stop?.();
|
||||
await h?.module.stop?.();
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
await lib?.cleanup();
|
||||
await cache?.cleanup();
|
||||
});
|
||||
|
||||
it("巡检只入队 verifyAndImport;50MB 入库进行中 qB 同步仍按时执行", async () => {
|
||||
const entityId = await seedEntity(h.db, { kind: "movie", title: "长片", year: 2023 });
|
||||
await seedSlot(h.db, { entityId });
|
||||
const t = makeTorrent({ name: "Long.Movie.2023.1080p.BluRay.x264-GRP.mkv", length: SIZE, pieceSeed: 7 });
|
||||
const relId = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId,
|
||||
title: "Long.Movie.2023.1080p.BluRay.x264-GRP",
|
||||
size: SIZE,
|
||||
hash: t.hash,
|
||||
});
|
||||
torrents.set(relId, t.bytes);
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
|
||||
const sub = await submitDownload(h.d, ev.plans[0]!);
|
||||
expect(sub.status).toBe("submitted");
|
||||
await qb.complete(t.hash, { writeFiles: true });
|
||||
expect((await syncDownloads(h.d)).completed).toContain(sub.downloadTaskId);
|
||||
const dtId = sub.downloadTaskId!;
|
||||
// 完成超过 5 分钟且没有整理记录 → 巡检对象
|
||||
await sql`update download_task set completed_at = now() - interval '10 minutes' where id = ${dtId}`.execute(h.db);
|
||||
|
||||
await h.module.start?.(h.ctx);
|
||||
await schedules.start?.(h.ctx);
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (!h.engine.enqueued.some((e) => e.task === "verifyAndImport") && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
const enq = h.engine.enqueued.find((e) => e.task === "verifyAndImport");
|
||||
expect(enq?.input).toMatchObject({ downloadTaskId: dtId });
|
||||
expect(enq?.key).toMatch(new RegExp(`^verifyAndImport:import:${dtId}:\\d+$`));
|
||||
// 循环没有自己执行入库
|
||||
const recs = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select("id")
|
||||
.where("download_task_id", "=", dtId)
|
||||
.execute();
|
||||
expect(recs).toHaveLength(0);
|
||||
// 同一时间槽重复巡检用同一个引擎幂等键(真实引擎去重;假引擎只记录)
|
||||
await new Promise((r) => setTimeout(r, SYNC_MS * 3));
|
||||
const keys = new Set(h.engine.enqueued.filter((e) => e.task === "verifyAndImport").map((e) => e.key));
|
||||
expect(keys.size).toBe(1);
|
||||
|
||||
// 模拟 worker 执行这次 verifyAndImport(同进程,与同步循环并发)
|
||||
const t0 = Date.now();
|
||||
const plan = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
|
||||
expect(plan.status).toBe("running");
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
const t1 = Date.now();
|
||||
expect(ex).toMatchObject({ status: "succeeded", succeeded: 1 });
|
||||
expect(t1 - t0).toBeGreaterThanOrEqual(PROBE_DELAY_MS);
|
||||
|
||||
const stamps = qb
|
||||
.callsTo("torrents/info")
|
||||
.map((c) => c.at)
|
||||
.filter((at) => at >= t0 && at <= t1);
|
||||
// 每 200ms 一轮(同步本身几十 ms);入库期间至少有 1/3 的轮次、最大间隔不超过 1s
|
||||
expect(stamps.length).toBeGreaterThanOrEqual(Math.floor((t1 - t0) / SYNC_MS / 3));
|
||||
const gaps = [t0, ...stamps, t1].slice(1).map((v, i) => v - [t0, ...stamps][i]!);
|
||||
expect(Math.max(...gaps)).toBeLessThan(1000);
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { TmdbClient } from "@umt/adapters";
|
||||
import { DEFAULT_CLASSIFICATION_YAML } from "@umt/domain";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthEnv } from "../../../http/auth.ts";
|
||||
import { createSettingsReader } from "../../auth-settings/provided.ts";
|
||||
import { registerSettingsRoutes } from "../../auth-settings/settings-routes.ts";
|
||||
import { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||
import { classifyEntity } from "../services/classification.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
describe.skipIf(!hasPg)("TMDB classification API and accepted identities (real PG)", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("classification");
|
||||
const settingsApp = new OpenAPIHono<AuthEnv>();
|
||||
registerSettingsRoutes(settingsApp, h.ctx);
|
||||
h.app.route("/api/v1", settingsApp);
|
||||
}, 60_000);
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterAll(async () => await h?.close());
|
||||
|
||||
it("legacy stored organize settings receive default YAML without losing preferences", async () => {
|
||||
await h.setSettings("organize", { minSizeBytes: 456789, conflictDefault: "skip" });
|
||||
const reader = createSettingsReader({ db: h.db, logger: h.ctx.logger });
|
||||
expect(await reader.get("organize")).toMatchObject({
|
||||
minSizeBytes: 456789,
|
||||
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
|
||||
});
|
||||
});
|
||||
|
||||
it("ordered categories and raw preview do not write settings", async () => {
|
||||
const view = await h.api("GET", "/classification");
|
||||
expect(view.status).toBe(200);
|
||||
expect(view.body.data.yaml).toBe(DEFAULT_CLASSIFICATION_YAML);
|
||||
expect(view.body.data.categories.movie).toEqual(["动画电影", "演唱会", "华语电影", "亚洲电影", "欧美电影"]);
|
||||
const preview = await h.api("POST", "/classification/preview", {
|
||||
yaml: "movie:\n 日本片:\n production_countries: JP\ntv: {}\n",
|
||||
mediaType: "movie",
|
||||
tmdb: { production_countries: [{ iso_3166_1: "JP" }] },
|
||||
});
|
||||
expect(preview.status).toBe(200);
|
||||
expect(preview.body.data).toEqual({ category: "日本片", matchedFields: ["production_countries"], fallback: false });
|
||||
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(DEFAULT_CLASSIFICATION_YAML);
|
||||
});
|
||||
|
||||
it("PUT preserves organize preferences, audits and emits settings change; invalid writes reject", async () => {
|
||||
await h.setSettings("organize", { ...(await h.ctx.settings.get("organize")), minSizeBytes: 765432 });
|
||||
const publish = vi.spyOn(h.ctx.events, "publish");
|
||||
const yaml = "movie:\n 新分类:\n original_language: zh\n 其它: null\ntv: {}\n";
|
||||
const saved = await h.api("PUT", "/classification", { yaml });
|
||||
expect(saved.status).toBe(200);
|
||||
expect(saved.body.data.categories.movie).toEqual(["新分类", "其它"]);
|
||||
expect(await h.ctx.settings.get("organize")).toMatchObject({ classificationYaml: yaml, minSizeBytes: 765432 });
|
||||
expect(publish).toHaveBeenCalledWith("settings.changed", { keys: ["organize"] });
|
||||
expect(
|
||||
(await h.db.selectFrom("audit_event").selectAll().where("action", "=", "settings.update").execute()).length,
|
||||
).toBeGreaterThan(0);
|
||||
for (const invalid of ["movie:\n ../escape: null\ntv: {}", "movie:\n 同名: null\n 同名: null\ntv: {}"]) {
|
||||
expect((await h.api("PUT", "/classification", { yaml: invalid })).status).toBe(422);
|
||||
expect((await h.api("PATCH", "/settings", { organize: { classificationYaml: invalid } })).status).toBe(422);
|
||||
}
|
||||
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(yaml);
|
||||
await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML });
|
||||
});
|
||||
|
||||
it("PUT merges current row after waiting rather than overwriting a newer source edit", async () => {
|
||||
const source = {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
name: "Concurrent source",
|
||||
enabled: true,
|
||||
source: { root: "lib:11111111-1111-4111-8111-111111111111", path: "" },
|
||||
target: { root: "lib:22222222-2222-4222-8222-222222222222", path: "" },
|
||||
mediaType: "all",
|
||||
category: null,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
downloaderInstanceId: null,
|
||||
tag: null,
|
||||
intervalSec: 300,
|
||||
};
|
||||
const originalAll = h.ctx.settings.all.bind(h.ctx.settings);
|
||||
vi.spyOn(h.ctx.settings, "all").mockImplementationOnce(async () => {
|
||||
const stale = await originalAll();
|
||||
await h.setSettings("organize", { ...stale.organize, minSizeBytes: 654321, sources: [source] });
|
||||
return stale;
|
||||
});
|
||||
expect((await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML })).status).toBe(200);
|
||||
const stored = await h.db
|
||||
.selectFrom("settings")
|
||||
.select("value")
|
||||
.where("key", "=", "organize")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(stored.value).toMatchObject({
|
||||
minSizeBytes: 654321,
|
||||
sources: [source],
|
||||
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires accepted movie/tv identity, uses raw detail, and fails without fabricating fallback", async () => {
|
||||
await h.setSettings("organize", {
|
||||
...(await h.ctx.settings.get("organize")),
|
||||
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
|
||||
});
|
||||
const id = await seedEntity(h.db, { kind: "movie", title: "测试片", year: 2020 });
|
||||
const entity = await h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const getJson = vi.fn().mockResolvedValue({ genres: [{ id: 16 }], original_language: "zh" });
|
||||
const factory = vi.spyOn(SourcesRuntime.prototype, "tmdb").mockResolvedValue({ getJson } as unknown as TmdbClient);
|
||||
expect((await classifyEntity(h.d, entity)).category).toBeNull();
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
const identity = await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({ media_entity_id: id, provider: "tmdb", entity_type: "movie", external_id: "123", status: "proposed" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((await classifyEntity(h.d, entity)).category).toBeNull();
|
||||
await h.db.updateTable("external_identity").set({ status: "accepted" }).where("id", "=", identity.id).execute();
|
||||
expect(await classifyEntity(h.d, entity)).toEqual({
|
||||
category: "动画电影",
|
||||
matchedFields: ["genre_ids"],
|
||||
fallback: false,
|
||||
});
|
||||
expect(getJson).toHaveBeenCalledWith("/movie/123");
|
||||
getJson.mockRejectedValue(new Error("offline"));
|
||||
expect(await classifyEntity(h.d, entity)).toEqual({ category: null, matchedFields: [], fallback: false });
|
||||
getJson.mockResolvedValue({});
|
||||
expect((await classifyEntity(h.d, entity)).category).toBeNull();
|
||||
getJson.mockResolvedValue({ genres: [{ id: 10764 }], origin_country: ["CN"] });
|
||||
const tvId = await seedEntity(h.db, { kind: "series", title: "测试剧", year: 2020 });
|
||||
const tv = await h.db.selectFrom("media_entity").selectAll().where("id", "=", tvId).executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({ media_entity_id: tvId, provider: "tmdb", entity_type: "tv", external_id: "456", status: "accepted" })
|
||||
.execute();
|
||||
expect((await classifyEntity(h.d, tv)).category).toBe("综艺");
|
||||
expect(getJson).toHaveBeenCalledWith("/tv/456");
|
||||
factory.mockResolvedValue(null);
|
||||
expect((await classifyEntity(h.d, entity)).category).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,314 +0,0 @@
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease, seedSlot } from "./harness.ts";
|
||||
|
||||
// DL-3 下载计划并入下载面板:等批准 / 等磁盘空间的计划作为「进行中」行与下载任务按时间一起排、计入计数;
|
||||
// 深链按计划 id 取行;「为什么下这个」;取消还没提交的下载;没提交就结束的进作品下载历史;
|
||||
// 审批被拒 / 过期时草案跟着结束。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const h40 = (n: number) => n.toString(16).padStart(40, "c");
|
||||
type Row = { kind: "task" | "plan"; id: string; name: string; wait?: string; waitReason?: string | null };
|
||||
|
||||
describe.skipIf(!hasPg)("DL-3 下载计划并入下载面板(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
let entity: string;
|
||||
let siteId: string;
|
||||
const ids = {} as Record<"reserved" | "draft" | "orphan" | "submitted" | "failed" | "agentDraft", string>;
|
||||
const slots = {} as Record<string, string>;
|
||||
let taskId: string;
|
||||
let instId: string;
|
||||
|
||||
const at = (min: number) => new Date(Date.UTC(2026, 8, 30, 8, min));
|
||||
|
||||
async function plan(
|
||||
key: keyof typeof ids,
|
||||
p: { status: string; min: number; purpose?: string; evaluation?: Record<string, unknown>; reason?: string },
|
||||
) {
|
||||
const slot = await seedSlot(h.db, { entityId: entity, name: key === "submitted" ? "4K" : `slot-${key}` });
|
||||
slots[key] = slot;
|
||||
const rel = await seedRelease(h.db, {
|
||||
siteId,
|
||||
entityId: entity,
|
||||
title: `Severance.S02.${key}.2160p`,
|
||||
size: 10_000_000_000,
|
||||
hash: h40(Object.keys(ids).length + 1),
|
||||
});
|
||||
await h.db
|
||||
.updateTable("source_release")
|
||||
.set({ promotion: JSON.stringify({ kind: "free" }), seeders: 41 })
|
||||
.where("id", "=", rel)
|
||||
.execute();
|
||||
const row = await h.db
|
||||
.insertInto("acquisition_plan")
|
||||
.values({
|
||||
slot_id: slot,
|
||||
source_release_id: rel,
|
||||
purpose: (p.purpose ?? "fill") as "fill",
|
||||
status: p.status as "draft",
|
||||
evaluation: JSON.stringify(p.evaluation ?? { reasons: ["hard_conditions_met", "pri:90"], instanceId: instId }),
|
||||
reserved_bytes: 10_000_000_000,
|
||||
payload_hash: `hash-${key}`,
|
||||
idempotency_key: `test:${key}`,
|
||||
status_reason: p.reason ?? null,
|
||||
created_at: at(p.min),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
ids[key] = row.id;
|
||||
if (p.status === "reserved")
|
||||
await h.db
|
||||
.updateTable("acquisition_slot")
|
||||
.set({ status: "reserved", reserved_plan_id: row.id })
|
||||
.where("id", "=", slot)
|
||||
.execute();
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function approval(planId: string, status = "pending", scope: Record<string, unknown> = {}) {
|
||||
return (
|
||||
await h.db
|
||||
.insertInto("approval")
|
||||
.values({
|
||||
plan_type: "acquisition_plan",
|
||||
plan_id: planId,
|
||||
payload_hash: "hash-x",
|
||||
expected_revision: 1,
|
||||
scope: JSON.stringify(scope),
|
||||
status: status as "pending",
|
||||
requested_by: "agent:test",
|
||||
expires_at: new Date(Date.now() + 3600_000),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("dl3plans");
|
||||
entity = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
|
||||
siteId = (
|
||||
await h.db
|
||||
.insertInto("site")
|
||||
.values({ key: "dl3site", name: "测试站", adapter: "nexusphp", base_url: "https://pt.example.test" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
// 下载器停用:面板不去连 qB,只看数据库
|
||||
instId = (
|
||||
await h.db
|
||||
.insertInto("downloader_instance")
|
||||
.values({ name: "qb", base_url: "http://127.0.0.1:9", username: "a", enabled: false })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
|
||||
// 时间线(新 → 旧):已预留等空间(50)> 下载中的任务(40)> 等批准(30)> 智能体草案(25)> 孤立草案(20)> 失败(10)
|
||||
await plan("failed", { status: "failed", min: 10, reason: "rejected: torrent is invalid" });
|
||||
await plan("orphan", { status: "draft", min: 20 });
|
||||
await plan("agentDraft", { status: "draft", min: 25 });
|
||||
await plan("draft", { status: "draft", min: 30 });
|
||||
await plan("submitted", { status: "submitted", min: 35 });
|
||||
await plan("reserved", { status: "reserved", min: 50 });
|
||||
await approval(ids.draft);
|
||||
await approval(ids.agentDraft, "pending", { agentRunId: "x" });
|
||||
|
||||
taskId = (
|
||||
await h.db
|
||||
.insertInto("download_task")
|
||||
.values({
|
||||
instance_id: instId,
|
||||
torrent_hash: h40(99),
|
||||
acquisition_plan_id: ids.submitted,
|
||||
name: "Severance.S02.submitted.2160p",
|
||||
status: "downloading",
|
||||
managed: true,
|
||||
media_entity_id: entity,
|
||||
created_at: at(40),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
// 已预留的提交任务被下载盘阈值延后
|
||||
await h.db
|
||||
.insertInto("task")
|
||||
.values({
|
||||
kind: "submitDownload",
|
||||
idempotency_key: `plan:${ids.reserved}`,
|
||||
status: "waiting",
|
||||
subject_type: "acquisition_plan",
|
||||
subject_id: ids.reserved,
|
||||
output: JSON.stringify({ status: "deferred", reason: "cache:projected_over_threshold" }),
|
||||
})
|
||||
.execute();
|
||||
// 智能体会话准备的草案
|
||||
const run = await h.db
|
||||
.insertInto("agent_run")
|
||||
.values({ trigger: "user", goal: "补齐人生切割术第二季的 4K 版本" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("capability_invocation")
|
||||
.values({
|
||||
idempotency_key: "inv-1",
|
||||
capability_id: "download.plan",
|
||||
capability_version: 1,
|
||||
agent_run_id: run.id,
|
||||
actor_id: "agent",
|
||||
source: "agent",
|
||||
status: "succeeded",
|
||||
input: JSON.stringify({}),
|
||||
output: JSON.stringify({ plan: { id: ids.agentDraft } }),
|
||||
})
|
||||
.execute();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("进行中:等批准、等磁盘空间的计划与下载任务按加入时间一起排,计数包含它们;孤立草案不出现", async () => {
|
||||
const r = await h.api("GET", "/download-tasks/board");
|
||||
expect(r.status).toBe(200);
|
||||
const b = r.body.data;
|
||||
const rows = b.items as Row[];
|
||||
expect(rows.map((x) => [x.kind, x.id])).toEqual([
|
||||
["plan", ids.reserved],
|
||||
["task", taskId],
|
||||
["plan", ids.draft],
|
||||
["plan", ids.agentDraft],
|
||||
]);
|
||||
expect(b.counts).toEqual({ active: 4, completed: 0 });
|
||||
const reserved = b.items[0];
|
||||
expect(reserved).toMatchObject({
|
||||
wait: "space",
|
||||
waitReason: "cache:projected_over_threshold",
|
||||
status: "reserved",
|
||||
purpose: "fill",
|
||||
name: "Severance.S02.reserved.2160p",
|
||||
sizeBytes: 10_000_000_000,
|
||||
media: { id: entity, title: "人生切割术", year: 2022 },
|
||||
approval: null,
|
||||
});
|
||||
const draft = b.items[2];
|
||||
expect(draft).toMatchObject({ wait: "approval", status: "draft", approval: { payloadHash: "hash-x" } });
|
||||
// 已完成分组不含计划
|
||||
const done = (await h.api("GET", "/download-tasks/board?state=completed")).body.data;
|
||||
expect(done.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("翻页:两路按同一游标归并,不重不漏", async () => {
|
||||
const p1 = (await h.api("GET", "/download-tasks/board?limit=3")).body.data;
|
||||
expect(p1.items.map((x: Row) => x.id)).toEqual([ids.reserved, taskId, ids.draft]);
|
||||
expect(p1.nextCursor).toBeTruthy();
|
||||
const p2 = (await h.api("GET", `/download-tasks/board?limit=3&cursor=${encodeURIComponent(p1.nextCursor)}`)).body
|
||||
.data;
|
||||
expect(p2.items.map((x: Row) => x.id)).toEqual([ids.agentDraft]);
|
||||
expect(p2.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("深链 planId:等待中的计划给计划行,已提交的给它的下载任务,结束的没有行", async () => {
|
||||
const a = (await h.api("GET", `/download-tasks/board?planId=${ids.reserved}`)).body.data;
|
||||
expect(a.items.map((x: Row) => [x.kind, x.id])).toEqual([["plan", ids.reserved]]);
|
||||
const b = (await h.api("GET", `/download-tasks/board?planId=${ids.submitted}&state=completed`)).body.data;
|
||||
expect(b.items.map((x: Row) => [x.kind, x.id])).toEqual([["task", taskId]]);
|
||||
const c = (await h.api("GET", `/download-tasks/board?planId=${ids.failed}`)).body.data;
|
||||
expect(c.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("为什么下这个:订阅 / 智能体会话来源、目的、选中理由原始要点", async () => {
|
||||
const sub = (await h.api("GET", `/acquisition-plans/${ids.submitted}/why`)).body.data;
|
||||
expect(sub).toMatchObject({
|
||||
planId: ids.submitted,
|
||||
source: { kind: "subscription", name: "人生切割术" },
|
||||
purpose: "fill",
|
||||
slotName: "4K",
|
||||
replaces: null,
|
||||
reasons: ["hard_conditions_met", "pri:90"],
|
||||
promotion: "free",
|
||||
seeders: 41,
|
||||
siteName: "测试站",
|
||||
});
|
||||
expect(sub.evaluation.reasons).toEqual(["hard_conditions_met", "pri:90"]);
|
||||
const agent = (await h.api("GET", `/acquisition-plans/${ids.agentDraft}/why`)).body.data;
|
||||
expect(agent.source).toMatchObject({ kind: "agent", name: "补齐人生切割术第二季的 4K 版本" });
|
||||
expect((await h.api("GET", `/acquisition-plans/${crypto.randomUUID()}/why`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("升级:被替换的版本取评估时记下的文件规格", async () => {
|
||||
const asset = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({
|
||||
media_entity_id: entity,
|
||||
kind: "video",
|
||||
delivery: JSON.stringify({ resolution: "1080p", source: "BluRay", hdr: [], releaseGroup: "CMCT" }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.updateTable("acquisition_plan")
|
||||
.set({
|
||||
purpose: "upgrade",
|
||||
evaluation: JSON.stringify({ reasons: ["source_tier_improved"], replacesAssetId: asset.id }),
|
||||
})
|
||||
.where("id", "=", ids.submitted)
|
||||
.execute();
|
||||
const w = (await h.api("GET", `/acquisition-plans/${ids.submitted}/why`)).body.data;
|
||||
expect(w.purpose).toBe("upgrade");
|
||||
expect(w.replaces).toEqual({ assetId: asset.id, label: "1080p 蓝光压制 · CMCT" });
|
||||
});
|
||||
|
||||
it("取消:已预留的释放收录版本并进下载历史;已提交的 409", async () => {
|
||||
const r = await h.api("POST", `/acquisition-plans/${ids.reserved}/cancel`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data).toMatchObject({ status: "cancelled", statusReason: "cancelled_by_user" });
|
||||
const slot = await h.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select(["status", "reserved_plan_id"])
|
||||
.where("id", "=", slots.reserved!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(slot.status).not.toBe("reserved");
|
||||
expect((await h.api("POST", `/acquisition-plans/${ids.submitted}/cancel`)).status).toBe(409);
|
||||
expect((await h.api("POST", `/acquisition-plans/${ids.reserved}/cancel`)).status).toBe(409);
|
||||
const b = (await h.api("GET", "/download-tasks/board")).body.data;
|
||||
expect(b.counts.active).toBe(3);
|
||||
expect(b.items.some((x: Row) => x.id === ids.reserved)).toBe(false);
|
||||
});
|
||||
|
||||
it("作品下载历史:没提交就结束的下载带原因码;孤立草案与进行中的不在里面", async () => {
|
||||
const d = (await h.api("GET", `/library/${entity}`)).body.data;
|
||||
const dropped = d.droppedPlans.map((x: { plan: { id: string; statusReason: string } }) => [
|
||||
x.plan.id,
|
||||
x.plan.statusReason,
|
||||
]);
|
||||
expect(dropped).toEqual(
|
||||
expect.arrayContaining([
|
||||
[ids.failed, "rejected: torrent is invalid"],
|
||||
[ids.reserved, "cancelled_by_user"],
|
||||
]),
|
||||
);
|
||||
expect(dropped).toHaveLength(2);
|
||||
expect(d.droppedPlans.find((x: { plan: { id: string } }) => x.plan.id === ids.failed).releaseTitle).toBe(
|
||||
"Severance.S02.failed.2160p",
|
||||
);
|
||||
});
|
||||
|
||||
it("审批被拒 / 过期:草案跟着结束,不再算进行中", async () => {
|
||||
const { closeDraftPlan } = await import("../../agent/interaction.ts");
|
||||
await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.draft }, "rejected");
|
||||
await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.agentDraft }, "expired");
|
||||
// 不是草案的不动
|
||||
await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.submitted }, "rejected");
|
||||
const rows = await sql<{ id: string; status: string; status_reason: string | null }>`
|
||||
select id, status, status_reason from acquisition_plan where id in (${sql.join([ids.draft, ids.agentDraft, ids.submitted])})`.execute(
|
||||
h.db,
|
||||
);
|
||||
const by = new Map(rows.rows.map((r) => [r.id, [r.status, r.status_reason]]));
|
||||
expect(by.get(ids.draft)).toEqual(["cancelled", "approval_rejected"]);
|
||||
expect(by.get(ids.agentDraft)).toEqual(["expired", "approval_expired"]);
|
||||
expect(by.get(ids.submitted)?.[0]).toBe("submitted");
|
||||
const b = (await h.api("GET", "/download-tasks/board")).body.data;
|
||||
expect(b.items.map((x: Row) => x.kind)).toEqual(["task"]);
|
||||
expect(b.counts.active).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { createMockQb, type MockQb } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { syncDownloads } from "../services/downloads.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
|
||||
|
||||
// DL-2 下载面板:进行中 / 已完成分组与计数、每个种子的实时速度 / 剩余时间 / 连接数、下载器总速度、
|
||||
// 实时数据短 TTL 缓存(轮询不直打 qB)、下载器不可达时退化为只看数据库。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const TTL = 400;
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const h40 = (n: number) => n.toString(16).padStart(40, "b");
|
||||
|
||||
describe.skipIf(!hasPg)("DL-2 下载面板(真实 PG + mock qB)", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let instId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
h = await createHarness("dlboard", { options: { liveTtlMs: TTL } });
|
||||
instId = (
|
||||
await h.db
|
||||
.insertInto("downloader_instance")
|
||||
.values({ name: "qb-main", base_url: qb.url, username: "admin", settings: JSON.stringify({}) })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
|
||||
// 进行中:下载中(有速度与 eta)、暂停、排队、出错;已完成:做种、完成暂停;管理标签外的任务不纳入
|
||||
const dl = qb.seed({ hash: h40(1), name: "Dune.Part.Two.2024.2160p", tags: ["umt"], progress: 0.4 });
|
||||
Object.assign(dl, {
|
||||
dlspeed: 12_900_000,
|
||||
upspeed: 1_200_000,
|
||||
eta: 620,
|
||||
num_seeds: 12,
|
||||
num_leechs: 3,
|
||||
num_complete: 80,
|
||||
num_incomplete: 9,
|
||||
});
|
||||
qb.seed({ hash: h40(2), name: "Paused.Show.S01", tags: ["umt"], progress: 0.2, state: "pausedDL" });
|
||||
qb.seed({ hash: h40(3), name: "Queued.Movie.2020", tags: ["umt"], progress: 0, state: "queuedDL" });
|
||||
qb.seed({ hash: h40(4), name: "Broken.Movie.2019", tags: ["umt"], progress: 0.1, state: "error" });
|
||||
const seeding = qb.seed({ hash: h40(5), name: "Heat.1995.1080p", tags: ["umt"], progress: 1 });
|
||||
Object.assign(seeding, { upspeed: 300_000, ratio: 1.25 });
|
||||
qb.seed({ hash: h40(6), name: "Done.Paused.2018", tags: ["umt"], progress: 1, state: "pausedUP" });
|
||||
const other = qb.seed({ hash: h40(7), name: "Not.Managed", tags: ["other"], progress: 0.5 });
|
||||
other.dlspeed = 100_000;
|
||||
|
||||
await syncDownloads(h.d);
|
||||
const heat = await seedEntity(h.db, { kind: "movie", title: "盗火线", year: 1995 });
|
||||
const t5 = await h.db
|
||||
.updateTable("download_task")
|
||||
.set({ media_entity_id: heat })
|
||||
.where("torrent_hash", "=", h40(5))
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({
|
||||
trigger: "auto",
|
||||
actor_type: "system",
|
||||
action: "copy",
|
||||
status: "succeeded",
|
||||
download_task_id: t5.id,
|
||||
media_entity_id: heat,
|
||||
})
|
||||
.execute();
|
||||
// 完成暂停的那个:最近一次整理失败 + 未关闭的待处理事项(下载任务维度)
|
||||
const t6 = await h.db
|
||||
.selectFrom("download_task")
|
||||
.select("id")
|
||||
.where("torrent_hash", "=", h40(6))
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({
|
||||
trigger: "auto",
|
||||
actor_type: "system",
|
||||
action: "copy",
|
||||
status: "failed",
|
||||
reason: "identity_unknown",
|
||||
download_task_id: t6.id,
|
||||
})
|
||||
.execute();
|
||||
await h.db
|
||||
.insertInto("decision")
|
||||
.values({
|
||||
kind: "identity_unresolved",
|
||||
title: "没识别出作品",
|
||||
subject_type: "download_task",
|
||||
subject_id: t6.id,
|
||||
dedupe_key: `test:${t6.id}`,
|
||||
})
|
||||
.execute();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
});
|
||||
|
||||
it("默认进行中:只含未完成任务,两组各带计数,附实时速度与剩余时间", async () => {
|
||||
await sleep(TTL + 50);
|
||||
const r = await h.api("GET", "/download-tasks/board");
|
||||
expect(r.status).toBe(200);
|
||||
const b = r.body.data;
|
||||
expect(b.counts).toEqual({ active: 4, completed: 2 });
|
||||
const names = b.items.map((i: { name: string }) => i.name).sort();
|
||||
expect(names).toEqual(["Broken.Movie.2019", "Dune.Part.Two.2024.2160p", "Paused.Show.S01", "Queued.Movie.2020"]);
|
||||
const dune = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1));
|
||||
expect(dune.live).toMatchObject({
|
||||
dlSpeed: 12_900_000,
|
||||
upSpeed: 1_200_000,
|
||||
etaSec: 620,
|
||||
seeds: 12,
|
||||
leechs: 3,
|
||||
seedsTotal: 80,
|
||||
leechsTotal: 9,
|
||||
rawState: "downloading",
|
||||
});
|
||||
expect(dune.live.progress).toBeCloseTo(0.4);
|
||||
// eta 8640000(qB 的无穷)记为 null;num_complete -1 记为未知
|
||||
const queued = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(3));
|
||||
expect(queued.live.etaSec).toBeNull();
|
||||
expect(queued.live.seedsTotal).toBeNull();
|
||||
expect(queued.live.dlSpeed).toBe(0);
|
||||
// 总速度来自 transfer/info:整个下载器(含管理标签外的任务)
|
||||
expect(b.transfer.dlSpeed).toBe(12_900_000 + 100_000);
|
||||
expect(b.transfer.upSpeed).toBe(1_200_000 + 300_000);
|
||||
expect(b.transfer.instances).toEqual([
|
||||
{ instanceId: instId, name: "qb-main", reachable: true, dlSpeed: 13_000_000, upSpeed: 1_500_000 },
|
||||
]);
|
||||
expect(b.liveAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("已完成:含做种与完成后暂停,带作品与最近整理记录", async () => {
|
||||
const b = (await h.api("GET", "/download-tasks/board?state=completed")).body.data;
|
||||
expect(b.counts).toEqual({ active: 4, completed: 2 });
|
||||
expect(b.items).toHaveLength(2);
|
||||
const heat = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(5));
|
||||
expect(heat.status).toBe("completed");
|
||||
expect(heat.media).toMatchObject({ title: "盗火线", year: 1995 });
|
||||
expect(heat.organize).toMatchObject({ status: "succeeded", reason: null, decision: null });
|
||||
expect(heat.live).toMatchObject({ upSpeed: 300_000, ratio: 1.25, etaSec: null });
|
||||
expect(heat.live.completedAt).toBeTruthy();
|
||||
const paused = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(6));
|
||||
expect(paused.organize).toMatchObject({
|
||||
status: "failed",
|
||||
reason: "identity_unknown",
|
||||
decision: { kind: "identity_unresolved" },
|
||||
});
|
||||
expect(paused.media).toBeNull();
|
||||
});
|
||||
|
||||
it("TTL 内的轮询共用一次 qB 查询,并发请求合并;过期后重新查询", async () => {
|
||||
await sleep(TTL + 50);
|
||||
const before = qb.callsTo("torrents/info").length;
|
||||
const beforeT = qb.callsTo("transfer/info").length;
|
||||
await Promise.all([
|
||||
h.api("GET", "/download-tasks/board"),
|
||||
h.api("GET", "/download-tasks/board?state=completed"),
|
||||
h.api("GET", "/download-tasks/board"),
|
||||
]);
|
||||
await h.api("GET", "/download-tasks/board");
|
||||
expect(qb.callsTo("torrents/info").length - before).toBe(1);
|
||||
expect(qb.callsTo("transfer/info").length - beforeT).toBe(1);
|
||||
// 缓存期间速度变化不可见,过期后可见
|
||||
const dl = qb.get(h40(1));
|
||||
if (dl) dl.dlspeed = 5_000_000;
|
||||
const cached = (await h.api("GET", "/download-tasks/board")).body.data;
|
||||
expect(cached.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1)).live.dlSpeed).toBe(12_900_000);
|
||||
await sleep(TTL + 50);
|
||||
const fresh = (await h.api("GET", "/download-tasks/board")).body.data;
|
||||
expect(fresh.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1)).live.dlSpeed).toBe(5_000_000);
|
||||
expect(qb.callsTo("torrents/info").length - before).toBe(2);
|
||||
});
|
||||
|
||||
it("下载器不可达:列表照常返回,实时数据为空,实例标记不可达", async () => {
|
||||
await sleep(TTL + 50);
|
||||
qb.fault("torrents/info", { status: 500, times: 10 });
|
||||
const b = (await h.api("GET", "/download-tasks/board")).body.data;
|
||||
qb.clearFaults();
|
||||
expect(b.counts.active).toBe(4);
|
||||
expect(b.items.every((i: { live: unknown }) => i.live === null)).toBe(true);
|
||||
expect(b.transfer).toMatchObject({ dlSpeed: 0, upSpeed: 0 });
|
||||
expect(b.transfer.instances[0].reachable).toBe(false);
|
||||
expect(b.liveAt).toBeNull();
|
||||
});
|
||||
|
||||
it("按实例过滤;非法 state 返回 422", async () => {
|
||||
const other = await h.api("GET", `/download-tasks/board?instanceId=${crypto.randomUUID()}`);
|
||||
expect(other.body.data).toMatchObject({ items: [], counts: { active: 0, completed: 0 } });
|
||||
expect(other.body.data.transfer.instances).toEqual([]);
|
||||
expect((await h.api("GET", "/download-tasks/board?state=done")).status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import type { TaskRunView } from "@umt/tasks";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { sql } from "kysely";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { enqueueSweep, enqueueTracked } from "../services/common.ts";
|
||||
import { type QueueScrapeInput, queueScrape, runScrapeRecord } from "../services/scrape.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const runView = (runId: string, input: unknown, status: TaskRunView["status"] = "pending"): TaskRunView => ({
|
||||
runId,
|
||||
taskName: "scrapeMedia",
|
||||
version: 2,
|
||||
status,
|
||||
attempts: 0,
|
||||
idempotencyKey: null,
|
||||
traceId: null,
|
||||
priority: 0,
|
||||
input,
|
||||
output: null,
|
||||
error: null,
|
||||
cancelRequestedAt: null,
|
||||
cancelReason: null,
|
||||
availableAt: null,
|
||||
createdAt: new Date(),
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("入队关联与巡检恢复", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let rootId: string;
|
||||
let entityId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true });
|
||||
h = await createHarness("enqueue_recovery");
|
||||
({ rootId } = await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" }));
|
||||
entityId = await seedEntity(h.db, { kind: "movie", title: "恢复测试", year: 2020 });
|
||||
await sql`create function reject_association() returns trigger language plpgsql as $$
|
||||
begin
|
||||
if new.engine_run_id is not null and new.engine_run_id is distinct from old.engine_run_id
|
||||
then raise exception 'injected association failure'; end if;
|
||||
return new;
|
||||
end $$`.execute(h.db);
|
||||
});
|
||||
|
||||
async function rejectAssociation(table: "task" | "scrape_record") {
|
||||
await sql
|
||||
.raw(`create trigger reject_association before update on ${table}
|
||||
for each row execute function reject_association()`)
|
||||
.execute(h.db);
|
||||
}
|
||||
|
||||
async function allowAssociation() {
|
||||
for (const table of ["task", "scrape_record"])
|
||||
await sql.raw(`drop trigger if exists reject_association on ${table}`).execute(h.db);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await allowAssociation();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await lib?.cleanup();
|
||||
});
|
||||
|
||||
const task = (key: string) => ({ kind: "reconcileLibrary" as const, idempotencyKey: key, input: {} });
|
||||
const scrape = (path: string, idempotencyKey?: string): QueueScrapeInput => ({
|
||||
rootId: `lib:${rootId}`,
|
||||
rootPath: lib.root,
|
||||
libraryRootId: rootId,
|
||||
path,
|
||||
match: {
|
||||
mediaEntityId: entityId,
|
||||
provider: null,
|
||||
externalId: null,
|
||||
kind: "movie",
|
||||
title: "恢复测试",
|
||||
year: 2020,
|
||||
source: "chosen",
|
||||
},
|
||||
trigger: "manual",
|
||||
mode: "missing",
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
});
|
||||
|
||||
it("引擎查询异常跳过本轮,下一轮仍查原任务", async () => {
|
||||
const t = task("query_error");
|
||||
const q = await enqueueTracked(h.d, t);
|
||||
const enqueue = vi.spyOn(h.engine, "enqueue");
|
||||
const get = vi
|
||||
.spyOn(h.engine, "get")
|
||||
.mockRejectedValueOnce(new Error("temporary engine outage"))
|
||||
.mockResolvedValue(runView(q.runId, {}, "running"));
|
||||
expect(await enqueueSweep(h.d, t, "next")).toBeNull();
|
||||
expect(await enqueueSweep(h.d, t, "later")).toBeNull();
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
expect(get).toHaveBeenNthCalledWith(2, q.runId);
|
||||
expect(
|
||||
(await h.db.selectFrom("task").select("engine_run_id").where("id", "=", q.taskId).executeTakeFirstOrThrow())
|
||||
.engine_run_id,
|
||||
).toBe(q.runId);
|
||||
});
|
||||
|
||||
it.each(["missing", "completed"])("明确 %s 时允许补投", async (state) => {
|
||||
const t = task(`retry_${state}`);
|
||||
const q = await enqueueTracked(h.d, t);
|
||||
vi.spyOn(h.engine, "get").mockResolvedValue(state === "missing" ? null : runView(q.runId, {}, "completed"));
|
||||
const enqueue = vi.spyOn(h.engine, "enqueue");
|
||||
expect(await enqueueSweep(h.d, t, "next")).not.toBeNull();
|
||||
expect(enqueue).toHaveBeenCalledOnce();
|
||||
expect(enqueue.mock.calls[0]?.[2].idempotencyKey).toBe(`${t.kind}:${t.idempotencyKey}:next`);
|
||||
});
|
||||
|
||||
it.each([false, true])("关联写回失败后巡检按原 taskId 找回已接受的 run (旧关联=%s)", async (oldAssociation) => {
|
||||
const t = task(`association_${oldAssociation}`);
|
||||
if (oldAssociation) await enqueueTracked(h.d, t);
|
||||
await rejectAssociation("task");
|
||||
const q = await enqueueTracked(h.d, t, { engineKey: "new-slot" });
|
||||
await h.db
|
||||
.insertInto("tasks_run_meta")
|
||||
.values({ run_id: q.runId, task_name: t.kind, task_version: 1, idempotency_key: "new-slot", trace_id: q.taskId })
|
||||
.execute();
|
||||
await allowAssociation();
|
||||
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.runId, {}, "running"));
|
||||
const enqueue = vi.spyOn(h.engine, "enqueue");
|
||||
expect(await enqueueSweep(h.d, t, "later")).toBeNull();
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(await h.db.selectFrom("task").select("engine_run_id").where("id", "=", q.taskId).executeTakeFirstOrThrow())
|
||||
.engine_run_id,
|
||||
).toBe(q.runId);
|
||||
});
|
||||
|
||||
it("刮削已接受而关联失败时保持 queued,重试复用记录与原幂等键", async () => {
|
||||
const input = scrape("association-retry");
|
||||
await rejectAssociation("scrape_record");
|
||||
const q = await queueScrape(h.d, input);
|
||||
expect(
|
||||
await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["status", "reason", "engine_run_id"])
|
||||
.where("id", "=", q.recordId)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).toMatchObject({ status: "queued", reason: null, engine_run_id: null });
|
||||
const key = h.engine.enqueued.at(-1)?.key;
|
||||
await allowAssociation();
|
||||
const enqueue = vi.spyOn(h.engine, "enqueue").mockResolvedValue({ runId: q.taskId!, deduplicated: true });
|
||||
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.taskId!, { recordId: q.recordId }));
|
||||
expect(await queueScrape(h.d, input)).toEqual({ recordId: q.recordId, taskId: q.taskId, deduplicated: true });
|
||||
expect(enqueue.mock.calls[0]?.[2].idempotencyKey).toBe(key);
|
||||
expect((await h.db.selectFrom("scrape_record").select("id").where("path", "=", input.path).execute()).length).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("刮削处理器开始时补关联,不会把已接受的工作误标为入队失败", async () => {
|
||||
await rejectAssociation("scrape_record");
|
||||
const q = await queueScrape(h.d, scrape("worker-backfill"));
|
||||
await allowAssociation();
|
||||
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute();
|
||||
const r = await runScrapeRecord(h.d, q.recordId, undefined, q.taskId!);
|
||||
expect(r.reason).toBe("scrape_root_read_only");
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select("engine_run_id")
|
||||
.where("id", "=", q.recordId)
|
||||
.executeTakeFirstOrThrow()
|
||||
).engine_run_id,
|
||||
).toBe(q.taskId);
|
||||
});
|
||||
|
||||
it("确实入队失败的新刮削记录标失败", async () => {
|
||||
vi.spyOn(h.engine, "enqueue").mockRejectedValue(new Error("enqueue rejected"));
|
||||
const input = scrape("enqueue-failed");
|
||||
await expect(queueScrape(h.d, input)).rejects.toThrow("enqueue rejected");
|
||||
expect(
|
||||
await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["status", "reason"])
|
||||
.where("path", "=", input.path)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).toMatchObject({ status: "failed", reason: "task_enqueue_failed" });
|
||||
});
|
||||
|
||||
it("自动重放从原 run 输入找回未关联记录,只删除本次多建的记录", async () => {
|
||||
const input = scrape("auto-replay", "auto-stable-key");
|
||||
await rejectAssociation("scrape_record");
|
||||
const q = await queueScrape(h.d, input);
|
||||
await allowAssociation();
|
||||
await h.db.updateTable("scrape_record").set({ status: "succeeded" }).where("id", "=", q.recordId).execute();
|
||||
vi.spyOn(h.engine, "enqueue").mockResolvedValue({ runId: q.taskId!, deduplicated: true });
|
||||
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.taskId!, { recordId: q.recordId }, "completed"));
|
||||
expect(await queueScrape(h.d, input)).toEqual({ recordId: q.recordId, taskId: q.taskId, deduplicated: true });
|
||||
const recs = await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["id", "status", "engine_run_id"])
|
||||
.where("path", "=", input.path)
|
||||
.execute();
|
||||
expect(recs).toEqual([{ id: q.recordId, status: "succeeded", engine_run_id: q.taskId }]);
|
||||
});
|
||||
});
|
||||
@@ -1,360 +0,0 @@
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { DiskUsage } from "@umt/adapters";
|
||||
import { createQbClient } from "@umt/adapters";
|
||||
import { DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
|
||||
import { type DB, migrateToLatest } from "@umt/db";
|
||||
import type { TaskEngine } from "@umt/tasks";
|
||||
import { Kysely, PostgresDialect, sql } from "kysely";
|
||||
import pg from "pg";
|
||||
import { ConfigSchema } from "../../../config.ts";
|
||||
import { createOutboxPort } from "../../../context.ts";
|
||||
import { createApp } from "../../../http/app.ts";
|
||||
import { hashApiKey } from "../../../http/auth.ts";
|
||||
import { EventBus } from "../../../http/sse.ts";
|
||||
import { createLogger } from "../../../logger.ts";
|
||||
import { createCapabilityRegistry } from "../../agent/capability-registry.ts";
|
||||
import { createSourcesIdentityModule } from "../../sources-identity/index.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { type AoOverrides, createAcquisitionOrganizeModule } from "../index.ts";
|
||||
import type { AoDeps } from "../services/common.ts";
|
||||
|
||||
// 真实 PG(独立 schema)+ mock qB + 临时目录 的测试装配;引擎用记录入队的假实现,测试直接驱动服务函数。
|
||||
|
||||
export const DB_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
export const DEV_KEY = "dev-admin-ao-test-key";
|
||||
/** 只读整理记录 / 可发起整理 的 API Key */
|
||||
export const READ_KEY = "ao-organize-read-key";
|
||||
export const EXEC_KEY = "ao-organize-exec-key";
|
||||
const KEYS = [
|
||||
{ key: READ_KEY, scopes: ["organize.read"] },
|
||||
{ key: EXEC_KEY, scopes: ["organize.execute"] },
|
||||
];
|
||||
|
||||
export async function pgAvailable() {
|
||||
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
|
||||
const c = new pg.Client({ connectionString: DB_URL, connectionTimeoutMillis: 2000 });
|
||||
try {
|
||||
await c.connect();
|
||||
await c.end();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Enqueued {
|
||||
task: string;
|
||||
input: unknown;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** 记录入队、不执行的引擎(处理器由测试直接调服务函数) */
|
||||
export function fakeEngine(): TaskEngine & { enqueued: Enqueued[] } {
|
||||
const enqueued: Enqueued[] = [];
|
||||
let n = 0;
|
||||
return {
|
||||
enqueued,
|
||||
async enqueue(task, input, opts) {
|
||||
enqueued.push({ task, input, key: opts.idempotencyKey });
|
||||
n++;
|
||||
return { runId: `run-${n}`, deduplicated: false };
|
||||
},
|
||||
async signal() {
|
||||
return { delivered: 0 };
|
||||
},
|
||||
async cancel() {
|
||||
return false;
|
||||
},
|
||||
async get() {
|
||||
return null;
|
||||
},
|
||||
async list() {
|
||||
return [];
|
||||
},
|
||||
async startWorker() {},
|
||||
async stop() {},
|
||||
};
|
||||
}
|
||||
|
||||
/** 可调的磁盘用量:used = base + 目录下文件总大小(清理删文件后自然下降) */
|
||||
export function fakeDisk(total: number) {
|
||||
const state = { total, base: 0, watch: [] as string[], fail: false };
|
||||
const du = (dir: string): number => {
|
||||
let s = 0;
|
||||
try {
|
||||
for (const n of readdirSync(dir)) {
|
||||
const p = join(dir, n);
|
||||
const st = statSync(p);
|
||||
s += st.isDirectory() ? du(p) : st.size;
|
||||
}
|
||||
} catch {
|
||||
// 不存在
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const fn = async (path: string): Promise<DiskUsage> => {
|
||||
if (state.fail) return { status: "unknown", path, code: "EIO", message: "fake" };
|
||||
const used = state.base + state.watch.reduce((a, d) => a + du(d), 0);
|
||||
return {
|
||||
status: "ok",
|
||||
path,
|
||||
totalBytes: state.total,
|
||||
freeBytes: state.total - used,
|
||||
availableBytes: state.total - used,
|
||||
usedBytes: used,
|
||||
usedRatio: used / state.total,
|
||||
};
|
||||
};
|
||||
/** 把使用率设为 pct(按当前目录内容反推 base) */
|
||||
const setPct = (pct: number) => {
|
||||
const files = state.watch.reduce((a, d) => a + du(d), 0);
|
||||
state.base = Math.round((pct / 100) * state.total) - files;
|
||||
};
|
||||
return { state, fn, setPct };
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应/事件 JSON
|
||||
type Loose = any;
|
||||
|
||||
export interface Harness {
|
||||
module: ReturnType<typeof createAcquisitionOrganizeModule>;
|
||||
db: Kysely<DB>;
|
||||
ctx: ServerContext;
|
||||
d: AoDeps;
|
||||
engine: ReturnType<typeof fakeEngine>;
|
||||
app: ReturnType<typeof createApp>;
|
||||
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
|
||||
setSettings(key: string, value: Record<string, unknown>): Promise<void>;
|
||||
outbox(type?: string): Promise<{ type: string; payload: Loose; idempotency_key: string | null }[]>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createHarness(
|
||||
name: string,
|
||||
overrides: AoOverrides = {},
|
||||
/** withSources:同时装配 sources-identity 模块(待处理中心路由等) */
|
||||
opts: { withSources?: boolean } = {},
|
||||
): Promise<Harness> {
|
||||
const schema = `ao_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const admin = new pg.Client({ connectionString: DB_URL });
|
||||
await admin.connect();
|
||||
await admin.query(`create schema ${schema}`);
|
||||
await admin.end();
|
||||
const db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: DB_URL, max: 6, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
});
|
||||
await migrateToLatest(db, { migrationTableSchema: schema });
|
||||
const logger = createLogger({ level: "silent" });
|
||||
const engine = fakeEngine();
|
||||
const config = ConfigSchema.parse({
|
||||
DATABASE_URL: DB_URL,
|
||||
SECRET_MASTER_KEY: "test-master-key-0123456789",
|
||||
DEV_ADMIN_KEY: DEV_KEY,
|
||||
});
|
||||
const outbox = createOutboxPort();
|
||||
const bus = new EventBus(50);
|
||||
const ctx: ServerContext = {
|
||||
role: "all",
|
||||
db,
|
||||
config,
|
||||
logger,
|
||||
events: bus,
|
||||
engine,
|
||||
outbox,
|
||||
secrets: {
|
||||
resolve: async () => "adminadmin",
|
||||
create: async () => "x",
|
||||
replace: async () => {},
|
||||
remove: async () => {},
|
||||
},
|
||||
// 不缓存的设置读取(测试里改 settings 行立即生效)
|
||||
settings: {
|
||||
async all() {
|
||||
const rows = await db.selectFrom("settings").select(["key", "value"]).execute();
|
||||
const stored = new Map(rows.map((r) => [r.key, r.value]));
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
|
||||
const v = stored.get(k);
|
||||
// 整理最小体积缺省跟随端口注入的 minVideoBytes(测试用小文件)
|
||||
const base =
|
||||
k === "organize"
|
||||
? { ...DEFAULT_SETTINGS.organize, minSizeBytes: overrides.options?.minVideoBytes ?? 100_000 }
|
||||
: DEFAULT_SETTINGS[k];
|
||||
out[k] = GlobalSettings.shape[k].parse({ ...base, ...((v as object) ?? {}) });
|
||||
}
|
||||
return out as GlobalSettings;
|
||||
},
|
||||
async get(k) {
|
||||
return (await this.all())[k];
|
||||
},
|
||||
},
|
||||
capabilities: createCapabilityRegistry({ logger }),
|
||||
};
|
||||
const module = createAcquisitionOrganizeModule({
|
||||
downloader: async (inst) =>
|
||||
createQbClient({
|
||||
instanceId: inst.id,
|
||||
baseUrl: inst.base_url,
|
||||
username: "admin",
|
||||
password: "adminadmin",
|
||||
lookup: { attempts: 3, intervalMs: 50 },
|
||||
}),
|
||||
probe: async () => ({ status: "unknown", reason: "ffprobe_unavailable" }),
|
||||
...overrides,
|
||||
options: { backgroundLoops: false, minVideoBytes: 100_000, verifyHash: "sha256", ...(overrides.options ?? {}) },
|
||||
});
|
||||
const app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger,
|
||||
bus,
|
||||
role: "all",
|
||||
version: "test",
|
||||
db,
|
||||
modules: opts.withSources ? [module, createSourcesIdentityModule()] : [module],
|
||||
ctx,
|
||||
apiKeyStore: {
|
||||
async findByHash(hash) {
|
||||
const k = KEYS.find((x) => hashApiKey(x.key) === hash);
|
||||
return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null;
|
||||
},
|
||||
async touch() {},
|
||||
},
|
||||
});
|
||||
const d = module.deps();
|
||||
return {
|
||||
module,
|
||||
db,
|
||||
ctx,
|
||||
d,
|
||||
engine,
|
||||
app,
|
||||
async api(method, path, body, key = DEV_KEY) {
|
||||
const res = await app.request(`/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${key}`,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
},
|
||||
async setSettings(key, value) {
|
||||
await sql`insert into settings (key, value) values (${key}, ${JSON.stringify(value)}::jsonb)
|
||||
on conflict (key) do update set value = excluded.value`.execute(db);
|
||||
},
|
||||
async outbox(type) {
|
||||
const r = await sql<{ type: string; payload: Loose; idempotency_key: string | null }>`
|
||||
select type, payload, idempotency_key from outbox_event ${type ? sql`where type = ${type}` : sql``} order by at, id`.execute(
|
||||
db,
|
||||
);
|
||||
return r.rows;
|
||||
},
|
||||
async close() {
|
||||
await db.destroy();
|
||||
const c = new pg.Client({ connectionString: DB_URL });
|
||||
await c.connect();
|
||||
await c.query(`drop schema if exists ${schema} cascade`);
|
||||
await c.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 种子数据 ──
|
||||
|
||||
export async function seedBase(
|
||||
db: Kysely<DB>,
|
||||
opts: { libraryPath: string; cachePath: string; qbUrl: string; mediaKind?: "movie" | "series" },
|
||||
) {
|
||||
const root = await db
|
||||
.insertInto("library_root")
|
||||
.values({
|
||||
name: `lib-${Math.random().toString(36).slice(2, 6)}`,
|
||||
media_kind: "mixed",
|
||||
local_path: opts.libraryPath,
|
||||
management_mode: "managed",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const inst = await db
|
||||
.insertInto("downloader_instance")
|
||||
.values({
|
||||
name: `qb-${Math.random().toString(36).slice(2, 6)}`,
|
||||
base_url: opts.qbUrl,
|
||||
username: "admin",
|
||||
settings: JSON.stringify({ savePath: opts.cachePath }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const site = await db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: `site${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: "测试站",
|
||||
adapter: "nexusphp",
|
||||
base_url: "https://pt.example.test",
|
||||
config: JSON.stringify({ hitAndRun: { minSeedingSec: 86_400 } }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return { rootId: root.id, instanceId: inst.id, siteId: site.id };
|
||||
}
|
||||
|
||||
export async function seedEntity(db: Kysely<DB>, e: { kind: "movie" | "series"; title: string; year: number }) {
|
||||
return (
|
||||
await db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: e.kind, title: e.title, year: e.year, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}
|
||||
|
||||
export async function seedRelease(
|
||||
db: Kysely<DB>,
|
||||
r: { siteId: string; entityId: string; title: string; size: number; hash: string; hr?: boolean | "unknown" },
|
||||
) {
|
||||
return (
|
||||
await db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: r.siteId,
|
||||
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
|
||||
title: r.title,
|
||||
size_bytes: r.size,
|
||||
infohash_v1: r.hash,
|
||||
media_entity_id: r.entityId,
|
||||
resolution_status: "resolved",
|
||||
raw: JSON.stringify({ _site: { hr: r.hr ?? false } }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}
|
||||
|
||||
export async function seedSlot(
|
||||
db: Kysely<DB>,
|
||||
s: { entityId: string; name?: string; hard?: Record<string, unknown>; upgrade?: Record<string, unknown> },
|
||||
) {
|
||||
const sub = await db
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: s.entityId, media_kind: "movie", mode: "continuous" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return (
|
||||
await db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: s.entityId,
|
||||
subscription_id: sub.id,
|
||||
name: s.name ?? "main",
|
||||
hard_conditions: JSON.stringify(s.hard ?? {}),
|
||||
upgrade_threshold: JSON.stringify(s.upgrade ?? {}),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import type { DB } from "@umt/db";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { backfillVersionSpecs } from "../services/version-backfill.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease, seedSlot } from "./harness.ts";
|
||||
|
||||
// LIBV-1:入库版本 API(每集多个版本、季汇总、单目标比对)与规格回填(dry-run / apply)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const GB = 1_000_000_000;
|
||||
|
||||
const PROBE_DV = {
|
||||
status: "ok",
|
||||
durationSec: 3300,
|
||||
video: [{ codec: "hevc", width: 3840, height: 2160, pixFmt: "yuv420p10le", hdr: "DV" }],
|
||||
audio: [{ codec: "eac3", profile: "Dolby Digital Plus + Dolby Atmos", channels: 6, language: "eng" }],
|
||||
subtitles: [{ language: "chi", title: "简体" }],
|
||||
};
|
||||
|
||||
async function seedAsset(
|
||||
db: Kysely<DB>,
|
||||
a: {
|
||||
entityId: string;
|
||||
rootId: string;
|
||||
path: string;
|
||||
size: number;
|
||||
units: string[];
|
||||
mediaInfo?: object;
|
||||
delivery?: object;
|
||||
},
|
||||
) {
|
||||
const asset = await db
|
||||
.insertInto("media_asset")
|
||||
.values({
|
||||
media_entity_id: a.entityId,
|
||||
kind: "video",
|
||||
size_bytes: a.size,
|
||||
media_info: JSON.stringify(a.mediaInfo ?? {}),
|
||||
delivery: JSON.stringify(a.delivery ?? {}),
|
||||
status: "active",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await db
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: asset.id,
|
||||
location_kind: "library",
|
||||
library_root_id: a.rootId,
|
||||
relative_path: a.path,
|
||||
status: "available",
|
||||
is_primary: true,
|
||||
size_bytes: a.size,
|
||||
})
|
||||
.execute();
|
||||
for (const u of a.units)
|
||||
await db.insertInto("asset_unit_coverage").values({ media_asset_id: asset.id, content_unit_id: u }).execute();
|
||||
return asset.id;
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
|
||||
let h: Harness;
|
||||
let rootId: string;
|
||||
let series: string;
|
||||
let movie: string;
|
||||
let bare: string;
|
||||
const eps: string[] = [];
|
||||
const ids: Record<string, string> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("libv1");
|
||||
rootId = (
|
||||
await h.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "剧集", media_kind: "mixed", local_path: "/media/tv/", management_mode: "managed" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
series = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({ media_entity_id: series, kind: "season", season_number: 1, status: "confirmed", episode_total: 3 })
|
||||
.execute();
|
||||
for (const n of [1, 2, 3])
|
||||
eps.push(
|
||||
(
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: series,
|
||||
kind: "episode",
|
||||
season_number: 1,
|
||||
episode_number: n,
|
||||
status: "confirmed",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id,
|
||||
);
|
||||
const dir = "人生切割术 (2022)/Season 1";
|
||||
ids.e1dv = await seedAsset(h.db, {
|
||||
entityId: series,
|
||||
rootId,
|
||||
path: `${dir}/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
|
||||
size: 14.2 * GB,
|
||||
units: [eps[0]!],
|
||||
mediaInfo: { probe: PROBE_DV, _umt: { quality: { resolution: "2160p", source: "WEB-DL" } } },
|
||||
delivery: { resolution: "2160p", sourceType: "WEB-DL" },
|
||||
});
|
||||
ids.e1bd = await seedAsset(h.db, {
|
||||
entityId: series,
|
||||
rootId,
|
||||
path: `${dir}/Severance.S01E01.1080p.BluRay.x264.DTS-HD.MA.5.1-CHD.mkv`,
|
||||
size: 8 * GB,
|
||||
units: [eps[0]!],
|
||||
});
|
||||
ids.e2 = await seedAsset(h.db, {
|
||||
entityId: series,
|
||||
rootId,
|
||||
path: `${dir}/Severance.S01E02.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
|
||||
size: 13 * GB,
|
||||
units: [eps[1]!],
|
||||
});
|
||||
// 附属外挂字幕
|
||||
const sub = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: series, kind: "subtitle", parent_asset_id: ids.e2, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: sub.id,
|
||||
library_root_id: rootId,
|
||||
relative_path: `${dir}/Severance.S01E02.chs.srt`,
|
||||
status: "available",
|
||||
})
|
||||
.execute();
|
||||
await seedSlot(h.db, { entityId: series, name: "4K", hard: { resolutions: ["2160p"] } });
|
||||
|
||||
movie = await seedEntity(h.db, { kind: "movie", title: "奥本海默", year: 2023 });
|
||||
ids.movie = await seedAsset(h.db, {
|
||||
entityId: movie,
|
||||
rootId,
|
||||
path: "奥本海默 (2023)/Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR10.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv",
|
||||
size: 80 * GB,
|
||||
units: [],
|
||||
});
|
||||
bare = await seedEntity(h.db, { kind: "movie", title: "没有版本目标", year: 2020 });
|
||||
await seedAsset(h.db, {
|
||||
entityId: bare,
|
||||
rootId,
|
||||
path: "x/Bare.2020.1080p.WEB-DL.H264-GRP.mkv",
|
||||
size: GB,
|
||||
units: [],
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("剧集:一集两个版本、季汇总、按版本目标逐集比对", async () => {
|
||||
const r = await h.api("GET", `/library/${series}/versions`);
|
||||
expect(r.status).toBe(200);
|
||||
const v = r.body.data;
|
||||
expect(v.kind).toBe("series");
|
||||
expect(v.targets).toEqual([{ slotId: expect.any(String), name: "4K" }]);
|
||||
expect(v.summary.versionCount).toBe(3);
|
||||
const s1 = v.seasons[0];
|
||||
expect(s1).toMatchObject({ seasonNumber: 1, episodeTotal: 3, episodesWithVersion: 2 });
|
||||
expect(s1.summary.sizeBytes).toBe(35.2 * GB);
|
||||
expect(s1.summary.specs[0]).toEqual({ resolution: "2160p", source: "WEB-DL", hdr: ["DV"], count: 2 });
|
||||
expect(s1.matchCounts).toEqual({ satisfied: 2, missing: 1, upgradeable: 0 });
|
||||
|
||||
const [e1, e2, e3] = s1.episodes;
|
||||
expect(e1.versions.map((x: { assetId: string }) => x.assetId).sort()).toEqual([ids.e1dv, ids.e1bd].sort());
|
||||
expect(e1.matches).toEqual([
|
||||
{ slotId: v.targets[0].slotId, state: "satisfied", assetId: ids.e1dv, reasons: [], codes: [] },
|
||||
]);
|
||||
const dv = e1.versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
|
||||
expect(dv).toMatchObject({
|
||||
resolution: "2160p",
|
||||
source: "WEB-DL",
|
||||
videoCodec: "H.265",
|
||||
hdr: ["DV", "HDR10"],
|
||||
releaseGroup: "FLUX",
|
||||
audio: [{ codec: "DDP Atmos", channels: "5.1", language: "en" }],
|
||||
subtitleLanguages: ["zh-Hans"],
|
||||
durationSec: 3300,
|
||||
sizeBytes: 14.2 * GB,
|
||||
specFrom: ["name", "probe"],
|
||||
specStored: false,
|
||||
locationStatus: "available",
|
||||
unitIds: [eps[0]],
|
||||
});
|
||||
expect(dv.absolutePath).toBe(
|
||||
"/media/tv/人生切割术 (2022)/Season 1/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv",
|
||||
);
|
||||
expect(dv.probe.status).toBe("ok");
|
||||
expect(e2.versions[0].subtitleLanguages).toEqual(["zh-Hans"]);
|
||||
expect(e3).toMatchObject({ episodeNumber: 3, versions: [] });
|
||||
expect(e3.matches[0]).toMatchObject({ state: "missing", assetId: null });
|
||||
expect(v.unplaced).toEqual([]);
|
||||
});
|
||||
|
||||
it("指定一个作品没有的版本目标 = 不比对;电影给出版本与比对", async () => {
|
||||
const r = await h.api("GET", `/library/${series}/versions?slotId=00000000-0000-4000-8000-000000000000`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.targets).toEqual([]);
|
||||
expect(r.body.data.seasons[0].episodes[0].matches).toBeUndefined();
|
||||
expect(r.body.data.seasons[0].matchCounts).toBeUndefined();
|
||||
|
||||
const m = await h.api("GET", `/library/${bare}/versions`);
|
||||
expect(m.body.data.targets).toEqual([]);
|
||||
expect(m.body.data.movie.versions).toHaveLength(1);
|
||||
expect(m.body.data.movie.matches).toBeUndefined();
|
||||
|
||||
await seedSlot(h.db, { entityId: movie, name: "1080p", hard: { resolutions: ["1080p"] } });
|
||||
const mv = await h.api("GET", `/library/${movie}/versions`);
|
||||
expect(mv.body.data.movie.versions[0]).toMatchObject({ source: "REMUX", resolution: "2160p", edition: null });
|
||||
expect(mv.body.data.movie.matches[0]).toMatchObject({ state: "upgradeable", assetId: ids.movie });
|
||||
|
||||
const nf = await h.api("GET", "/library/00000000-0000-4000-8000-000000000000/versions");
|
||||
expect(nf.status).toBe(404);
|
||||
});
|
||||
|
||||
it("回填:dry-run 只出清单不写库;apply 后再跑没有待更新", async () => {
|
||||
const before = await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select(["id", "delivery"])
|
||||
.where("kind", "=", "video")
|
||||
.execute();
|
||||
const dry = await backfillVersionSpecs(h.db, { entityId: series });
|
||||
expect(dry).toMatchObject({ apply: false, scanned: 3, upToDate: 0, changed: 3, probed: 0, noLocation: 0 });
|
||||
const item = dry.items.find((i) => i.assetId === ids.e1dv)!;
|
||||
expect(item).toMatchObject({ reason: "no_spec", entityTitle: "人生切割术", before: "2160p · WEB-DL" });
|
||||
expect(item.after).toBe("2160p · WEB-DL · H.265 · DV/HDR10 · DDP Atmos · 字幕 zh-Hans · FLUX");
|
||||
const after = await h.db.selectFrom("media_asset").select(["id", "delivery"]).where("kind", "=", "video").execute();
|
||||
expect(after).toEqual(before);
|
||||
|
||||
// 可选探测:只对缺探测结果的本地文件调用端口
|
||||
const probed: string[] = [];
|
||||
const withProbe = await backfillVersionSpecs(h.db, {
|
||||
entityId: series,
|
||||
probe: async (p) => {
|
||||
probed.push(p);
|
||||
return { status: "unknown" };
|
||||
},
|
||||
});
|
||||
expect(withProbe.probed).toBe(0);
|
||||
expect(probed).toHaveLength(2);
|
||||
expect(probed.every((p) => p.startsWith("/media/tv/人生切割术 (2022)/Season 1/"))).toBe(true);
|
||||
|
||||
const applied = await backfillVersionSpecs(h.db, { apply: true });
|
||||
expect(applied.changed).toBe(5);
|
||||
const row = await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select("delivery")
|
||||
.where("id", "=", ids.e1dv!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(row.delivery).toMatchObject({
|
||||
specVersion: 1,
|
||||
sourceType: "WEB-DL",
|
||||
releaseGroup: "FLUX",
|
||||
durationSec: 3300,
|
||||
});
|
||||
const again = await backfillVersionSpecs(h.db, {});
|
||||
expect(again).toMatchObject({ changed: 0, upToDate: 5 });
|
||||
|
||||
const r = await h.api("GET", `/library/${series}/versions`);
|
||||
const dv = r.body.data.seasons[0].episodes[0].versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
|
||||
expect(dv).toMatchObject({ specStored: true, hdr: ["DV", "HDR10"], releaseGroup: "FLUX" });
|
||||
});
|
||||
|
||||
it("作品详情保留跨过旧条数上限的整理、下载和失败计划供分页查看", async () => {
|
||||
const entityId = await seedEntity(h.db, { kind: "movie", title: "历史分页测试", year: 2026 });
|
||||
const instance = await h.db
|
||||
.insertInto("downloader_instance")
|
||||
.values({ name: "历史下载器", base_url: "http://127.0.0.1:1" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("download_task")
|
||||
.values(
|
||||
Array.from({ length: 105 }, (_, i) => ({
|
||||
instance_id: instance.id,
|
||||
media_entity_id: entityId,
|
||||
torrent_hash: String(i + 1).padStart(40, "0"),
|
||||
name: `历史下载 ${i + 1}`,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
const slotId = await seedSlot(h.db, { entityId, name: "历史版本" });
|
||||
const site = await h.db
|
||||
.insertInto("site")
|
||||
.values({ key: "history-source", name: "历史来源", base_url: "http://127.0.0.1:1", adapter: "nexusphp" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const releaseId = await seedRelease(h.db, {
|
||||
siteId: site.id,
|
||||
entityId,
|
||||
title: "历史分页测试",
|
||||
size: 1000,
|
||||
hash: "a".repeat(40),
|
||||
});
|
||||
await h.db
|
||||
.insertInto("acquisition_plan")
|
||||
.values(
|
||||
Array.from({ length: 51 }, (_, i) => ({
|
||||
slot_id: slotId,
|
||||
source_release_id: releaseId,
|
||||
status: "failed" as const,
|
||||
payload_hash: `history-${i}`,
|
||||
idempotency_key: `history:${i}`,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
await h.db
|
||||
.insertInto("organization_record")
|
||||
.values(
|
||||
Array.from({ length: 105 }, (_, i) => ({
|
||||
media_entity_id: entityId,
|
||||
trigger: "manual" as const,
|
||||
actor_type: "user" as const,
|
||||
action: "copy" as const,
|
||||
status: "succeeded" as const,
|
||||
source_path: `/history/${i}.mkv`,
|
||||
target_path: `/library/history/${i}.mkv`,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
const response = await h.api("GET", `/library/${entityId}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.organizationRecords).toHaveLength(105);
|
||||
expect(response.body.data.downloads).toHaveLength(105);
|
||||
expect(response.body.data.droppedPlans).toHaveLength(51);
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import { ManualAcquisitionRequest } from "@umt/contracts";
|
||||
import { createMockQb, createTempTree, makeTorrent } from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../services/acquisition.ts";
|
||||
import { submitDownload } from "../services/downloads.ts";
|
||||
import { planManualAcquisition } from "../services/manual-acquisition.ts";
|
||||
import { createHarness, fakeDisk, pgAvailable, seedBase, seedEntity, seedRelease, seedSlot } from "./harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
describe.skipIf(!hasPg)("独立手动下载", () => {
|
||||
it("无订阅下载,未知资源确认,同hash沿用原路径", async () => {
|
||||
const qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
const cache = await createTempTree({});
|
||||
const lib = await createTempTree({}, { sentinel: true });
|
||||
const torrents = new Map<string, Uint8Array>();
|
||||
const disk = fakeDisk(10_000_000);
|
||||
disk.setPct(20);
|
||||
const h = await createHarness("manual_acquisition", {
|
||||
diskUsage: disk.fn,
|
||||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||||
});
|
||||
try {
|
||||
const base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
const entityId = await seedEntity(h.db, { kind: "movie", title: "Manual Movie", year: 2026 });
|
||||
const torrent = makeTorrent({ name: "Manual.Movie.2026.1080p.mkv", length: 300_000 });
|
||||
const relId = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId,
|
||||
title: torrent.name,
|
||||
size: 300_000,
|
||||
hash: torrent.hash,
|
||||
});
|
||||
torrents.set(relId, torrent.bytes);
|
||||
const request = (id: string, opts = {}) => ManualAcquisitionRequest.parse({ sourceReleaseId: id, ...opts });
|
||||
const first = await planManualAcquisition(h.ctx, request(relId));
|
||||
expect(first).toMatchObject({ submitted: true, slotId: null });
|
||||
expect(await h.db.selectFrom("subscription").selectAll().execute()).toHaveLength(0);
|
||||
expect(await h.db.selectFrom("acquisition_slot").selectAll().execute()).toHaveLength(0);
|
||||
const sub = await submitDownload(h.d, first.plan!.id);
|
||||
expect(sub.status).toBe("submitted");
|
||||
const rel2 = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId,
|
||||
title: "Other.Release.2160p.mkv",
|
||||
size: 300_000,
|
||||
hash: torrent.hash,
|
||||
});
|
||||
torrents.set(rel2, torrent.bytes);
|
||||
const second = await planManualAcquisition(h.ctx, request(rel2, { savePath: `${cache.root}/other` }));
|
||||
const reused = await submitDownload(h.d, second.plan!.id);
|
||||
expect(reused).toMatchObject({ reason: "already_exists", downloadTaskId: sub.downloadTaskId });
|
||||
const dt = await h.db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("id", "=", sub.downloadTaskId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(dt.save_path).toBe(cache.root);
|
||||
expect(dt.media_entity_id).toBe(entityId);
|
||||
const unknown = makeTorrent({ name: "Unknown.1080p.mkv", length: 300_000, pieceSeed: 42 });
|
||||
const uid = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId,
|
||||
title: unknown.name,
|
||||
size: 300_000,
|
||||
hash: unknown.hash,
|
||||
});
|
||||
await h.db.updateTable("source_release").set({ media_entity_id: null }).where("id", "=", uid).execute();
|
||||
torrents.set(uid, unknown.bytes);
|
||||
expect(await planManualAcquisition(h.ctx, request(uid))).toMatchObject({
|
||||
submitted: false,
|
||||
requiresConfirmation: true,
|
||||
});
|
||||
const confirmed = await planManualAcquisition(h.ctx, request(uid, { allowUnrecognized: true }));
|
||||
const submitted = await submitDownload(h.d, confirmed.plan!.id);
|
||||
expect(submitted.status).toBe("submitted");
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("download_task")
|
||||
.select("media_entity_id")
|
||||
.where("id", "=", submitted.downloadTaskId!)
|
||||
.executeTakeFirstOrThrow()
|
||||
).media_entity_id,
|
||||
).toBeNull();
|
||||
const autoEntity = await seedEntity(h.db, { kind: "movie", title: "Auto Movie", year: 2026 });
|
||||
const slotId = await seedSlot(h.db, { entityId: autoEntity });
|
||||
const autoTorrent = makeTorrent({
|
||||
name: "Auto.Movie.2026.1080p.BluRay.x264-GRP.mkv",
|
||||
length: 300_000,
|
||||
pieceSeed: 100,
|
||||
});
|
||||
const autoRelease = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: autoEntity,
|
||||
title: autoTorrent.name,
|
||||
size: 300_000,
|
||||
hash: autoTorrent.hash,
|
||||
});
|
||||
torrents.set(autoRelease, autoTorrent.bytes);
|
||||
const auto = await evaluateAcquisition(h.d, { sourceReleaseId: autoRelease });
|
||||
expect(auto.plans).toHaveLength(1);
|
||||
const autoTask = await submitDownload(h.d, auto.plans[0]!);
|
||||
const oldAsset = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: autoEntity, kind: "video", size_bytes: 300_000, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.updateTable("download_task")
|
||||
.set({ status: "imported" })
|
||||
.where("id", "=", autoTask.downloadTaskId!)
|
||||
.execute();
|
||||
const unchanged = await planManualAcquisition(h.ctx, request(autoRelease));
|
||||
expect(unchanged.plan!.id).toBe(auto.plans[0]);
|
||||
const wash = await planManualAcquisition(
|
||||
h.ctx,
|
||||
request(autoRelease, { importMode: "replace", replacementAssetIds: [oldAsset.id] }),
|
||||
);
|
||||
const washed = await submitDownload(h.d, wash.plan!.id);
|
||||
expect(washed.downloadTaskId).toBe(autoTask.downloadTaskId);
|
||||
const linked = await h.db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("id", "=", autoTask.downloadTaskId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(linked).toMatchObject({
|
||||
acquisition_plan_id: auto.plans[0],
|
||||
import_plan_id: wash.plan!.id,
|
||||
status: "completed",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.select("slot_id")
|
||||
.where("id", "=", auto.plans[0]!)
|
||||
.executeTakeFirstOrThrow()
|
||||
).slot_id,
|
||||
).toBe(slotId);
|
||||
expect(h.engine.enqueued).toContainEqual(
|
||||
expect.objectContaining({
|
||||
task: "verifyAndImport",
|
||||
input: expect.objectContaining({ downloadTaskId: autoTask.downloadTaskId }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await h.close();
|
||||
await qb.close();
|
||||
await cache.cleanup();
|
||||
await lib.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { DEFAULT_NAMING_TEMPLATES } from "@umt/contracts";
|
||||
import { LEGACY_V1_NAMING, MP_DEFAULT_NAMING, namingCanonicalJson } from "@umt/domain";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { ensureNamingVersion, namingByVersion } from "../services/naming.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// W9-C 命名模板:变量表、预览(样本/错误/路径绑定)、三级模板读写与保存前校验、模板版本登记。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
it("contracts 默认模板 = domain MP 默认", () => {
|
||||
expect(DEFAULT_NAMING_TEMPLATES).toEqual(MP_DEFAULT_NAMING);
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("命名模板 API(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
let rootId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("naming");
|
||||
({ rootId } = await seedBase(h.db, {
|
||||
libraryPath: "/media/lib",
|
||||
cachePath: "/cache",
|
||||
qbUrl: "http://127.0.0.1:1",
|
||||
}));
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("迁移种子:版本 1 = v1 旧模板,2 = MP 默认,指纹与 domain 同算法;新模板集按内容登记版本", async () => {
|
||||
const rows = await h.db.selectFrom("naming_template_version").selectAll().orderBy("version").execute();
|
||||
const fp = (t: typeof MP_DEFAULT_NAMING) => createHash("sha256").update(namingCanonicalJson(t)).digest("hex");
|
||||
expect(rows.map((r) => [r.version, r.fingerprint])).toEqual([
|
||||
[1, fp(LEGACY_V1_NAMING)],
|
||||
[2, fp(MP_DEFAULT_NAMING)],
|
||||
]);
|
||||
expect((await ensureNamingVersion(h.db, { ...MP_DEFAULT_NAMING })).version).toBe(2);
|
||||
const custom = { ...MP_DEFAULT_NAMING, movie: "{{title}}/{{title}}{{fileExt}}" };
|
||||
const v = (await ensureNamingVersion(h.db, custom)).version;
|
||||
expect(v).toBeGreaterThanOrEqual(3);
|
||||
expect((await ensureNamingVersion(h.db, custom)).version).toBe(v);
|
||||
expect(await namingByVersion(h.db, v)).toEqual({ ...custom, version: v });
|
||||
expect(await namingByVersion(h.db, 1)).toEqual({ ...LEGACY_V1_NAMING, version: 1 });
|
||||
expect(await namingByVersion(h.db, 9999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GET /naming/variables", async () => {
|
||||
const r = await h.api("GET", "/naming/variables");
|
||||
expect(r.status).toBe(200);
|
||||
const names = r.body.data.items.map((i: { name: string }) => i.name);
|
||||
expect(names).toEqual(expect.arrayContaining(["title", "videoFormat", "season_episode", "fileExt", "slotName"]));
|
||||
expect(r.body.data.items.find((i: { name: string }) => i.name === "airDate")).toMatchObject({ ours: true });
|
||||
expect(r.body.data.filters.map((f: { name: string }) => f.name)).toContain("pad");
|
||||
expect(r.body.data.samples).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("POST /naming/preview:内置样本 / 手填文件名 / 编译错误行列号 / 提示", async () => {
|
||||
const a = await h.api("POST", "/naming/preview", { kind: "tv", sample: { builtin: 3 } });
|
||||
expect(a.status).toBe(200);
|
||||
expect(a.body.data).toMatchObject({
|
||||
ok: true,
|
||||
relativePath: "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv",
|
||||
usesBinding: false,
|
||||
templateSource: "global",
|
||||
segments: [
|
||||
{ text: "绝命毒师 (2008)", role: "root" },
|
||||
{ text: "Season 1", role: "season" },
|
||||
{ text: "绝命毒师 - S01E01 - 第 1 集.mkv", role: "file" },
|
||||
],
|
||||
});
|
||||
const b = await h.api("POST", "/naming/preview", {
|
||||
kind: "movie",
|
||||
template: "{{title}} ({{year}})/{{title}} - {{videoFormat}}{{fileExt}}",
|
||||
sample: { fileName: "Inception.2010.1080p.BluRay.x264-CMCT.mkv" },
|
||||
});
|
||||
expect(b.body.data).toMatchObject({
|
||||
ok: true,
|
||||
relativePath: "Inception (2010)/Inception - 1080p.mkv",
|
||||
templateSource: "input",
|
||||
});
|
||||
expect(b.body.data.warnings).toEqual(expect.arrayContaining([expect.stringContaining("year 可能为空")]));
|
||||
const c = await h.api("POST", "/naming/preview", {
|
||||
kind: "movie",
|
||||
template: "{{title}}/\n{{videoformat}}{{fileExt}}",
|
||||
});
|
||||
expect(c.body.data).toMatchObject({ ok: false, relativePath: "" });
|
||||
expect(c.body.data.errors[0]).toMatchObject({ code: "unknown_variable", line: 2, col: 3 });
|
||||
expect(c.body.data.errors[0].message).toContain("videoFormat");
|
||||
});
|
||||
|
||||
it("三级覆盖:全局保存校验、库根与作品覆盖、GET /naming/templates 生效来源", async () => {
|
||||
const bad = await h.api("PUT", "/naming/templates", { movie: "{{title}}/{{title}}" });
|
||||
expect(bad.status).toBe(422);
|
||||
const ok = await h.api("PUT", "/naming/templates", {
|
||||
movie: "{{title_year}}/{{title}}{{fileExt}}",
|
||||
lockFileNameForBound: true,
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.data).toMatchObject({ movie: "{{title_year}}/{{title}}{{fileExt}}", lockFileNameForBound: true });
|
||||
expect((await h.ctx.settings.get("naming")).lockFileNameForBound).toBe(true);
|
||||
|
||||
const e = await seedEntity(h.db, { kind: "series", title: "绝命毒师", year: 2008 });
|
||||
expect((await h.api("PUT", `/library-roots/${rootId}/naming`, { naming: { tv: "{{title}}" } })).status).toBe(422);
|
||||
expect(
|
||||
(
|
||||
await h.api("PUT", `/library-roots/${rootId}/naming`, {
|
||||
naming: { tv: "{{title}}/{{title}} {{season_episode}}{{fileExt}}" },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
const put = await h.api("PUT", `/media-entities/${e}/naming-override`, {
|
||||
namingOverride: { variety: "{{title}}/{{title}} {{airDate}}{{fileExt}}" },
|
||||
});
|
||||
expect(put.body.data).toEqual({ namingOverride: { variety: "{{title}}/{{title}} {{airDate}}{{fileExt}}" } });
|
||||
const view = await h.api("GET", `/naming/templates?libraryRootId=${rootId}&entityId=${e}`);
|
||||
expect(view.body.data.sources).toEqual({ movie: "global", tv: "root", variety: "entity" });
|
||||
expect(view.body.data.effective.tv).toBe("{{title}}/{{title}} {{season_episode}}{{fileExt}}");
|
||||
|
||||
const pv = await h.api("POST", "/naming/preview", {
|
||||
kind: "tv",
|
||||
entityId: e,
|
||||
libraryRootId: rootId,
|
||||
sample: { fileName: "Breaking.Bad.S01E02.1080p.WEB-DL.H264-NTb.mkv" },
|
||||
});
|
||||
expect(pv.body.data).toMatchObject({
|
||||
ok: true,
|
||||
relativePath: "绝命毒师/绝命毒师 S01E02.mkv",
|
||||
templateSource: "root",
|
||||
});
|
||||
|
||||
await h.api("PUT", `/library-roots/${rootId}/naming`, { naming: null });
|
||||
await h.api("PUT", "/naming/templates", { ...MP_DEFAULT_NAMING, lockFileNameForBound: false });
|
||||
});
|
||||
|
||||
it("预览:已有路径绑定时目录用绑定路径,文件名按模板", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "series", title: "新译名", year: 2008 });
|
||||
await h.db
|
||||
.insertInto("path_binding")
|
||||
.values([
|
||||
{
|
||||
media_entity_id: e,
|
||||
binding_kind: "entity",
|
||||
library_root_id: rootId,
|
||||
relative_path: "旧名 (2008)",
|
||||
template_version: "1",
|
||||
},
|
||||
{
|
||||
media_entity_id: e,
|
||||
binding_kind: "season",
|
||||
season_number: 1,
|
||||
library_root_id: rootId,
|
||||
relative_path: "旧名 (2008)/S1",
|
||||
template_version: "1",
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
const r = await h.api("POST", "/naming/preview", {
|
||||
kind: "tv",
|
||||
entityId: e,
|
||||
sample: { fileName: "Show.S01E03.1080p.WEB-DL.H264-NTb.mkv" },
|
||||
});
|
||||
expect(r.body.data).toMatchObject({
|
||||
ok: true,
|
||||
usesBinding: true,
|
||||
relativePath: "旧名 (2008)/S1/新译名 - S01E03 - 第 3 集.mkv",
|
||||
segments: [
|
||||
{ text: "旧名 (2008)", role: "root" },
|
||||
{ text: "S1", role: "season" },
|
||||
{ text: "新译名 - S01E03 - 第 3 集.mkv", role: "file" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord, type FrozenPlan, planImport } from "../services/organize.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// W4-E4 执行侧:replace_with_verify 接线(写临时→校验→原子替换,旧文件进回收目录)、写前清 partial、
|
||||
// 对账比对挂死不误报 EEXIST 而是 TIMEOUT → 有限次重试 → 超限建待处理(不删记录)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 200_000;
|
||||
|
||||
describe.skipIf(!hasPg)("整理执行:onExisting / partial 清理 / TIMEOUT 重试", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-fslib-" });
|
||||
src = await createTempTree(
|
||||
{ "Replace.Me.2020.1080p.BluRay.x264-GRP.mkv": SIZE, "Hang.Case.2019.1080p.BluRay.x264-GRP.mkv": SIZE },
|
||||
{ prefix: "umt-fssrc-" },
|
||||
);
|
||||
h = await createHarness("fsexec");
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
const plannedDst = async (entity: string, file: string) => {
|
||||
const dry = await planImport(h.d, {
|
||||
sourcePaths: [src.path(file)],
|
||||
mediaEntityId: entity,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
dryRun: true,
|
||||
});
|
||||
return dry.plan!.organize.files.find((f) => f.role === "main")!.dst;
|
||||
};
|
||||
|
||||
it("replace_with_verify + 授权:原子替换旧文件,旧文件进 .umt-trash;写前清走 >24h 的 partial", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "替换片", year: 2020 });
|
||||
const file = "Replace.Me.2020.1080p.BluRay.x264-GRP.mkv";
|
||||
const dst = await plannedDst(e, file);
|
||||
await writeFile(dst, "OLD-VERSION").catch(async () => {
|
||||
execFileSync("mkdir", ["-p", dirname(dst)]);
|
||||
await writeFile(dst, "OLD-VERSION");
|
||||
});
|
||||
const stale = join(dirname(dst), ".x.mkv.1.abc.uhd-partial");
|
||||
await writeFile(stale, "half");
|
||||
const old = new Date(Date.now() - 48 * 3600_000);
|
||||
await utimes(stale, old, old);
|
||||
|
||||
// 无授权:阻塞待处理,不执行
|
||||
const blocked = await planImport(h.d, {
|
||||
sourcePaths: [src.path(file)],
|
||||
mediaEntityId: e,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
onExisting: "replace_with_verify",
|
||||
});
|
||||
expect(blocked.status).toBe("failed");
|
||||
expect(blocked.reason).toContain("replace_requires_grant");
|
||||
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(file)],
|
||||
mediaEntityId: e,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
onExisting: "replace_with_verify",
|
||||
grantReplace: true,
|
||||
});
|
||||
expect(plan.status).toBe("running");
|
||||
const rec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select("plan")
|
||||
.where("id", "=", plan.recordId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((rec.plan as unknown as FrozenPlan).organize.files[0]!.action).toBe("replace");
|
||||
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
expect(ex.status).toBe("succeeded");
|
||||
expect((await readFile(dst)).equals(await readFile(src.path(file)))).toBe(true);
|
||||
const op = await h.db
|
||||
.selectFrom("asset_operation")
|
||||
.select("result")
|
||||
.where("operation_id", "=", `org:${plan.recordId}:0`)
|
||||
.executeTakeFirstOrThrow();
|
||||
const r = op.result as { replaced?: { disposition: string; path: string; trashItemId?: string } };
|
||||
expect(r.replaced?.disposition).toBe("trash");
|
||||
expect(r.replaced!.path.startsWith(join(lib.root, ".umt-trash"))).toBe(true);
|
||||
expect(await readFile(r.replaced!.path, "utf8")).toBe("OLD-VERSION");
|
||||
// 旧文件登记进文件管理回收站:布局 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,可还原/清除
|
||||
const ti = await h.db
|
||||
.selectFrom("trash_item")
|
||||
.selectAll()
|
||||
.where("id", "=", r.replaced!.trashItemId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(ti.trash_path).toBe(r.replaced!.path);
|
||||
expect(ti.root_id).toBe(`lib:${ti.library_root_id}`);
|
||||
expect(ti.trash_path.endsWith(`/${ti.original_path}`)).toBe(true);
|
||||
expect(join(ti.root_host_path, ti.original_path)).toBe(dst);
|
||||
expect(Number(ti.size_bytes)).toBe("OLD-VERSION".length);
|
||||
expect((await readdir(join(lib.root, ".umt-trash"))).filter((n) => n.startsWith("replaced-"))).toEqual([]);
|
||||
expect(existsSync(stale)).toBe(false);
|
||||
expect((await readdir(dirname(dst))).filter((n) => n.startsWith("."))).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it("对账比对挂死 → TIMEOUT(不是 EEXIST)→ 有限次重试 → 超限建待处理,记录保留", async () => {
|
||||
const ctx = h.d.ctx as { config: Record<string, unknown> };
|
||||
const saved = ctx.config;
|
||||
ctx.config = { ...saved, FS_HANG_TIMEOUT_MS: 1000, FS_KILL_GRACE_MS: 300, FS_TRANSFER_MAX_ATTEMPTS: 3 };
|
||||
try {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "挂死片", year: 2019 });
|
||||
const file = "Hang.Case.2019.1080p.BluRay.x264-GRP.mkv";
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(file)],
|
||||
mediaEntityId: e,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
});
|
||||
expect(plan.status).toBe("running");
|
||||
const f = (plan.plan ??
|
||||
((
|
||||
await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select("plan")
|
||||
.where("id", "=", plan.recordId!)
|
||||
.executeTakeFirstOrThrow()
|
||||
).plan as unknown as FrozenPlan))!.organize.files[0]!;
|
||||
// 源换成 FIFO(读会卡在 open)、目标放一个同尺寸(0)的文件:对账走 sha256 比对并挂死
|
||||
await rm(f.src);
|
||||
execFileSync("mkfifo", [f.src]);
|
||||
execFileSync("mkdir", ["-p", dirname(f.dst)]);
|
||||
await writeFile(f.dst, "");
|
||||
|
||||
for (const attempt of [1, 2]) {
|
||||
const err = await executeRecord(h.d, plan.recordId!).catch((x) => x);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(String(err.message)).toContain(`TIMEOUT@${f.src}(第 ${attempt} 次)`);
|
||||
const fr = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["status"])
|
||||
.where("organization_record_id", "=", plan.recordId!)
|
||||
.where("role", "=", "main")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(fr.status).toBe("running");
|
||||
}
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
expect(ex.status).toBe("failed");
|
||||
const fr = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["status", "error"])
|
||||
.where("organization_record_id", "=", plan.recordId!)
|
||||
.where("role", "=", "main")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(fr.status).toBe("failed");
|
||||
expect((fr.error as { code: string }).code).toBe("TIMEOUT");
|
||||
const dec = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["kind", "title", "evidence"])
|
||||
.where("dedupe_key", "=", `fs_retry_exhausted:org:${plan.recordId}:0`)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(dec.title).toContain("挂死");
|
||||
expect((dec.evidence as { attempts: number }).attempts).toBe(3);
|
||||
// 记录保留,目标未被覆盖/删除
|
||||
const rec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select("status")
|
||||
.where("id", "=", plan.recordId!)
|
||||
.executeTakeFirst();
|
||||
expect(rec?.status).toBe("failed");
|
||||
expect(existsSync(f.dst)).toBe(true);
|
||||
await rm(f.src);
|
||||
} finally {
|
||||
ctx.config = saved;
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -1,275 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { sql } from "@umt/db";
|
||||
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../services/downloads.ts";
|
||||
import {
|
||||
createHarness,
|
||||
EXEC_KEY,
|
||||
fakeDisk,
|
||||
type Harness,
|
||||
pgAvailable,
|
||||
seedBase,
|
||||
seedEntity,
|
||||
seedRelease,
|
||||
seedSlot,
|
||||
} from "./harness.ts";
|
||||
|
||||
// 验收 R4:POST /organization-records 的三道闸。
|
||||
// 路径守卫(400):根外路径 / `..` / 符号链接逃逸(顶层与目录内)/ 不存在;dryRun 同样受限。
|
||||
// Move 授权(403):API Key 无授权、授权范围不含目标作品 → 拒绝且不留记录;覆盖作品的持续授权、
|
||||
// 与本次请求一致的一次性审批(用后 consumed)、管理员 → 放行;Copy 与 dryRun 不要求授权。
|
||||
// 做种义务(409):来源是纳管下载任务(按 ID 或按路径命中)且 H&R 未满足 → 冲突 + 待处理,不改 Copy、不动 qB。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 150_000;
|
||||
|
||||
describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
let outside: TempTree;
|
||||
const disk = fakeDisk(100_000_000);
|
||||
const torrents = new Map<string, Uint8Array>();
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
let movie: string;
|
||||
|
||||
const post = (body: Record<string, unknown>, key?: string) => h.api("POST", "/organization-records", body, key);
|
||||
const records = async (entityId: string) =>
|
||||
h.db.selectFrom("organization_record").select(["id", "action"]).where("media_entity_id", "=", entityId).execute();
|
||||
const file = async (rel: string) => cache.write(rel, SIZE);
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-glib-" });
|
||||
cache = await createTempTree({}, { prefix: "umt-gcache-" });
|
||||
outside = await createTempTree(
|
||||
{ "Secret.2020.1080p.BluRay-X.mkv": SIZE, "etc/passwd": 100 },
|
||||
{ prefix: "umt-gout-" },
|
||||
);
|
||||
disk.state.watch = [cache.root];
|
||||
disk.setPct(10);
|
||||
h = await createHarness("guard", {
|
||||
diskUsage: disk.fn,
|
||||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||||
});
|
||||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
movie = await seedEntity(h.db, { kind: "movie", title: "守卫片", year: 2020 });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
for (const t of [lib, cache, outside]) await t?.cleanup();
|
||||
});
|
||||
|
||||
it("路径守卫:根外 / `..` / 符号链接逃逸 / 不存在 → 400,dryRun 也不例外,且不留记录", async () => {
|
||||
const outFile = outside.path("Secret.2020.1080p.BluRay-X.mkv");
|
||||
for (const dryRun of [true, false]) {
|
||||
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.error.message).toContain("不在已登记的媒体库目录或下载器路径映射之下");
|
||||
}
|
||||
// `..` 即使规范化后仍在根内也拒绝
|
||||
const inside = await file("Dots.2020.1080p.BluRay-X.mkv");
|
||||
const dotted = `${cache.root}/sub/../Dots.2020.1080p.BluRay-X.mkv`;
|
||||
expect((await post({ sourcePaths: [dotted], mediaEntityId: movie, action: "copy" })).status).toBe(400);
|
||||
// 顶层符号链接:根内的链接指向根外文件
|
||||
const link = join(cache.root, "Link.2020.1080p.BluRay-X.mkv");
|
||||
await symlink(outFile, link);
|
||||
const r1 = await post({ sourcePaths: [link], mediaEntityId: movie, action: "copy", dryRun: true });
|
||||
expect(r1.status).toBe(400);
|
||||
// 目录内的符号链接:遍历用 lstat,不跟随链接(文件链接、目录链接都不进计划),根外内容不会被列出
|
||||
const dir = join(cache.root, "Pack.2020.1080p.BluRay-X");
|
||||
await mkdir(dir);
|
||||
await writeFile(join(dir, "Pack.2020.1080p.BluRay-X.mkv"), Buffer.alloc(SIZE, 1));
|
||||
await symlink(outside.root, join(dir, "escape"));
|
||||
await symlink(outFile, join(dir, "Pack.2020.1080p.BluRay-X.sample.mkv"));
|
||||
const r2 = await post({ sourcePaths: [dir], mediaEntityId: movie, action: "copy", dryRun: true });
|
||||
expect(r2.status).toBe(201);
|
||||
const planArt = await h.db
|
||||
.selectFrom("artifact")
|
||||
.select("content")
|
||||
.where("id", "=", r2.body.data.planId)
|
||||
.executeTakeFirstOrThrow();
|
||||
const srcs = (planArt.content as { plan: { organize: { files: { src: string }[] } } }).plan.organize.files.map(
|
||||
(f) => f.src,
|
||||
);
|
||||
expect(srcs).toEqual([join(dir, "Pack.2020.1080p.BluRay-X.mkv")]);
|
||||
expect(JSON.stringify(planArt.content)).not.toContain(outside.root);
|
||||
// 不存在
|
||||
expect(
|
||||
(await post({ sourcePaths: [join(cache.root, "nope.mkv")], mediaEntityId: movie, action: "copy" })).status,
|
||||
).toBe(400);
|
||||
expect(await records(movie)).toHaveLength(0);
|
||||
expect(existsSync(outFile)).toBe(true);
|
||||
// 根内正常路径:dryRun 通过
|
||||
const ok = await post({ sourcePaths: [inside], mediaEntityId: movie, action: "copy", dryRun: true }, EXEC_KEY);
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body.data.planId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Move 授权:API Key 无授权 / 范围不含作品 → 403 不留记录;dryRun 与 Copy 不要求授权", async () => {
|
||||
const src = await file("Auth.2020.1080p.BluRay-X.mkv");
|
||||
const denied = await post({ sourcePaths: [src], mediaEntityId: movie, action: "move" }, EXEC_KEY);
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body.error.message).toContain("需要");
|
||||
expect(await records(movie)).toHaveLength(0);
|
||||
expect(existsSync(src)).toBe(true);
|
||||
|
||||
// 授权范围只含别的作品
|
||||
const other = await seedEntity(h.db, { kind: "movie", title: "别的片", year: 2019 });
|
||||
const g = await h.db
|
||||
.insertInto("automation_grant")
|
||||
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [other] }), granted_by: "test" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const scoped = await post({ sourcePaths: [src], mediaEntityId: movie, action: "move" }, EXEC_KEY);
|
||||
expect(scoped.status).toBe(403);
|
||||
expect(scoped.body.error.message).toContain("对象范围不含目标作品");
|
||||
// 没有目标作品时持续授权无从核对
|
||||
expect((await post({ sourcePaths: [src], action: "move" }, EXEC_KEY)).status).toBe(403);
|
||||
|
||||
// dryRun Move / Copy:只要 organize.execute
|
||||
expect(
|
||||
(await post({ sourcePaths: [src], mediaEntityId: movie, action: "move", dryRun: true }, EXEC_KEY)).status,
|
||||
).toBe(201);
|
||||
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", g.id).execute();
|
||||
const audits = await sql<{ n: number }>`
|
||||
select count(*)::int as n from audit_event where action = 'organize.move_denied'`.execute(h.db);
|
||||
expect(audits.rows[0]!.n).toBe(3);
|
||||
});
|
||||
|
||||
it("Move 授权:覆盖作品的持续授权放行;策略版本非发布状态不算", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "授权片", year: 2021 });
|
||||
const src = await file("Granted.2021.1080p.BluRay-X.mkv");
|
||||
const pol = await h.db
|
||||
.insertInto("policy_revision")
|
||||
.values({ policy_key: "g", revision: 1, kind: "acquisition", title: "g", status: "draft" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("automation_grant")
|
||||
.values({
|
||||
action: "policy.acquisition",
|
||||
policy_revision_id: pol.id,
|
||||
scope: JSON.stringify({ mediaKinds: ["movie"] }),
|
||||
granted_by: "policy:g",
|
||||
})
|
||||
.execute();
|
||||
const draft = await post({ sourcePaths: [src], mediaEntityId: e, action: "move" }, EXEC_KEY);
|
||||
expect(draft.status).toBe(403);
|
||||
expect(draft.body.error.message).toContain("不是发布状态");
|
||||
const g = await h.db
|
||||
.insertInto("automation_grant")
|
||||
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [e] }), granted_by: "test" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const ok = await post({ sourcePaths: [src], mediaEntityId: e, action: "move" }, EXEC_KEY);
|
||||
expect(ok.status).toBe(201);
|
||||
expect((await records(e)).map((r) => r.action)).toEqual(["move"]);
|
||||
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", g.id).execute();
|
||||
});
|
||||
|
||||
it("Move 授权:一次性审批绑定 dryRun 计划,请求一致才放行,用后 consumed 不可复用", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "审批片", year: 2022 });
|
||||
const src = await file("Approved.2022.1080p.BluRay-X.mkv");
|
||||
const req = { sourcePaths: [src], mediaEntityId: e, action: "move" as const };
|
||||
const dry = await post({ ...req, dryRun: true }, EXEC_KEY);
|
||||
expect(dry.status).toBe(201);
|
||||
const art = await h.db
|
||||
.selectFrom("artifact")
|
||||
.select(["id", "content_hash", "content"])
|
||||
.where("id", "=", dry.body.data.planId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(art.content).toMatchObject({ mediaEntityId: e, mediaKind: "movie" });
|
||||
const approve = (hash: string) =>
|
||||
h.db
|
||||
.insertInto("approval")
|
||||
.values({
|
||||
plan_type: "organize_plan",
|
||||
plan_id: art.id,
|
||||
payload_hash: hash,
|
||||
expected_revision: 1,
|
||||
status: "approved",
|
||||
requested_by: "api_key:x",
|
||||
decided_by: "admin_session:a",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
// 请求与计划不一致(换了目标库根字段)→ 不算
|
||||
const a = await approve(art.content_hash);
|
||||
const mismatch = await post({ ...req, action: "move", targetLibraryRootId: base.rootId }, EXEC_KEY);
|
||||
expect(mismatch.status).toBe(403);
|
||||
const ok = await post(req, EXEC_KEY);
|
||||
expect(ok.status).toBe(201);
|
||||
const used = await h.db.selectFrom("approval").select("status").where("id", "=", a.id).executeTakeFirstOrThrow();
|
||||
expect(used.status).toBe("consumed");
|
||||
// 用过的审批不能再放行第二次
|
||||
const again = await post(req, EXEC_KEY);
|
||||
expect(again.status).toBe(403);
|
||||
// payloadHash 与计划内容不符的审批无效
|
||||
await approve("tampered");
|
||||
expect((await post(req, EXEC_KEY)).status).toBe(403);
|
||||
});
|
||||
|
||||
it("做种义务:纳管下载任务 H&R 未满足 → 409 + 待处理,不改 Copy、不动 qB;管理员 Copy 可以", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "做种片", year: 2023 });
|
||||
await seedSlot(h.db, { entityId: e });
|
||||
const t = makeTorrent({ name: "Seeding.2023.1080p.BluRay-HR.mkv", length: SIZE, pieceSeed: 11 });
|
||||
const relId = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: e,
|
||||
title: "Seeding.2023.1080p.BluRay-HR",
|
||||
size: SIZE,
|
||||
hash: t.hash,
|
||||
hr: true,
|
||||
});
|
||||
torrents.set(relId, t.bytes);
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
|
||||
const sub = await submitDownload(h.d, ev.plans[0]!);
|
||||
await qb.complete(t.hash, { writeFiles: true });
|
||||
await syncDownloads(h.d);
|
||||
const dtId = sub.downloadTaskId!;
|
||||
const deletesBefore = qb.callsTo("torrents/delete").length;
|
||||
|
||||
// 按下载任务 ID(管理员也一样被做种义务拦下)
|
||||
const byId = await post({ downloadTaskId: dtId, action: "move" });
|
||||
expect(byId.status).toBe(409);
|
||||
expect(byId.body.error.message).toContain("未改为复制");
|
||||
// 按路径命中纳管任务的内容
|
||||
const content = join(cache.root, "Seeding.2023.1080p.BluRay-HR.mkv");
|
||||
expect(existsSync(content)).toBe(true);
|
||||
const byPath = await post({ sourcePaths: [content], mediaEntityId: e, action: "move" });
|
||||
expect(byPath.status).toBe(409);
|
||||
const decisions = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["kind", "status", "attempts"])
|
||||
.where("dedupe_key", "=", `manual_move_hnr:${dtId}`)
|
||||
.execute();
|
||||
expect(decisions).toMatchObject([{ kind: "approval_required", status: "open" }]);
|
||||
expect(Number(decisions[0]!.attempts)).toBeGreaterThanOrEqual(1);
|
||||
expect(await records(e)).toHaveLength(0);
|
||||
expect(qb.callsTo("torrents/delete")).toHaveLength(deletesBefore);
|
||||
expect(qb.get(t.hash)).toBeDefined();
|
||||
expect(existsSync(content)).toBe(true);
|
||||
// 同一来源 Copy 不受做种义务限制
|
||||
const copy = await post({ downloadTaskId: dtId, action: "copy" });
|
||||
expect(copy.status).toBe(201);
|
||||
expect((await records(e)).map((r) => r.action)).toEqual(["copy"]);
|
||||
}, 60_000);
|
||||
|
||||
it("管理员会话(dev admin)按路径 Move:放行并记审计", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "管理员片", year: 2024 });
|
||||
const src = await file("Admin.2024.1080p.BluRay-X.mkv");
|
||||
const r = await post({ sourcePaths: [src], mediaEntityId: e, action: "move" });
|
||||
expect(r.status).toBe(201);
|
||||
const a = await sql<{ payload: { via: string } }>`
|
||||
select payload from audit_event where action = 'organize.move_authorized' and target_id = ${e}`.execute(h.db);
|
||||
expect(a.rows.map((x) => x.payload.via)).toEqual(["admin_session"]);
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { sql } from "@umt/db";
|
||||
import {
|
||||
createMockQb,
|
||||
createTempTree,
|
||||
findCrossDeviceBase,
|
||||
type MockQb,
|
||||
makeTorrent,
|
||||
type TempTree,
|
||||
} from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../services/downloads.ts";
|
||||
import { executeRecord, type FrozenPlan, planImport } from "../services/organize.ts";
|
||||
import {
|
||||
createHarness,
|
||||
EXEC_KEY,
|
||||
fakeDisk,
|
||||
type Harness,
|
||||
pgAvailable,
|
||||
READ_KEY,
|
||||
seedBase,
|
||||
seedEntity,
|
||||
seedRelease,
|
||||
seedSlot,
|
||||
} from "./harness.ts";
|
||||
|
||||
// 跨卷 Move 分步可恢复(kill 后 resumed 先对账目标)、路径绑定不因译名变化改变、手动整理 API 与 scope。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const xdev = findCrossDeviceBase();
|
||||
const SIZE = 250_000;
|
||||
|
||||
describe.skipIf(!hasPg)("整理:跨卷 Move 恢复 / 路径绑定固定 / 手动整理", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
let src: TempTree;
|
||||
const disk = fakeDisk(100_000_000);
|
||||
const torrents = new Map<string, Uint8Array>();
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
// 库根放到另一个设备(/dev/shm)上,缓存在 tmpdir:入库 Move 必然跨卷
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-xlib-", ...(xdev ? { base: xdev } : {}) });
|
||||
cache = await createTempTree({}, { prefix: "umt-xcache-" });
|
||||
src = await createTempTree(
|
||||
{
|
||||
"Show.S01E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
|
||||
"Show.S01E02.1080p.WEB-DL.x264-GRP.mkv": SIZE,
|
||||
"Show.S02E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
|
||||
"Show.S01E01.1080p.WEB-DL.x264-GRP.chs.srt": 2_000,
|
||||
},
|
||||
// 手动整理的源路径必须在已登记的根下(验收 R4):放在下载器保存路径里
|
||||
{ prefix: "umt-src-", base: cache.root },
|
||||
);
|
||||
disk.state.watch = [cache.root];
|
||||
disk.setPct(30);
|
||||
h = await createHarness("move", {
|
||||
diskUsage: disk.fn,
|
||||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||||
});
|
||||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
for (const t of [lib, cache, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
it.skipIf(!xdev)(
|
||||
"跨卷 Move:copy 已完成后进程被 kill → 续跑时 resumed 先对账目标,不重复复制,核验后删源并移除 qB 任务记录",
|
||||
async () => {
|
||||
await h.setSettings("organize", { defaultMode: "move", allowCrossVolumeMove: true });
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "跨卷片", year: 2021 });
|
||||
await seedSlot(h.db, { entityId: e });
|
||||
const t = makeTorrent({ name: "Cross.Volume.2021.1080p.BluRay-XV.mkv", length: SIZE, pieceSeed: 7 });
|
||||
const relId = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: e,
|
||||
title: "Cross.Volume.2021.1080p.BluRay-XV",
|
||||
size: SIZE,
|
||||
hash: t.hash,
|
||||
hr: false,
|
||||
});
|
||||
torrents.set(relId, t.bytes);
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
|
||||
const sub = await submitDownload(h.d, ev.plans[0]!);
|
||||
await qb.complete(t.hash, { writeFiles: true });
|
||||
await syncDownloads(h.d);
|
||||
|
||||
const plan = await planImport(h.d, { downloadTaskId: sub.downloadTaskId, trigger: "auto", actorType: "system" });
|
||||
expect(plan.status).toBe("running");
|
||||
const rec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.selectAll()
|
||||
.where("id", "=", plan.recordId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
const frozen = rec.plan as unknown as FrozenPlan;
|
||||
const f = frozen.organize.files[0]!;
|
||||
expect(f.crossDevice).toBe(true);
|
||||
|
||||
// 模拟上一次 attempt:copy 步骤已把目标写完并校验,删源前 worker 被 kill(账本停在 started)
|
||||
await mkdir(join(lib.root, "跨卷片 (2021)"), { recursive: true });
|
||||
await copyFile(f.src, f.dst);
|
||||
await sql`insert into tasks_op_ledger (op_key, attempt_token, attempts, status) values (${`org:${plan.recordId}:0`}, ${randomUUID()}, 1, 'started')`.execute(
|
||||
h.db,
|
||||
);
|
||||
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
expect(ex.status).toBe("succeeded");
|
||||
expect(existsSync(f.src)).toBe(false);
|
||||
expect(existsSync(f.dst)).toBe(true);
|
||||
const op = await h.db
|
||||
.selectFrom("asset_operation")
|
||||
.selectAll()
|
||||
.where("operation_id", "=", `org:${plan.recordId}:0`)
|
||||
.executeTakeFirstOrThrow();
|
||||
const result = op.result as { resumed?: boolean; steps?: { step: string; status: string }[] };
|
||||
expect(result.resumed).toBe(true);
|
||||
expect(result.steps?.find((s) => s.step === "copy")?.status).toBe("skipped");
|
||||
expect(result.steps?.find((s) => s.step === "delete_source")?.status).toBe("done");
|
||||
// Move 成功:移除 qB 任务记录(deleteFiles=false),结束下载侧管理
|
||||
expect(qb.get(t.hash)).toBeUndefined();
|
||||
const del = qb.callsTo("torrents/delete").at(-1)!;
|
||||
expect(del.form.deleteFiles).toBe("false");
|
||||
const dt = await h.db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("id", "=", sub.downloadTaskId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(dt.removed_at).not.toBeNull();
|
||||
expect(dt.status).toBe("imported");
|
||||
await h.setSettings("organize", { defaultMode: "copy" });
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it("路径绑定固定:译名变化后新集仍进旧目录;新季在旧作品目录下建季绑定;手动整理与 scope", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "series", title: "旧译名", year: 2020 });
|
||||
// 只读 key 不能发起整理
|
||||
const denied = await h.api(
|
||||
"POST",
|
||||
"/organization-records",
|
||||
{ sourcePaths: [src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
|
||||
READ_KEY,
|
||||
);
|
||||
expect(denied.status).toBe(403);
|
||||
|
||||
// dryRun:只出计划,不写记录
|
||||
const dry = await h.api(
|
||||
"POST",
|
||||
"/organization-records",
|
||||
{
|
||||
sourcePaths: [src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.mkv")],
|
||||
mediaEntityId: e,
|
||||
action: "copy",
|
||||
dryRun: true,
|
||||
},
|
||||
EXEC_KEY,
|
||||
);
|
||||
expect(dry.status).toBe(201);
|
||||
expect(dry.body.data).toMatchObject({ recordId: null });
|
||||
expect(dry.body.data.planId).toBeTruthy();
|
||||
expect(
|
||||
await h.db.selectFrom("organization_record").select("id").where("media_entity_id", "=", e).execute(),
|
||||
).toHaveLength(0);
|
||||
|
||||
const r1 = await h.api(
|
||||
"POST",
|
||||
"/organization-records",
|
||||
{
|
||||
sourcePaths: [
|
||||
src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.mkv"),
|
||||
src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.chs.srt"),
|
||||
],
|
||||
mediaEntityId: e,
|
||||
action: "copy",
|
||||
},
|
||||
EXEC_KEY,
|
||||
);
|
||||
expect(r1.status).toBe(201);
|
||||
expect(h.engine.enqueued.at(-1)).toMatchObject({
|
||||
task: "verifyAndImport",
|
||||
input: { recordId: r1.body.data.recordId },
|
||||
});
|
||||
await executeRecord(h.d, r1.body.data.recordId);
|
||||
const ep1 = join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01 - 第 1 集.mkv");
|
||||
expect(existsSync(ep1)).toBe(true);
|
||||
expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01 - 第 1 集.chi.zh-cn.srt"))).toBe(
|
||||
true,
|
||||
);
|
||||
const rec1 = await h.api("GET", `/organization-records/${r1.body.data.recordId}`, undefined, READ_KEY);
|
||||
expect(rec1.body.data).toMatchObject({ origin: "manual", status: "succeeded", counts: { total: 2, succeeded: 2 } });
|
||||
expect(rec1.body.data.actor.type).toBe("api_key");
|
||||
|
||||
// TMDB 改名:只改展示,不改目录
|
||||
await h.db.updateTable("media_entity").set({ title: "新译名" }).where("id", "=", e).execute();
|
||||
const r2 = await h.api(
|
||||
"POST",
|
||||
"/organization-records",
|
||||
{ sourcePaths: [src.path("Show.S01E02.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
|
||||
EXEC_KEY,
|
||||
);
|
||||
await executeRecord(h.d, r2.body.data.recordId);
|
||||
expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "新译名 - S01E02 - 第 2 集.mkv"))).toBe(true);
|
||||
expect(existsSync(join(lib.root, "新译名 (2020)"))).toBe(false);
|
||||
|
||||
const r3 = await h.api(
|
||||
"POST",
|
||||
"/organization-records",
|
||||
{ sourcePaths: [src.path("Show.S02E01.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
|
||||
EXEC_KEY,
|
||||
);
|
||||
await executeRecord(h.d, r3.body.data.recordId);
|
||||
expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 2", "新译名 - S02E01 - 第 1 集.mkv"))).toBe(true);
|
||||
|
||||
const bindings = await h.db
|
||||
.selectFrom("path_binding")
|
||||
.select(["binding_kind", "season_number", "relative_path", "status"])
|
||||
.where("media_entity_id", "=", e)
|
||||
.execute();
|
||||
bindings.sort((a, b) => (a.season_number ?? -1) - (b.season_number ?? -1));
|
||||
expect(bindings).toEqual([
|
||||
{ binding_kind: "entity", season_number: null, relative_path: "旧译名 (2020)", status: "active" },
|
||||
{ binding_kind: "season", season_number: 1, relative_path: "旧译名 (2020)/Season 1", status: "active" },
|
||||
{ binding_kind: "season", season_number: 2, relative_path: "旧译名 (2020)/Season 2", status: "active" },
|
||||
]);
|
||||
// 同目标已存在且内容相同:重复整理判 already_in_library 跳过,不覆盖
|
||||
const r4 = await h.api(
|
||||
"POST",
|
||||
"/organization-records",
|
||||
{ sourcePaths: [src.path("Show.S01E02.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
|
||||
EXEC_KEY,
|
||||
);
|
||||
const rec4 = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.selectAll()
|
||||
.where("organization_record_id", "=", r4.body.data.recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rec4).toMatchObject({ status: "skipped", skip_reason: "already_in_library" });
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import type { TmdbClient } from "@umt/adapters";
|
||||
import { DEFAULT_SETTINGS, type OrganizeSourceConfig } from "@umt/contracts";
|
||||
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||
import { syncDownloads } from "../services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../services/organize.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const FILE = "Example.2020.1080p.BluRay.x264-GRP.mkv";
|
||||
describe.skipIf(!hasPg)("configured organize sources (real PG, files, mock qB)", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
let qb: MockQb;
|
||||
let otherQb: MockQb;
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
let otherInstance: string;
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true });
|
||||
cache = await createTempTree({ [`a/${FILE}`]: 200_000, [`b/${FILE}`]: 200_000, [`tagged/${FILE}`]: 200_000 });
|
||||
qb = await createMockQb();
|
||||
otherQb = await createMockQb();
|
||||
h = await createHarness("source_config");
|
||||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
otherInstance = (
|
||||
await h.db
|
||||
.insertInto("downloader_instance")
|
||||
.values({
|
||||
name: "other-qb",
|
||||
base_url: otherQb.url,
|
||||
username: "admin",
|
||||
settings: JSON.stringify({ savePath: cache.root }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}, 60_000);
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
await otherQb?.close();
|
||||
await lib?.cleanup();
|
||||
await cache?.cleanup();
|
||||
});
|
||||
const profile = (patch: Partial<OrganizeSourceConfig> = {}): OrganizeSourceConfig => ({
|
||||
id: randomUUID(),
|
||||
name: "整理来源",
|
||||
enabled: true,
|
||||
source: { root: `dl:${base.instanceId}:0`, path: "a" },
|
||||
target: { root: `lib:${base.rootId}`, path: "华语电影" },
|
||||
mediaType: "movie",
|
||||
category: "华语电影",
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
downloaderInstanceId: null,
|
||||
tag: null,
|
||||
intervalSec: 300,
|
||||
...patch,
|
||||
});
|
||||
const settings = (
|
||||
sources: OrganizeSourceConfig[],
|
||||
classificationYaml = DEFAULT_SETTINGS.organize.classificationYaml,
|
||||
) => h.setSettings("organize", { ...DEFAULT_SETTINGS.organize, minSizeBytes: 100_000, sources, classificationYaml });
|
||||
const request = (entityId: string, source: OrganizeSourceConfig, folder = "a") => ({
|
||||
sourcePaths: [cache.path(`${folder}/${FILE}`)],
|
||||
mediaEntityId: entityId,
|
||||
sourceConfigId: source.id,
|
||||
trigger: "manual" as const,
|
||||
actorType: "user" as const,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
it("fixed category adds no duplicate; explicit same-category source selects own target and rejects wrong source", async () => {
|
||||
const a = profile();
|
||||
const b = profile({
|
||||
source: { root: `dl:${base.instanceId}:0`, path: "b" },
|
||||
target: { root: `lib:${base.rootId}`, path: "另一个目录/华语电影" },
|
||||
});
|
||||
await settings([a, b]);
|
||||
const movie = await seedEntity(h.db, { kind: "movie", title: "固定片", year: 2020 });
|
||||
const first = await planImport(h.d, request(movie, a));
|
||||
expect(first.plan?.sourceConfigId).toBe(a.id);
|
||||
expect(first.plan?.newBindings[0]?.relativePath).toBe("华语电影/固定片 (2020)");
|
||||
const explicit = await planImport(h.d, { ...request(movie, a), targetDir: join(lib.root, "华语电影") });
|
||||
expect(explicit.plan?.newBindings).toEqual([]);
|
||||
const explicitDst = explicit.plan?.organize.files.find((f) => f.role === "main")?.dst;
|
||||
expect(explicitDst?.startsWith(`${join(lib.root, "华语电影")}/`)).toBe(true);
|
||||
expect(explicitDst?.includes("华语电影/华语电影")).toBe(false);
|
||||
const second = await planImport(h.d, request(movie, b, "b"));
|
||||
expect(second.plan?.sourceConfigId).toBe(b.id);
|
||||
expect(second.plan?.newBindings[0]?.relativePath).toBe("另一个目录/华语电影/固定片 (2020)");
|
||||
const mismatch = await planImport(h.d, request(movie, a, "b"));
|
||||
expect(mismatch.reason).toBe("source_config_mismatch");
|
||||
expect(mismatch.plan).toBeUndefined();
|
||||
});
|
||||
|
||||
it("automatic category prefixes new binding; changed YAML/target preserves existing binding", async () => {
|
||||
const automatic = profile({ category: null, target: { root: `lib:${base.rootId}`, path: "自动" } });
|
||||
await settings([automatic]);
|
||||
const movie = await seedEntity(h.db, { kind: "movie", title: "动画片", year: 2020 });
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: movie,
|
||||
provider: "tmdb",
|
||||
entity_type: "movie",
|
||||
external_id: "987",
|
||||
status: "accepted",
|
||||
})
|
||||
.execute();
|
||||
const getJson = vi.fn().mockResolvedValue({ genres: [{ id: 16 }], original_language: "zh" });
|
||||
vi.spyOn(SourcesRuntime.prototype, "tmdb").mockResolvedValue({ getJson } as unknown as TmdbClient);
|
||||
const plan = await planImport(h.d, { ...request(movie, automatic), dryRun: false });
|
||||
expect(plan.status).toBe("running");
|
||||
const record = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select("plan")
|
||||
.where("id", "=", plan.recordId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(record.plan).toMatchObject({
|
||||
classification: { category: "动画电影" },
|
||||
newBindings: [{ relativePath: "自动/动画电影/动画片 (2020)" }],
|
||||
});
|
||||
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
|
||||
await settings(
|
||||
[{ ...automatic, target: { ...automatic.target, path: "新目录" } }],
|
||||
"movie:\n 新分类: null\ntv: {}\n",
|
||||
);
|
||||
const updated = await planImport(h.d, request(movie, automatic));
|
||||
expect(updated.plan?.classification?.category).toBe("新分类");
|
||||
expect(updated.plan?.newBindings).toEqual([]);
|
||||
expect(updated.plan?.organize.files.find((f) => f.role === "main")?.dst).toContain(
|
||||
join(lib.root, "自动", "动画电影", "动画片 (2020)"),
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("path_binding")
|
||||
.select("relative_path")
|
||||
.where("media_entity_id", "=", movie)
|
||||
.executeTakeFirstOrThrow()
|
||||
).relative_path,
|
||||
).toBe("自动/动画电影/动画片 (2020)");
|
||||
});
|
||||
|
||||
it("explicit root creates an unbound path there and refuses to relocate an existing binding", async () => {
|
||||
const alternate = await createTempTree({}, { sentinel: true });
|
||||
const alternateRoot = await h.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "alternate", media_kind: "mixed", local_path: alternate.root, management_mode: "managed" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
try {
|
||||
const source = profile();
|
||||
await settings([source]);
|
||||
const fresh = await seedEntity(h.db, { kind: "movie", title: "新根片", year: 2020 });
|
||||
const override = await planImport(h.d, { ...request(fresh, source), targetLibraryRootId: alternateRoot.id });
|
||||
expect(override.plan?.libraryRootId).toBe(alternateRoot.id);
|
||||
expect(override.plan?.newBindings[0]?.relativePath).toBe("新根片 (2020)");
|
||||
const existing = await seedEntity(h.db, { kind: "movie", title: "已绑定片", year: 2020 });
|
||||
const first = await planImport(h.d, { ...request(existing, source), dryRun: false });
|
||||
expect((await executeRecord(h.d, first.recordId!)).status).toBe("succeeded");
|
||||
const attempted = await planImport(h.d, { ...request(existing, source), targetLibraryRootId: alternateRoot.id });
|
||||
expect(attempted.reason).toBe("binding_on_other_root");
|
||||
expect(
|
||||
await h.db
|
||||
.selectFrom("path_binding")
|
||||
.select(["library_root_id", "relative_path"])
|
||||
.where("media_entity_id", "=", existing)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).toMatchObject({ library_root_id: base.rootId, relative_path: "华语电影/已绑定片 (2020)" });
|
||||
} finally {
|
||||
await h.db.updateTable("library_root").set({ enabled: false }).where("id", "=", alternateRoot.id).execute();
|
||||
await alternate.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("tag and source path adopt only matching instance; removing tag stops management", async () => {
|
||||
const tag = "source-only-tag";
|
||||
await settings([
|
||||
profile({
|
||||
source: { root: `dl:${base.instanceId}:0`, path: "tagged" },
|
||||
trigger: "qb_tag",
|
||||
downloaderInstanceId: base.instanceId,
|
||||
tag,
|
||||
}),
|
||||
]);
|
||||
const hash = (n: number) => n.toString(16).padStart(40, "0");
|
||||
qb.seed({ hash: hash(1), name: FILE, save_path: cache.path("tagged"), tags: [tag] });
|
||||
qb.seed({ hash: hash(2), name: "No.Tag.mkv", save_path: cache.path("tagged"), tags: [] });
|
||||
qb.seed({ hash: hash(3), name: "Wrong.Path.mkv", save_path: cache.path("b"), tags: [tag] });
|
||||
otherQb.seed({ hash: hash(1), name: FILE, save_path: cache.path("tagged"), tags: [tag] });
|
||||
const result = await syncDownloads(h.d);
|
||||
expect(result.adopted).toBe(1);
|
||||
const rows = await h.db.selectFrom("download_task").selectAll().execute();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ instance_id: base.instanceId, torrent_hash: hash(1), managed: true });
|
||||
expect(rows.some((r) => r.instance_id === otherInstance)).toBe(false);
|
||||
qb.get(hash(1))!.tags = [];
|
||||
await syncDownloads(h.d);
|
||||
expect(
|
||||
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rows[0]!.id).executeTakeFirstOrThrow())
|
||||
.managed,
|
||||
).toBe(false);
|
||||
qb.get(hash(1))!.tags = [tag];
|
||||
await syncDownloads(h.d);
|
||||
expect(
|
||||
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rows[0]!.id).executeTakeFirstOrThrow())
|
||||
.managed,
|
||||
).toBe(true);
|
||||
});
|
||||
it("automatic source with movie-only media type refuses a TV entity instead of generic organization", async () => {
|
||||
const tag = "movie-source";
|
||||
await settings([
|
||||
profile({
|
||||
source: { root: `dl:${base.instanceId}:0`, path: "tagged" },
|
||||
trigger: "qb_tag",
|
||||
downloaderInstanceId: base.instanceId,
|
||||
tag,
|
||||
}),
|
||||
]);
|
||||
const hash = "4".padStart(40, "0");
|
||||
const name = "Only.TV.S01E01.1080p.WEB-DL.x264-GRP.mkv";
|
||||
qb.seed({ hash, name, save_path: cache.path("tagged"), tags: [tag] });
|
||||
await qb.complete(hash, { files: [{ path: name, size: 200_000 }] });
|
||||
await syncDownloads(h.d);
|
||||
const row = await h.db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("torrent_hash", "=", hash)
|
||||
.executeTakeFirstOrThrow();
|
||||
const entity = await seedEntity(h.db, { kind: "series", title: "Only TV", year: 2020 });
|
||||
await h.db.updateTable("download_task").set({ media_entity_id: entity }).where("id", "=", row.id).execute();
|
||||
const planned = await planImport(h.d, {
|
||||
downloadTaskId: row.id,
|
||||
trigger: "auto",
|
||||
actorType: "system",
|
||||
dryRun: true,
|
||||
});
|
||||
expect(planned.reason).toBe("source_config_mismatch");
|
||||
expect(planned.plan).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
|
||||
|
||||
// HIS-1 整理记录列表筛选与统计:类型(电影 / 剧集 / 未识别)、时间、关键字(标题 / 路径 / 文件名 / 种子 hash,支持 * ?)、
|
||||
// 只看整理类、状态计数与列表同口径;列表带海报、大小、下载器与 hash。
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
|
||||
describe.skipIf(skip)("整理记录筛选与统计", () => {
|
||||
let h: Harness;
|
||||
const ids: Record<string, string> = {};
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("recfilter");
|
||||
const film = await seedEntity(h.db, { kind: "movie", title: "Arrival", year: 2016 });
|
||||
await h.db
|
||||
.updateTable("media_entity")
|
||||
.set({ metadata: JSON.stringify({ posterPath: { value: "/arrival.jpg" } }) })
|
||||
.where("id", "=", film)
|
||||
.execute();
|
||||
const show = await seedEntity(h.db, { kind: "series", title: "Severance", year: 2022 });
|
||||
const inst = await h.db
|
||||
.insertInto("downloader_instance")
|
||||
.values({ name: "家里的 qB", base_url: "http://127.0.0.1:1" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const task = await h.db
|
||||
.insertInto("download_task")
|
||||
.values({ instance_id: inst.id, torrent_hash: "abcdef0123456789abcdef0123456789abcdef01", name: "Arrival.2016" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const old = new Date(Date.now() - 40 * 86_400_000);
|
||||
const rows = [
|
||||
{ k: "film", entity: film, status: "succeeded", action: "copy", src: "/dl/Arrival.2016.mkv", task: task.id },
|
||||
{ k: "show", entity: show, status: "failed", action: "move", src: "/dl/Severance.S01E01.mkv", at: old },
|
||||
{ k: "unknown", entity: null, status: "failed", action: "copy", src: "/dl/random.mp4" },
|
||||
{ k: "rename", entity: film, status: "succeeded", action: "rename", src: "/lib/Arrival/a.mkv" },
|
||||
] as const;
|
||||
for (const r of rows) {
|
||||
const rec = await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({
|
||||
trigger: "auto",
|
||||
actor_type: "system",
|
||||
media_entity_id: r.entity,
|
||||
download_task_id: "task" in r ? r.task : null,
|
||||
source_path: r.src,
|
||||
action: r.action,
|
||||
status: r.status,
|
||||
...("at" in r ? { created_at: r.at } : {}),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
ids[r.k] = rec.id;
|
||||
await h.db
|
||||
.insertInto("organization_file_result")
|
||||
.values({
|
||||
organization_record_id: rec.id,
|
||||
mode: r.action,
|
||||
source_path: `${r.src}`,
|
||||
source_size_bytes: 1000,
|
||||
status: r.status === "failed" ? "failed" : "succeeded",
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
afterAll(async () => h?.close());
|
||||
|
||||
const list = async (q: string) => {
|
||||
const r = await h.api("GET", `/organization-records?${q}`);
|
||||
expect(r.status).toBe(200);
|
||||
return r.body.data.items as { id: string }[];
|
||||
};
|
||||
const idsOf = (items: { id: string }[]) => items.map((x) => x.id).sort();
|
||||
|
||||
it("类型:电影 / 剧集 / 未识别", async () => {
|
||||
expect(idsOf(await list("kind=movie"))).toEqual([ids.film, ids.rename].sort());
|
||||
expect(idsOf(await list("kind=series"))).toEqual([ids.show]);
|
||||
expect(idsOf(await list("kind=none"))).toEqual([ids.unknown]);
|
||||
});
|
||||
|
||||
it("时间:since 之后创建的", async () => {
|
||||
const since = new Date(Date.now() - 7 * 86_400_000).toISOString();
|
||||
const items = await list(`since=${encodeURIComponent(since)}`);
|
||||
expect(items.map((x) => x.id)).not.toContain(ids.show);
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("关键字:标题、文件名、种子 hash、通配", async () => {
|
||||
expect(idsOf(await list("q=Severance"))).toEqual([ids.show]);
|
||||
expect(idsOf(await list(`q=${encodeURIComponent("abcdef0123")}`))).toEqual([ids.film]);
|
||||
expect(idsOf(await list(`q=${encodeURIComponent("*.mp4")}`))).toEqual([ids.unknown]);
|
||||
expect(idsOf(await list(`q=${encodeURIComponent("/dl/?rrival*")}`))).toEqual([ids.film]);
|
||||
// 通配里的 % 与 _ 按字面匹配
|
||||
expect(await list(`q=${encodeURIComponent("100%")}`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("只看整理类:不含改名 / 删除 / 还原", async () => {
|
||||
const items = await list("organizeOnly=true");
|
||||
expect(items.map((x) => x.id)).not.toContain(ids.rename);
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("统计与列表同口径,不受状态筛选影响", async () => {
|
||||
const all = await h.api("GET", "/organization-records/stats");
|
||||
expect(all.status).toBe(200);
|
||||
expect(all.body.data).toEqual({ total: 4, succeeded: 2, partial: 0, failed: 2, running: 0, cancelled: 0 });
|
||||
const movies = await h.api("GET", "/organization-records/stats?kind=movie&organizeOnly=true");
|
||||
expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
|
||||
});
|
||||
|
||||
it("历史分页和统计排除排队与运行记录,显式active仍可查询", async () => {
|
||||
const inserted = await h.db
|
||||
.insertInto("organization_record")
|
||||
.values([
|
||||
{ trigger: "manual", actor_type: "user", action: "copy", status: "queued", source_path: "/queued.mkv" },
|
||||
{ trigger: "manual", actor_type: "user", action: "move", status: "running", source_path: "/running.mkv" },
|
||||
])
|
||||
.returning("id")
|
||||
.execute();
|
||||
try {
|
||||
expect(await list("phase=history")).toHaveLength(4);
|
||||
expect(idsOf(await list("phase=active"))).toEqual(inserted.map((item) => item.id).sort());
|
||||
expect(await list("phase=history&status=queued")).toHaveLength(0);
|
||||
const stats = await h.api("GET", "/organization-records/stats?phase=history");
|
||||
expect(stats.body.data).toMatchObject({ total: 4, running: 0 });
|
||||
} finally {
|
||||
await h.db
|
||||
.deleteFrom("organization_record")
|
||||
.where(
|
||||
"id",
|
||||
"in",
|
||||
inserted.map((item) => item.id),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
it("列表项带海报、大小、下载器名称与 hash", async () => {
|
||||
const [film] = await list("q=Arrival.2016");
|
||||
expect(film).toMatchObject({
|
||||
media: { title: "Arrival", posterPath: "/arrival.jpg" },
|
||||
sizeBytes: 1000,
|
||||
download: { name: "Arrival.2016", hash: "abcdef0123456789abcdef0123456789abcdef01", downloaderName: "家里的 qB" },
|
||||
});
|
||||
const [unknown] = await list("kind=none");
|
||||
expect(unknown).toMatchObject({ media: null, download: null, sizeBytes: 1000 });
|
||||
});
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { DEFAULT_SETTINGS } from "@umt/contracts";
|
||||
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { executeRecord, planImport } from "../services/organize.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
describe.skipIf(!hasPg)("one-time replacement (real PG and library files)", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
let rootId: string;
|
||||
let instanceId: string;
|
||||
let siteId: string;
|
||||
let qb: MockQb;
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true });
|
||||
src = await createTempTree({
|
||||
"Movie.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
|
||||
"Same.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
|
||||
...Object.fromEntries(
|
||||
Array.from({ length: 6 }, (_, i) => [
|
||||
`Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
|
||||
200_000,
|
||||
]),
|
||||
),
|
||||
});
|
||||
qb = await createMockQb();
|
||||
h = await createHarness("replacement");
|
||||
({ rootId, instanceId, siteId } = await seedBase(h.db, {
|
||||
libraryPath: lib.root,
|
||||
cachePath: src.root,
|
||||
qbUrl: qb.url,
|
||||
}));
|
||||
await h.setSettings("naming", {
|
||||
tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}",
|
||||
});
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
await lib?.cleanup();
|
||||
await src?.cleanup();
|
||||
});
|
||||
const old = async (entityId: string, path: string, parent?: string) => {
|
||||
await mkdir(dirname(lib.path(path)), { recursive: true });
|
||||
await writeFile(lib.path(path), Buffer.alloc(200_001, 7));
|
||||
const asset = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
kind: parent ? "subtitle" : "video",
|
||||
parent_asset_id: parent ?? null,
|
||||
status: "active",
|
||||
size_bytes: 200_001,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: asset.id,
|
||||
location_kind: "library",
|
||||
library_root_id: rootId,
|
||||
relative_path: path,
|
||||
status: "available",
|
||||
is_primary: true,
|
||||
})
|
||||
.execute();
|
||||
return asset.id;
|
||||
};
|
||||
const request = (entityId: string, paths: string[], assetIds: string[] = []) => ({
|
||||
mediaEntityId: entityId,
|
||||
sourcePaths: paths.map((p) => src.path(p)),
|
||||
trigger: "manual" as const,
|
||||
actorType: "user" as const,
|
||||
action: "copy" as const,
|
||||
importMode: "replace" as const,
|
||||
replacementAssetIds: assetIds,
|
||||
});
|
||||
const status = async (id: string) =>
|
||||
(await h.db.selectFrom("media_asset").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status;
|
||||
it("new movie registers first, retires only selected old and its private subtitle, preserving unselected/shared/cache", async () => {
|
||||
const e = await seedEntity(h.db, {
|
||||
kind: "movie",
|
||||
title: "Movie",
|
||||
year: 2020,
|
||||
});
|
||||
const path = "Movie (2020)/Movie (2020) - 720p.mkv";
|
||||
const selected = await old(e, path);
|
||||
const subtitle = await old(e, path.replace(".mkv", ".en.srt"), selected);
|
||||
const kept = await old(e, "Movie (2020)/Movie (2020) - 2160p.mkv");
|
||||
await writeFile(lib.path("Movie (2020)/movie.nfo"), "shared");
|
||||
const planned = await planImport(h.d, request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
|
||||
expect(planned.status).toBe("running");
|
||||
expect(existsSync(lib.path(path))).toBe(true);
|
||||
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
|
||||
expect(await status(selected)).toBe("retired");
|
||||
expect(await status(subtitle)).toBe("retired");
|
||||
expect(await status(kept)).toBe("active");
|
||||
expect(existsSync(lib.path(path))).toBe(false);
|
||||
expect(existsSync(lib.path("Movie (2020)/movie.nfo"))).toBe(true);
|
||||
expect(existsSync(src.path("Movie.2020.1080p.BluRay.x264-GRP.mkv"))).toBe(true);
|
||||
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
|
||||
await executeRecord(h.d, planned.recordId!);
|
||||
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
|
||||
});
|
||||
it("same-path replaces selected old asset with a fresh asset, but normal/unselected cannot overwrite", async () => {
|
||||
const e = await seedEntity(h.db, {
|
||||
kind: "movie",
|
||||
title: "Same",
|
||||
year: 2020,
|
||||
});
|
||||
const selected = await old(e, "Same (2020)/Same (2020) - 1080p.mkv");
|
||||
const normal = await planImport(h.d, {
|
||||
...request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"]),
|
||||
importMode: "normal",
|
||||
dryRun: true,
|
||||
});
|
||||
expect(normal.plan?.organize.files[0]?.action).toBe("hold");
|
||||
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
|
||||
expect(planned.status).toBe("running");
|
||||
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
|
||||
expect(await status(selected)).toBe("retired");
|
||||
const active = await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select("id")
|
||||
.where("media_entity_id", "=", e)
|
||||
.where("status", "=", "active")
|
||||
.execute();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0]!.id).not.toBe(selected);
|
||||
});
|
||||
it("selected grants cannot overwrite an unselected path, and changed old files survive cleanup", async () => {
|
||||
const e = await seedEntity(h.db, {
|
||||
kind: "movie",
|
||||
title: "Protected",
|
||||
year: 2020,
|
||||
});
|
||||
const lower = await old(e, "Protected (2020)/Protected (2020) - 720p.mkv");
|
||||
const occupied = await old(e, "Protected (2020)/Protected (2020) - 1080p.mkv");
|
||||
const blocked = await planImport(h.d, {
|
||||
...request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [lower]),
|
||||
dryRun: true,
|
||||
});
|
||||
expect(blocked.plan?.organize.files[0]?.action).toBe("hold");
|
||||
expect(await status(occupied)).toBe("active");
|
||||
const other = await seedEntity(h.db, {
|
||||
kind: "movie",
|
||||
title: "Stale",
|
||||
year: 2020,
|
||||
});
|
||||
const oldPath = "Stale (2020)/Stale (2020) - 720p.mkv";
|
||||
const stale = await old(other, oldPath);
|
||||
const planned = await planImport(h.d, request(other, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [stale]));
|
||||
await writeFile(lib.path(oldPath), "changed-after-plan");
|
||||
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
|
||||
expect(await status(stale)).toBe("active");
|
||||
expect((await readFile(lib.path(oldPath))).toString()).toBe("changed-after-plan");
|
||||
});
|
||||
|
||||
it("same-path failed final probe restores old bytes and asset, even with delete policy", async () => {
|
||||
const e = await seedEntity(h.db, {
|
||||
kind: "movie",
|
||||
title: "Same",
|
||||
year: 2021,
|
||||
});
|
||||
const path = "Same (2021)/Same (2021) - 1080p.mkv";
|
||||
const selected = await old(e, path);
|
||||
const savedProbe = h.d.ports.probe;
|
||||
const savedConfig = h.ctx.config;
|
||||
h.ctx.config = { ...savedConfig, FS_REPLACE_OLD: "delete" };
|
||||
h.d.ports.probe = async () => ({
|
||||
status: "ok",
|
||||
video: [],
|
||||
audio: [],
|
||||
subtitles: [],
|
||||
});
|
||||
try {
|
||||
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
|
||||
expect(planned.status).toBe("running");
|
||||
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("failed");
|
||||
expect((await readFile(lib.path(path))).equals(Buffer.alloc(200_001, 7))).toBe(true);
|
||||
expect(await status(selected)).toBe("active");
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("asset_location")
|
||||
.select("status")
|
||||
.where("media_asset_id", "=", selected)
|
||||
.executeTakeFirstOrThrow()
|
||||
).status,
|
||||
).toBe("available");
|
||||
await executeRecord(h.d, planned.recordId!);
|
||||
expect((await readFile(lib.path(path))).length).toBe(200_001);
|
||||
} finally {
|
||||
h.d.ports.probe = savedProbe;
|
||||
h.ctx.config = savedConfig;
|
||||
}
|
||||
});
|
||||
|
||||
it("old E01-E10 only loses successful new E01-E06; failed E04 and old merged range survive", async () => {
|
||||
const e = await seedEntity(h.db, {
|
||||
kind: "series",
|
||||
title: "Show",
|
||||
year: 2020,
|
||||
});
|
||||
const ids: string[] = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
ids.push(await old(e, `Show (2020)/Season 1/Show - S01E${String(i).padStart(2, "0")} - 720p.mkv`));
|
||||
}
|
||||
const merged = await old(e, "Show (2020)/Season 1/Show - S01E01-E10 - 480p.mkv");
|
||||
const saved = h.d.ports.probe;
|
||||
h.d.ports.probe = async (path) =>
|
||||
path.includes("E04")
|
||||
? { status: "ok", video: [], audio: [], subtitles: [] }
|
||||
: { status: "unknown", reason: "ffprobe_unavailable" };
|
||||
try {
|
||||
const paths = Array.from(
|
||||
{ length: 6 },
|
||||
(_, i) => `Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
|
||||
);
|
||||
const planned = await planImport(h.d, request(e, paths, [...ids, merged]));
|
||||
expect(planned.status).toBe("running");
|
||||
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("partial");
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
expect(await status(ids[i - 1]!)).toBe(i <= 6 && i !== 4 ? "retired" : "active");
|
||||
}
|
||||
expect(await status(merged)).toBe("active");
|
||||
} finally {
|
||||
h.d.ports.probe = saved;
|
||||
}
|
||||
});
|
||||
it("unknown manual task resolves Local, classifies and registers in the same import, then explicit reused-hash replacement uses its new plan", async () => {
|
||||
const name = "Unknown.2022.1080p.WEB-DL.x264-GRP.mkv";
|
||||
const hash = "a".repeat(40);
|
||||
qb.seed({ hash, name, tags: ["umt"], save_path: src.root });
|
||||
await qb.complete(hash, {
|
||||
writeFiles: true,
|
||||
files: [{ path: name, size: 200_000 }],
|
||||
});
|
||||
const release = await h.db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
site_release_id: randomUUID(),
|
||||
title: name,
|
||||
size_bytes: 200_000,
|
||||
raw: JSON.stringify({}),
|
||||
resolution_status: "unresolved",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const makePlan = async (evaluation: object) =>
|
||||
(
|
||||
await h.db
|
||||
.insertInto("acquisition_plan")
|
||||
.values({
|
||||
slot_id: null,
|
||||
source_release_id: release.id,
|
||||
status: "submitted",
|
||||
payload_hash: randomUUID(),
|
||||
idempotency_key: randomUUID(),
|
||||
evaluation: JSON.stringify(evaluation),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const initialPlan = await makePlan({
|
||||
manual: true,
|
||||
mediaEntityId: null,
|
||||
importMode: "normal",
|
||||
replacementAssetIds: [],
|
||||
});
|
||||
const dt = await h.db
|
||||
.insertInto("download_task")
|
||||
.values({
|
||||
instance_id: instanceId,
|
||||
torrent_hash: hash,
|
||||
name,
|
||||
save_path: src.root,
|
||||
content_path: src.path(name),
|
||||
managed: true,
|
||||
tags: ["umt"],
|
||||
status: "completed",
|
||||
source_release_id: release.id,
|
||||
acquisition_plan_id: initialPlan,
|
||||
import_plan_id: initialPlan,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const local = await seedEntity(h.db, {
|
||||
kind: "movie",
|
||||
title: "本地作品",
|
||||
year: 2022,
|
||||
});
|
||||
await h.db
|
||||
.updateTable("media_entity")
|
||||
.set({
|
||||
metadata: JSON.stringify({
|
||||
title: {
|
||||
value: "本地作品",
|
||||
source: "web",
|
||||
evidence: { sourceUrl: "https://example.test/detail" },
|
||||
},
|
||||
originalLanguage: { value: "zh", source: "web" },
|
||||
genres: { value: ["剧情"], source: "web" },
|
||||
}),
|
||||
})
|
||||
.where("id", "=", local)
|
||||
.execute();
|
||||
const sourceId = randomUUID();
|
||||
await h.setSettings("organize", {
|
||||
...DEFAULT_SETTINGS.organize,
|
||||
minSizeBytes: 100_000,
|
||||
scrapeAfterImport: false,
|
||||
sources: [
|
||||
{
|
||||
id: sourceId,
|
||||
name: "Local来源",
|
||||
enabled: true,
|
||||
source: { root: `dl:${instanceId}:0`, path: "" },
|
||||
target: { root: `lib:${rootId}`, path: "" },
|
||||
mediaType: "movie",
|
||||
category: null,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
downloaderInstanceId: null,
|
||||
tag: null,
|
||||
intervalSec: 300,
|
||||
},
|
||||
],
|
||||
});
|
||||
const has = vi.spyOn(h.ctx.capabilities, "has").mockReturnValue(true);
|
||||
const invoke = vi.spyOn(h.ctx.capabilities, "invoke").mockResolvedValue({
|
||||
mediaEntityId: local,
|
||||
mode: "local",
|
||||
enrichmentId: randomUUID(),
|
||||
reason: null,
|
||||
});
|
||||
try {
|
||||
const planned = await planImport(h.d, {
|
||||
downloadTaskId: dt.id,
|
||||
sourceConfigId: sourceId,
|
||||
trigger: "auto",
|
||||
actorType: "system",
|
||||
});
|
||||
expect(planned.status).toBe("running");
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select("plan")
|
||||
.where("id", "=", planned.recordId!)
|
||||
.executeTakeFirstOrThrow()
|
||||
).plan,
|
||||
).toMatchObject({ metadataMode: "local" });
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"metadata.resolve_organize",
|
||||
{ sourceReleaseId: release.id },
|
||||
expect.objectContaining({ source: "task" }),
|
||||
);
|
||||
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
|
||||
expect(
|
||||
await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select("id")
|
||||
.where("media_entity_id", "=", local)
|
||||
.where("status", "=", "active")
|
||||
.execute(),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("path_binding")
|
||||
.select("relative_path")
|
||||
.where("media_entity_id", "=", local)
|
||||
.executeTakeFirstOrThrow()
|
||||
).relative_path,
|
||||
).toBe("华语电影/本地作品 (2022)");
|
||||
expect(h.engine.enqueued.some((j) => j.task === "scrapeMedia")).toBe(true);
|
||||
const selected = await old(local, "华语电影/本地作品 (2022)/本地作品 (2022) - 720p.mkv");
|
||||
const secondPlan = await makePlan({
|
||||
manual: true,
|
||||
mediaEntityId: local,
|
||||
importMode: "replace",
|
||||
replacementAssetIds: [selected],
|
||||
});
|
||||
await h.db
|
||||
.updateTable("download_task")
|
||||
.set({ import_plan_id: secondPlan, status: "completed" })
|
||||
.where("id", "=", dt.id)
|
||||
.execute();
|
||||
const rerun = await planImport(h.d, {
|
||||
downloadTaskId: dt.id,
|
||||
sourceConfigId: sourceId,
|
||||
trigger: "auto",
|
||||
actorType: "system",
|
||||
});
|
||||
expect(rerun.status).toBe("running");
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select("plan")
|
||||
.where("id", "=", rerun.recordId!)
|
||||
.executeTakeFirstOrThrow()
|
||||
).plan,
|
||||
).toMatchObject({ metadataMode: "local" });
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
await executeRecord(h.d, rerun.recordId!);
|
||||
expect(await status(selected)).toBe("retired");
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.select("status")
|
||||
.where("id", "=", secondPlan)
|
||||
.executeTakeFirstOrThrow()
|
||||
).status,
|
||||
).toBe("completed");
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("download_task")
|
||||
.select("acquisition_plan_id")
|
||||
.where("id", "=", dt.id)
|
||||
.executeTakeFirstOrThrow()
|
||||
).acquisition_plan_id,
|
||||
).toBe(initialPlan);
|
||||
} finally {
|
||||
has.mockRestore();
|
||||
invoke.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
import { makeTorrent } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createAcquisitionOrganizeModule } from "../index.ts";
|
||||
import { evaluateAcquisition } from "../services/acquisition.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
|
||||
|
||||
// 规则 API:订阅/Slot/策略版本(发布=持续授权、回滚)/偏好标签抑制;新作品收录走已发布策略;outbox 路由。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe("outbox 路由", () => {
|
||||
const routes = createAcquisitionOrganizeModule().outboxRoutes!({} as never);
|
||||
it("release.resolved → evaluateAcquisition;只有下载完成才建 verifyAndImport", () => {
|
||||
const ev = (type: string, payload: unknown) => ({ id: "1", type, payload, at: new Date() });
|
||||
expect(
|
||||
routes["release.resolved"]!(ev("release.resolved", { sourceReleaseId: "a", contentUnitIds: ["u"] })),
|
||||
).toEqual({
|
||||
task: "evaluateAcquisition",
|
||||
input: { sourceReleaseId: "a", contentUnitIds: ["u"] },
|
||||
});
|
||||
expect(
|
||||
routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "completed" })),
|
||||
).toEqual({
|
||||
task: "verifyAndImport",
|
||||
input: { downloadTaskId: "d" },
|
||||
});
|
||||
expect(
|
||||
routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "paused" })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("规则 API(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
const disk = fakeDisk(1_000_000_000);
|
||||
|
||||
beforeAll(async () => {
|
||||
disk.setPct(10);
|
||||
h = await createHarness("rules", { diskUsage: disk.fn });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("订阅与 Slot:缺省季范围、同名冲突、revision 乐观锁", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "series", title: "追更剧", year: 2025 });
|
||||
const sub = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "series" });
|
||||
expect(sub.status).toBe(201);
|
||||
expect(sub.body.data).toMatchObject({
|
||||
mode: "continuous",
|
||||
seasonScope: "following_seasons",
|
||||
origin: "user",
|
||||
// 剧集没有已接受的 TMDB 身份 → 总集数无来源:pending(照常搜索/评估,不能完成)
|
||||
status: "pending",
|
||||
statusReason: "metadata_pending",
|
||||
});
|
||||
const once = await h.api("PATCH", `/subscriptions/${sub.body.data.id}`, {
|
||||
seasonScope: "current_season",
|
||||
params: { stopAfter: "2030-01-01T00:00:00Z" },
|
||||
});
|
||||
expect(once.body.data.seasonScope).toBe("current_season");
|
||||
expect((await h.api("POST", "/subscriptions", { mediaKind: "movie" })).status).toBe(422);
|
||||
|
||||
const s = await h.api("POST", "/acquisition-slots", {
|
||||
mediaEntityId: e,
|
||||
subscriptionId: sub.body.data.id,
|
||||
name: "1080p",
|
||||
hardConditions: { resolutions: ["1080p"] },
|
||||
});
|
||||
expect(s.status).toBe(201);
|
||||
expect(s.body.data).toMatchObject({ status: "empty", revision: 1 });
|
||||
expect((await h.api("POST", "/acquisition-slots", { mediaEntityId: e, name: "1080p" })).status).toBe(409);
|
||||
expect(
|
||||
(
|
||||
await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, {
|
||||
expectedRevision: 9,
|
||||
softPreferences: { hdr: ["DV"] },
|
||||
})
|
||||
).status,
|
||||
).toBe(409);
|
||||
const ok = await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, {
|
||||
expectedRevision: 1,
|
||||
softPreferences: { hdr: ["DV"] },
|
||||
});
|
||||
expect(ok.body.data).toMatchObject({ revision: 2, softPreferences: { hdr: ["DV"] } });
|
||||
const list = await h.api("GET", `/acquisition-slots?mediaEntityId=${e}`);
|
||||
expect(list.body.data.items).toHaveLength(1);
|
||||
expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true);
|
||||
});
|
||||
|
||||
it("策略版本:草案 → 发布(持续授权)→ 新版本发布(旧授权吊销)→ 回滚(新版本号,内容同目标)", async () => {
|
||||
const bad = await h.api("POST", "/policy-revisions", {
|
||||
policyKey: "acq",
|
||||
kind: "acquisition",
|
||||
title: "x",
|
||||
rules: { newTitle: { enabled: "yes" } },
|
||||
});
|
||||
expect(bad.status).toBe(422);
|
||||
const rules = {
|
||||
defaultSlots: [{ name: "1080p", hardConditions: { resolutions: ["1080p"] } }],
|
||||
newTitle: {
|
||||
enabled: true,
|
||||
windowDays: 60,
|
||||
mediaKinds: ["movie"],
|
||||
slots: [{ name: "新片", hardConditions: { resolutions: ["1080p", "2160p"] } }],
|
||||
},
|
||||
};
|
||||
const r1 = await h.api("POST", "/policy-revisions", {
|
||||
policyKey: "acq",
|
||||
kind: "acquisition",
|
||||
title: "默认收录",
|
||||
rules,
|
||||
});
|
||||
expect(r1.body.data).toMatchObject({ revision: 1, status: "draft" });
|
||||
expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/preview`)).body.data.preview).toMatchObject({
|
||||
valid: true,
|
||||
templates: 2,
|
||||
});
|
||||
expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 2 })).status).toBe(
|
||||
409,
|
||||
);
|
||||
const p1 = await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 1 });
|
||||
expect(p1.body.data.status).toBe("published");
|
||||
expect((await h.api("PATCH", `/policy-revisions/${r1.body.data.id}`, { title: "改" })).status).toBe(409);
|
||||
const g1 = await h.db
|
||||
.selectFrom("automation_grant")
|
||||
.selectAll()
|
||||
.where("policy_revision_id", "=", r1.body.data.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(g1).toMatchObject({ status: "active", action: "policy.acquisition" });
|
||||
|
||||
const r2 = await h.api("POST", "/policy-revisions", {
|
||||
policyKey: "acq",
|
||||
kind: "acquisition",
|
||||
title: "只要 4K",
|
||||
rules: { defaultSlots: [{ name: "4k", hardConditions: { resolutions: ["2160p"] } }] },
|
||||
});
|
||||
expect(r2.body.data.revision).toBe(2);
|
||||
await h.api("POST", `/policy-revisions/${r2.body.data.id}/publish`, { expectedRevision: 2 });
|
||||
expect((await h.api("GET", `/policy-revisions/${r1.body.data.id}`)).body.data.status).toBe("superseded");
|
||||
expect(
|
||||
(await h.db.selectFrom("automation_grant").select("status").where("id", "=", g1.id).executeTakeFirstOrThrow())
|
||||
.status,
|
||||
).toBe("revoked");
|
||||
|
||||
const rb = await h.api("POST", `/policy-revisions/${r1.body.data.id}/rollback`);
|
||||
expect(rb.body.data).toMatchObject({ revision: 3, status: "published", basedOnRevisionId: r1.body.data.id, rules });
|
||||
expect((await h.api("GET", `/policy-revisions/${r2.body.data.id}`)).body.data.status).toBe("rolled_back");
|
||||
const active = await h.db
|
||||
.selectFrom("automation_grant")
|
||||
.select("policy_revision_id")
|
||||
.where("status", "=", "active")
|
||||
.execute();
|
||||
expect(active.map((a) => a.policy_revision_id)).toEqual([rb.body.data.id]);
|
||||
|
||||
// 订阅没有 Slot 时套用已发布策略的缺省 Slot
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "套模板", year: 2025 });
|
||||
await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "movie" });
|
||||
const slots = await h.db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", e).execute();
|
||||
expect(slots.map((s) => s.name)).toEqual(["1080p"]);
|
||||
});
|
||||
|
||||
it("新作品收录:库里没有、首映在窗口内 → 按已发布策略建订阅与 Slot 并预留计划;旧片不算新片", async () => {
|
||||
const base = await seedBase(h.db, {
|
||||
libraryPath: "/tmp/nonexistent-lib",
|
||||
cachePath: "/tmp",
|
||||
qbUrl: "http://127.0.0.1:9",
|
||||
});
|
||||
const recent = new Date(Date.now() - 5 * 86_400_000).toISOString().slice(0, 10);
|
||||
const fresh = (
|
||||
await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({
|
||||
kind: "movie",
|
||||
title: "新片",
|
||||
year: 2026,
|
||||
status: "active",
|
||||
metadata: JSON.stringify({ releaseDate: recent }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const t = makeTorrent({ name: "Fresh.2026.2160p.WEB-DL.mkv", length: 1000, pieceSeed: 3 });
|
||||
const rel = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: fresh,
|
||||
title: "Fresh.2026.2160p.WEB-DL.H265-NEW",
|
||||
size: 1000,
|
||||
hash: t.hash,
|
||||
});
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel });
|
||||
expect(ev.plans).toHaveLength(1);
|
||||
const sub = await h.db
|
||||
.selectFrom("subscription")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", fresh)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(sub).toMatchObject({ origin: "policy", mode: "once", priority: "new_work" });
|
||||
const plan = await h.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.selectAll()
|
||||
.where("id", "=", ev.plans[0]!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(plan).toMatchObject({ status: "reserved", priority: "new_work", purpose: "fill" });
|
||||
expect((plan.evaluation as { mode: string }).mode).toBe("new_title");
|
||||
|
||||
const old = await seedEntity(h.db, { kind: "movie", title: "老片", year: 1999 });
|
||||
const t2 = makeTorrent({ name: "Old.1999.1080p.mkv", length: 1000, pieceSeed: 4 });
|
||||
const rel2 = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: old,
|
||||
title: "Old.1999.1080p.BluRay-OLD",
|
||||
size: 1000,
|
||||
hash: t2.hash,
|
||||
});
|
||||
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel2 });
|
||||
expect(ev2.plans).toHaveLength(0);
|
||||
expect(ev2.skipped[0]?.reason).toBe("new_title:not_new_release");
|
||||
|
||||
// 总开关关闭:不建计划
|
||||
await h.setSettings("automation", { enabled: false });
|
||||
const t3 = makeTorrent({ name: "Fresh2.mkv", length: 1000, pieceSeed: 5 });
|
||||
const rel3 = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: fresh,
|
||||
title: "Fresh.2026.1080p.WEB-DL.H264-NEW",
|
||||
size: 1000,
|
||||
hash: t3.hash,
|
||||
});
|
||||
const ev3 = await evaluateAcquisition(h.d, { sourceReleaseId: rel3 });
|
||||
expect(ev3.plans).toHaveLength(0);
|
||||
await h.setSettings("automation", { enabled: true });
|
||||
});
|
||||
|
||||
it("偏好标签:删除/停用写抑制记录;Agent 不能据旧证据加回;用户重新添加解除抑制", async () => {
|
||||
const tag = { label: "不收恐怖片", kind: "hard", scope: { genres: ["horror"] }, rule: { exclude: true } };
|
||||
const c = await h.api("POST", "/preference-tags", tag);
|
||||
expect(c.status).toBe(201);
|
||||
expect((await h.api("POST", "/preference-tags", tag)).status).toBe(409);
|
||||
expect((await h.api("DELETE", `/preference-tags/${c.body.data.id}`)).status).toBe(200);
|
||||
const sup = await h.api("GET", "/preference-suppressions?active=true");
|
||||
expect(sup.body.data.items).toHaveLength(1);
|
||||
expect(sup.body.data.items[0].fingerprint).toBe(c.body.data.fingerprint);
|
||||
expect((await h.api("POST", "/preference-tags", { ...tag, origin: "agent_inferred" })).status).toBe(409);
|
||||
const again = await h.api("POST", "/preference-tags", { ...tag, origin: "manual" });
|
||||
expect(again.status).toBe(201);
|
||||
expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(0);
|
||||
const off = await h.api("PATCH", `/preference-tags/${again.body.data.id}`, { status: "disabled" });
|
||||
expect(off.body.data).toMatchObject({ status: "disabled", revision: 2 });
|
||||
expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(1);
|
||||
const list = await h.api("GET", "/preference-tags");
|
||||
expect(list.body.data.items.map((t: { id: string }) => t.id)).toEqual([again.body.data.id]);
|
||||
});
|
||||
});
|
||||
@@ -1,336 +0,0 @@
|
||||
import { chmod, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord, planImport } from "../services/organize.ts";
|
||||
import { entityScrapeLocations, runScrapeRecord, SCRAPE_TASK, scrapeEntity } from "../services/scrape.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// 刮削(SCR-1 / SCR-2):入库成功后自动对作品目录排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;
|
||||
// 图片失败不阻塞 NFO;只登记库根不写;作品详情「重新刮削」接口与刮削状态(最近一条刮削记录)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 200_000;
|
||||
/** 最小 JPEG 头(按文件头认图) */
|
||||
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]);
|
||||
const at = new Date().toISOString();
|
||||
const f = (value: unknown, language: string | null = null) => ({ value, source: "tmdb", language, at, locked: false });
|
||||
|
||||
async function tree(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const walk = async (d: string) => {
|
||||
for (const n of (await readdir(d)).sort()) {
|
||||
const p = join(d, n);
|
||||
if ((await stat(p)).isDirectory()) await walk(p);
|
||||
else out.push(relative(root, p));
|
||||
}
|
||||
};
|
||||
await walk(root);
|
||||
return out.filter((p) => !p.startsWith(".")).sort();
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
const fetched: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-scrlib-" });
|
||||
src = await createTempTree(
|
||||
{
|
||||
"Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv": SIZE,
|
||||
"Severance.S01E02.1080p.WEB-DL.H264-GRP.mkv": SIZE,
|
||||
"Interstellar.2014.1080p.BluRay.x264-GRP.mkv": SIZE,
|
||||
},
|
||||
{ prefix: "umt-scrsrc-" },
|
||||
);
|
||||
h = await createHarness("scrape", {
|
||||
async scrapeImage(p) {
|
||||
fetched.push(p);
|
||||
if (p === "/broken.jpg") throw new Error("TMDB 图片返回 HTTP 404");
|
||||
return { content: JPEG, mime: "image/jpeg" };
|
||||
},
|
||||
});
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
||||
await h.setSettings("organize", { scrapeAfterImport: true });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
async function organize(entity: string, file: string) {
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(file)],
|
||||
mediaEntityId: entity,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
});
|
||||
expect(plan.status).toBe("running");
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
expect(ex.status).toBe("succeeded");
|
||||
return plan.recordId!;
|
||||
}
|
||||
|
||||
/** 按引擎记录的入队项跑刮削(假引擎不执行):该作品最近一条刮削记录 */
|
||||
async function runQueued(entity: string) {
|
||||
const recs = await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["id"])
|
||||
.where("media_entity_id", "=", entity)
|
||||
.where("status", "=", "queued")
|
||||
.orderBy("requested_at", "desc")
|
||||
.execute();
|
||||
expect(recs.length).toBeGreaterThan(0);
|
||||
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
|
||||
expect(q.some((x) => (x.input as { recordId: string }).recordId === recs[0]!.id)).toBe(true);
|
||||
return runScrapeRecord(h.d, recs[0]!.id);
|
||||
}
|
||||
|
||||
/** 作品详情「重新刮削」的服务端做法:对作品目录排刮削并执行 */
|
||||
async function rescrape(entity: string, mode?: "missing" | "overwrite") {
|
||||
const q = await scrapeEntity(h.d, entity, { trigger: "manual", mode });
|
||||
expect(q.length).toBe(1);
|
||||
return runScrapeRecord(h.d, q[0]!.recordId);
|
||||
}
|
||||
|
||||
let series = "";
|
||||
let movie = "";
|
||||
|
||||
it("剧集:入库后自动排刮削,写 tvshow / 季 / 集 NFO、海报、季海报、剧照", async () => {
|
||||
series = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
|
||||
await h.db
|
||||
.updateTable("media_entity")
|
||||
.set({
|
||||
original_title: "Severance",
|
||||
metadata: JSON.stringify({
|
||||
title: f("人生切割术", "zh-CN"),
|
||||
originalTitle: f("Severance", "en"),
|
||||
year: f(2022),
|
||||
releaseDate: f("2022-02-17"),
|
||||
overview: f("马克领导着一个办公室团队。", "zh-CN"),
|
||||
genres: f(["剧情", "悬疑"]),
|
||||
voteAverage: f(8.4),
|
||||
originCountry: f(["US"]),
|
||||
platform: f("Apple TV+"),
|
||||
posterPath: f("/sev.jpg"),
|
||||
backdropPath: f("/broken.jpg"),
|
||||
cast: f([
|
||||
{ id: 4495, name: "亚当·斯科特", originalName: null, role: "Mark Scout", profilePath: "/adam.jpg" },
|
||||
]),
|
||||
crew: [],
|
||||
externalIds: f({ imdb: "tt11280740", tvdb: "371980", wikidata: null }),
|
||||
}),
|
||||
})
|
||||
.where("id", "=", series)
|
||||
.execute();
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: series,
|
||||
provider: "tmdb",
|
||||
entity_type: "tv",
|
||||
external_id: "95396",
|
||||
status: "accepted",
|
||||
})
|
||||
.execute();
|
||||
const season = await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: series,
|
||||
kind: "season",
|
||||
season_number: 1,
|
||||
status: "confirmed",
|
||||
title: "第 1 季",
|
||||
metadata: JSON.stringify({ name: f("第 1 季", "zh-CN"), airDate: f("2022-02-17"), posterPath: f("/s1.jpg") }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
for (const [n, name, still] of [
|
||||
[1, "有关地狱的好消息", "/e1.jpg"],
|
||||
[2, "半循环", null],
|
||||
] as const)
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: series,
|
||||
parent_unit_id: season.id,
|
||||
kind: "episode",
|
||||
season_number: 1,
|
||||
episode_number: n,
|
||||
status: "confirmed",
|
||||
title: name,
|
||||
air_date: "2022-02-17",
|
||||
metadata: JSON.stringify({
|
||||
name: f(name, "zh-CN"),
|
||||
overview: f(`第 ${n} 集简介`, "zh-CN"),
|
||||
airDate: f("2022-02-17"),
|
||||
runtime: f(55),
|
||||
voteAverage: f(8),
|
||||
...(still ? { stillPath: f(still) } : {}),
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const rec = await organize(series, "Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv");
|
||||
await organize(series, "Severance.S01E02.1080p.WEB-DL.H264-GRP.mkv");
|
||||
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
|
||||
expect(q[0]!.key).toBe(`${SCRAPE_TASK}:record:${rec}:0`);
|
||||
const first = await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.selectAll()
|
||||
.where("id", "=", (q[0]!.input as { recordId: string }).recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(first).toMatchObject({
|
||||
media_entity_id: series,
|
||||
trigger: "auto",
|
||||
mode: "missing",
|
||||
organization_record_id: rec,
|
||||
});
|
||||
|
||||
const r = await runQueued(series);
|
||||
expect(r.status).toBe("partial"); // 背景图下载失败
|
||||
const files = await tree(lib.root);
|
||||
const show = files.find((p) => p.endsWith("tvshow.nfo"))!;
|
||||
const dir = show.slice(0, -"/tvshow.nfo".length);
|
||||
const ep1 = files.find((p) => p.includes("S01E01") && p.endsWith(".mkv"))!;
|
||||
const stem1 = ep1.slice(0, -4);
|
||||
expect(files).toEqual(
|
||||
expect.arrayContaining([
|
||||
`${dir}/tvshow.nfo`,
|
||||
`${dir}/poster.jpg`,
|
||||
`${dir}/season01-poster.jpg`,
|
||||
`${dir}/Season 1/season.nfo`,
|
||||
`${dir}/Season 1/poster.jpg`,
|
||||
`${stem1}.nfo`,
|
||||
`${stem1}-thumb.jpg`,
|
||||
]),
|
||||
);
|
||||
expect(files.some((p) => p.endsWith("fanart.jpg"))).toBe(false);
|
||||
expect(files.filter((p) => p.endsWith(".nfo"))).toHaveLength(4);
|
||||
expect(files.some((p) => p.includes(".umt-scrape.tmp"))).toBe(false);
|
||||
const nfo = await readFile(join(lib.root, show), "utf8");
|
||||
expect(nfo.startsWith('<?xml version="1.0" encoding="utf-8"?>\n<tvshow>')).toBe(true);
|
||||
expect(nfo).toContain('<uniqueid type="imdb" default="true">tt11280740</uniqueid>');
|
||||
expect(nfo).toContain("<country>美国</country>");
|
||||
const epNfo = await readFile(join(lib.root, `${stem1}.nfo`), "utf8");
|
||||
expect(epNfo).toContain("<title>有关地狱的好消息</title>");
|
||||
expect(epNfo).toMatch(/<season>1<\/season>\n {2}<episode>1<\/episode>/);
|
||||
// 同一张季海报只取一次
|
||||
expect(fetched.filter((p) => p === "/s1.jpg")).toHaveLength(1);
|
||||
|
||||
const st = await h.api("GET", `/library/${series}`);
|
||||
expect(st.status).toBe(200);
|
||||
expect(st.body.data.scrape).toMatchObject({
|
||||
status: "partial",
|
||||
trigger: "auto",
|
||||
failed: 1,
|
||||
reason: "scrape_image_failed",
|
||||
});
|
||||
expect(st.body.data.scrape.files.find((x: { role: string }) => x.role === "fanart")).toMatchObject({
|
||||
action: "failed",
|
||||
reason: "scrape_image_failed",
|
||||
});
|
||||
const audit = await h.db
|
||||
.selectFrom("audit_event")
|
||||
.select(["action", "target_id", "target_type"])
|
||||
.where("action", "=", "organize.scrape")
|
||||
.execute();
|
||||
expect(audit.some((a) => a.target_type === "scrape_record" && a.target_id === st.body.data.scrape.id)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it("只补缺失:已有文件(包括别人写的)不动;全部覆盖:改写", async () => {
|
||||
const files = await tree(lib.root);
|
||||
const show = join(lib.root, files.find((p) => p.endsWith("tvshow.nfo"))!);
|
||||
await writeFile(show, "<tvshow>别的工具写的</tvshow>");
|
||||
const again = await rescrape(series, "missing");
|
||||
expect(await readFile(show, "utf8")).toBe("<tvshow>别的工具写的</tvshow>");
|
||||
expect(again.files.find((x) => x.role === "tvshow_nfo")).toMatchObject({
|
||||
action: "skipped",
|
||||
reason: "scrape_exists",
|
||||
});
|
||||
const over = await rescrape(series, "overwrite");
|
||||
expect(over.written).toBeGreaterThan(0);
|
||||
expect(await readFile(show, "utf8")).toContain("<title>人生切割术</title>");
|
||||
}, 60_000);
|
||||
|
||||
it("电影:<视频名>.nfo + 作品目录 poster / fanart;手动「重新刮削」接口排任务", async () => {
|
||||
await h.setSettings("organize", { scrapeAfterImport: false });
|
||||
movie = await seedEntity(h.db, { kind: "movie", title: "星际穿越", year: 2014 });
|
||||
await h.db
|
||||
.updateTable("media_entity")
|
||||
.set({
|
||||
metadata: JSON.stringify({
|
||||
title: f("星际穿越", "zh-CN"),
|
||||
overview: f("穿越虫洞。", "zh-CN"),
|
||||
releaseDate: f("2014-11-05"),
|
||||
runtime: f(169),
|
||||
posterPath: f("/inter.jpg"),
|
||||
backdropPath: f("/inter-bg.jpg"),
|
||||
crew: f([{ id: 525, name: "克里斯托弗·诺兰", originalName: null, role: "Director", profilePath: null }]),
|
||||
}),
|
||||
})
|
||||
.where("id", "=", movie)
|
||||
.execute();
|
||||
const before = h.engine.enqueued.length;
|
||||
await organize(movie, "Interstellar.2014.1080p.BluRay.x264-GRP.mkv");
|
||||
expect(h.engine.enqueued.slice(before).some((x) => x.task === SCRAPE_TASK)).toBe(false); // 关了自动刮削
|
||||
|
||||
const res = await h.api("POST", `/library/${movie}/scrape`, {});
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body.data).toMatchObject({ mediaEntityId: movie, deduplicated: false });
|
||||
const dup = await h.api("POST", `/library/${movie}/scrape`, {});
|
||||
expect(dup.body.data.deduplicated).toBe(true);
|
||||
expect((await h.api("GET", `/library/${movie}`)).body.data.scrape.status).toBe("queued");
|
||||
|
||||
const r = await runQueued(movie);
|
||||
expect(r.status).toBe("succeeded");
|
||||
const files = await tree(lib.root);
|
||||
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
|
||||
const dir = video.slice(0, video.lastIndexOf("/"));
|
||||
expect(files.filter((p) => p.startsWith(`${dir}/`))).toEqual(
|
||||
[video, `${video.slice(0, -4)}.nfo`, `${dir}/fanart.jpg`, `${dir}/poster.jpg`].sort(),
|
||||
);
|
||||
const nfo = await readFile(join(lib.root, `${video.slice(0, -4)}.nfo`), "utf8");
|
||||
expect(nfo).toContain('<director tmdbid="525">克里斯托弗·诺兰</director>');
|
||||
expect(nfo).toContain("<runtime>169</runtime>");
|
||||
}, 60_000);
|
||||
|
||||
it("蓝光原盘资产:原盘目录本身就是刮削目录,不取上一级", async () => {
|
||||
const disc = await seedEntity(h.db, { kind: "movie", title: "原盘", year: 2020 });
|
||||
const root = await h.db.selectFrom("library_root").select("id").executeTakeFirstOrThrow();
|
||||
const asset = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: disc, kind: "bluray_dir" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("asset_location")
|
||||
.values({ media_asset_id: asset.id, library_root_id: root.id, relative_path: "Movies/原盘 (2020)" })
|
||||
.execute();
|
||||
expect((await entityScrapeLocations(h.d, disc, "movie")).map((l) => l.path)).toEqual(["Movies/原盘 (2020)"]);
|
||||
});
|
||||
|
||||
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 412", async () => {
|
||||
const files = await tree(lib.root);
|
||||
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
|
||||
const dirAbs = join(lib.root, video.slice(0, video.lastIndexOf("/")));
|
||||
if (process.getuid?.() !== 0) {
|
||||
await chmod(dirAbs, 0o555);
|
||||
const r = await rescrape(movie, "overwrite");
|
||||
await chmod(dirAbs, 0o755);
|
||||
expect(r.files.some((x) => x.reason === "scrape_write_failed")).toBe(true);
|
||||
}
|
||||
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).execute();
|
||||
const ro = await rescrape(movie, "overwrite");
|
||||
expect(ro).toMatchObject({ status: "failed", reason: "scrape_root_read_only", written: 0 });
|
||||
await h.db.updateTable("library_root").set({ management_mode: "managed" }).execute();
|
||||
|
||||
const empty = await seedEntity(h.db, { kind: "movie", title: "没入库", year: 2020 });
|
||||
expect((await h.api("POST", `/library/${empty}/scrape`, {})).status).toBe(412);
|
||||
expect(await scrapeEntity(h.d, empty, { trigger: "manual" })).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,346 +0,0 @@
|
||||
import { createQbClient } from "@umt/adapters";
|
||||
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../services/organize.ts";
|
||||
import { applyEpisodeTotal, subscriptionProgress, sweepDueSlots } from "../services/subscriptions.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
|
||||
|
||||
// W5-C 订阅增强集成:Slot×单元多在途 + 忙时延后重评 + 移除释放;季包按缺集选文件(mock qB files/filePrio);
|
||||
// 总集数未知不结束(疑似缺集);once 完成写历史;pending 不完成;分集洗版只替换能提升的单元。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 300_000;
|
||||
|
||||
describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
const disk = fakeDisk(10_000_000_000);
|
||||
const torrents = new Map<string, Uint8Array>();
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
|
||||
cache = await createTempTree({}, { prefix: "umt-cache-" });
|
||||
disk.state.watch = [cache.root];
|
||||
disk.setPct(10);
|
||||
h = await createHarness("subscription", {
|
||||
diskUsage: disk.fn,
|
||||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||||
});
|
||||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
await lib?.cleanup();
|
||||
await cache?.cleanup();
|
||||
});
|
||||
|
||||
const db = () => h.db;
|
||||
|
||||
async function seriesSlot(
|
||||
title: string,
|
||||
opts: { mode?: "once" | "continuous"; status?: "active" | "pending"; hard?: object; upgrade?: object } = {},
|
||||
) {
|
||||
const entityId = await seedEntity(h.db, { kind: "series", title, year: 2024 });
|
||||
const sub = await db()
|
||||
.insertInto("subscription")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
media_kind: "series",
|
||||
mode: opts.mode ?? "continuous",
|
||||
status: opts.status ?? "active",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const slot = await db()
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
subscription_id: sub.id,
|
||||
name: "1080p",
|
||||
hard_conditions: JSON.stringify(opts.hard ?? { resolutions: ["1080p"] }),
|
||||
upgrade_threshold: JSON.stringify(opts.upgrade ?? {}),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return { entityId, subId: sub.id, slotId: slot.id };
|
||||
}
|
||||
|
||||
async function release(entityId: string, title: string, files?: { path: string; length: number }[]) {
|
||||
const t = files
|
||||
? makeTorrent({ name: title, files, pieceSeed: Math.floor(Math.random() * 1e6) })
|
||||
: makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
|
||||
const id = await seedRelease(h.db, { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
|
||||
torrents.set(id, t.bytes);
|
||||
return { id, hash: t.hash, name: t.name };
|
||||
}
|
||||
|
||||
const slotRow = (id: string) =>
|
||||
db().selectFrom("acquisition_slot").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const planRow = (id: string) =>
|
||||
db().selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const subRow = (id: string) =>
|
||||
db().selectFrom("subscription").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
|
||||
async function planFor(relId: string) {
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
|
||||
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
||||
return ev.plans[0]!;
|
||||
}
|
||||
async function submit(planId: string) {
|
||||
const s = await submitDownload(h.d, planId);
|
||||
expect(s.status).toBe("submitted");
|
||||
return s.downloadTaskId!;
|
||||
}
|
||||
async function completeAndImport(hash: string, dtId: string) {
|
||||
await qb.complete(hash, { writeFiles: true });
|
||||
const sync = await syncDownloads(h.d);
|
||||
expect(sync.completed).toContain(dtId);
|
||||
const p = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
|
||||
expect(p.status, JSON.stringify(p)).toBe("running");
|
||||
const ex = await executeRecord(h.d, p.recordId!);
|
||||
expect(ex.status).toBe("succeeded");
|
||||
}
|
||||
|
||||
it("Slot×单元多在途:两集同时在途;重叠单元的发布延后;下载被移除即释放预留,重评后补上", async () => {
|
||||
const { entityId, slotId } = await seriesSlot("熊家餐馆");
|
||||
const e1 = await release(entityId, "The.Bear.S01E01.1080p.WEB-DL.x264-GRP");
|
||||
const e2 = await release(entityId, "The.Bear.S01E02.1080p.WEB-DL.x264-GRP");
|
||||
const p1 = await planFor(e1.id);
|
||||
const p2 = await planFor(e2.id); // Slot 已 reserved,单元不相交 → 第二个在途计划
|
||||
expect((await planRow(p1)).unit_keys).toEqual(["s1e1"]);
|
||||
expect((await planRow(p2)).unit_keys).toEqual(["s1e2"]);
|
||||
expect((await slotRow(slotId)).status).toBe("reserved");
|
||||
|
||||
// 同一集的另一个版本:单元在途 → 延后(deferred),不丢
|
||||
const e2b = await release(entityId, "The.Bear.S01E02.1080p.WEB-DL.x265-OTHER");
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: e2b.id });
|
||||
expect(ev.plans).toHaveLength(0);
|
||||
expect(ev.deferred).toEqual([{ sourceReleaseId: e2b.id, slotId, reason: "units_inflight" }]);
|
||||
expect(
|
||||
await db().selectFrom("slot_deferral").select(["unit_keys"]).where("slot_id", "=", slotId).execute(),
|
||||
).toEqual([{ unit_keys: ["s1e2"] }]);
|
||||
|
||||
const dt1 = await submit(p1);
|
||||
await submit(p2);
|
||||
expect((await slotRow(slotId)).status).toBe("downloading");
|
||||
await completeAndImport(e1.hash, dt1);
|
||||
// E01 入库,但 E02 仍在途:Slot 显示在途阶段而不是静止态;分集账本登记 E01
|
||||
const s1 = await slotRow(slotId);
|
||||
expect(s1.status).toBe("downloading");
|
||||
expect(s1.current_asset_id).not.toBeNull();
|
||||
expect(s1.next_evaluate_at).not.toBeNull();
|
||||
const ledger = await db()
|
||||
.selectFrom("slot_unit as su")
|
||||
.innerJoin("content_unit as u", "u.id", "su.content_unit_id")
|
||||
.select(["u.episode_number", "su.plan_id"])
|
||||
.where("su.slot_id", "=", slotId)
|
||||
.execute();
|
||||
expect(ledger).toEqual([{ episode_number: 1, plan_id: p1 }]);
|
||||
|
||||
// 用户在 qB 里删掉 E02:确认移除 → 计划失败释放单元 → Slot 到期重评 → 延后的 E02 另一版本得到计划
|
||||
const qc = createQbClient({ instanceId: "t", baseUrl: qb.url, username: "admin", password: "adminadmin" });
|
||||
await qc.delete([e2.hash], { deleteFiles: false });
|
||||
await syncDownloads(h.d);
|
||||
expect(await planRow(p2)).toMatchObject({ status: "failed", status_reason: "download_removed" });
|
||||
expect((await slotRow(slotId)).status).toBe("attention");
|
||||
h.engine.enqueued.length = 0;
|
||||
expect(await sweepDueSlots(h.d)).toBeGreaterThanOrEqual(1);
|
||||
expect(h.engine.enqueued).toContainEqual(
|
||||
expect.objectContaining({ task: "evaluateAcquisition", input: { acquisitionSlotId: slotId } }),
|
||||
);
|
||||
const re = await evaluateAcquisition(h.d, { acquisitionSlotId: slotId });
|
||||
expect(re.plans).toHaveLength(1);
|
||||
expect(await planRow(re.plans[0]!)).toMatchObject({ source_release_id: e2b.id, unit_keys: ["s1e2"] });
|
||||
// e2b 的延后已消化;被移除的原 E02 此时单元在途(e2b)→ 转为延后,等 e2b 结束后再判
|
||||
const left = await db()
|
||||
.selectFrom("slot_deferral")
|
||||
.select("source_release_id")
|
||||
.where("slot_id", "=", slotId)
|
||||
.execute();
|
||||
expect(left.map((x) => x.source_release_id)).toEqual([e2.id]);
|
||||
// 候选评估记录带 trace / unitKeys
|
||||
const art = await db()
|
||||
.selectFrom("artifact")
|
||||
.select("content")
|
||||
.where("subject_id", "=", slotId)
|
||||
.where("title", "=", "candidate_evaluation")
|
||||
.execute();
|
||||
const byRel = new Map(art.map((a) => [(a.content as { sourceReleaseId: string }).sourceReleaseId, a.content]));
|
||||
expect(byRel.get(e2b.id)).toMatchObject({ decision: "accept", unitKeys: ["s1e2"], pri: 100 });
|
||||
}, 60_000);
|
||||
|
||||
it("季包:总集数已知 → 只预留缺集;提交后读文件清单,已覆盖/在途的集文件优先级置 0", async () => {
|
||||
const { entityId, slotId } = await seriesSlot("选集剧");
|
||||
await db()
|
||||
.transaction()
|
||||
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 4, now: new Date() }));
|
||||
const e1 = await release(entityId, "Pick.Show.S01E01.1080p.WEB-DL.x264-GRP");
|
||||
await completeAndImport(e1.hash, await submit(await planFor(e1.id)));
|
||||
const e2 = await release(entityId, "Pick.Show.S01E02.1080p.WEB-DL.x264-GRP");
|
||||
const p2 = await planFor(e2.id); // E02 在途(未提交)
|
||||
|
||||
const packTitle = "Pick.Show.S01.1080p.WEB-DL.x264-GRP";
|
||||
const files = [1, 2, 3, 4].map((e) => ({ path: `Pick.Show.S01E0${e}.1080p.WEB-DL.x264-GRP.mkv`, length: SIZE }));
|
||||
const pack = await release(entityId, packTitle, [
|
||||
...files,
|
||||
{ path: "Pick.Show.S01E01.chs.srt", length: 100 },
|
||||
{ path: "Pick.Show.S01E03.chs.srt", length: 100 },
|
||||
]);
|
||||
const pp = await planFor(pack.id);
|
||||
expect((await planRow(pp)).unit_keys).toEqual(["s1e3", "s1e4"]);
|
||||
const before = qb.callsTo("torrents/filePrio").length;
|
||||
await submit(pp);
|
||||
const prio = qb.callsTo("torrents/filePrio").slice(before);
|
||||
expect(prio).toHaveLength(1);
|
||||
const t = qb.get(pack.hash)!;
|
||||
const off = t.files.filter((f) => f.priority === 0).map((f) => f.name.split("/").pop());
|
||||
expect(off.sort()).toEqual(
|
||||
[
|
||||
"Pick.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv",
|
||||
"Pick.Show.S01E01.chs.srt",
|
||||
"Pick.Show.S01E02.1080p.WEB-DL.x264-GRP.mkv",
|
||||
].sort(),
|
||||
);
|
||||
expect((await planRow(pp)).file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s1e3", "s1e4"] });
|
||||
// 进度:E01 已覆盖,E02–E04 在途
|
||||
const prog = await subscriptionProgress(db(), (await slotRow(slotId)).subscription_id!);
|
||||
expect(prog!.seasons[0]).toMatchObject({ seasonNumber: 1, episodeTotal: 4, totalKnown: true, missing: [] });
|
||||
expect(prog!.seasons[0]!.units.map((u) => u.state)).toEqual(["covered", "inflight", "inflight", "inflight"]);
|
||||
expect(prog!.slots[0]!.inflightPlans.map((x) => x.planId).sort()).toEqual([p2, pp].sort());
|
||||
|
||||
// 认不出集号的整包:整包下载并记理由
|
||||
const { entityId: e2Id } = await seriesSlot("整包剧");
|
||||
const odd = await release(e2Id, "Odd.Show.S01.1080p.WEB-DL.x264-GRP", [
|
||||
{ path: "disc1.mkv", length: SIZE },
|
||||
{ path: "disc2.mkv", length: SIZE },
|
||||
]);
|
||||
const po = await planFor(odd.id);
|
||||
expect((await planRow(po)).unit_keys).toEqual(["s1*"]);
|
||||
const n0 = qb.callsTo("torrents/filePrio").length;
|
||||
await submit(po);
|
||||
expect(qb.callsTo("torrents/filePrio").length).toBe(n0);
|
||||
expect((await planRow(po)).file_selection).toMatchObject({ mode: "whole", reason: "unmapped_video_file" });
|
||||
}, 60_000);
|
||||
|
||||
it("总集数未知:入库后不结束、记疑似缺集;总集数就绪后补齐 → once 订阅完成并写历史", async () => {
|
||||
const { entityId, slotId, subId } = await seriesSlot("未知集数剧", { mode: "once" });
|
||||
for (const ep of [1, 3]) {
|
||||
const r = await release(entityId, `Unknown.Total.S01E0${ep}.1080p.WEB-DL.x264-GRP`);
|
||||
await completeAndImport(r.hash, await submit(await planFor(r.id)));
|
||||
}
|
||||
expect((await slotRow(slotId)).status).toBe("empty");
|
||||
expect(await subRow(subId)).toMatchObject({ status: "active" });
|
||||
const dec = await db()
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("dedupe_key", "=", `suspected_missing:${slotId}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(dec.evidence).toMatchObject({ suspectedMissing: [{ season: 1, episodes: [2] }] });
|
||||
|
||||
await db()
|
||||
.transaction()
|
||||
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 3, now: new Date() }));
|
||||
const r2 = await release(entityId, "Unknown.Total.S01E02.1080p.WEB-DL.x264-GRP");
|
||||
const acceptedTask = await submit(await planFor(r2.id));
|
||||
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
|
||||
expect((await slotRow(slotId)).status).toBe("downloading");
|
||||
await completeAndImport(r2.hash, acceptedTask);
|
||||
expect((await slotRow(slotId)).status).toBe("satisfied");
|
||||
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
|
||||
const hist = await db()
|
||||
.selectFrom("subscription_history")
|
||||
.selectAll()
|
||||
.where("subscription_id", "=", subId)
|
||||
.execute();
|
||||
expect(hist).toHaveLength(1);
|
||||
expect(hist[0]).toMatchObject({ reason: "download_accepted", media_entity_id: entityId, mode: "once" });
|
||||
expect((hist[0]!.progress as { seasons: { complete: boolean }[] }).seasons[0]!.complete).toBe(true);
|
||||
// 历史 API + 重新订阅(改回 active)
|
||||
const api = await h.api("GET", `/subscription-history?subscriptionId=${subId}`);
|
||||
expect(api.body.data.items).toHaveLength(1);
|
||||
const re = await h.api("PATCH", `/subscriptions/${subId}`, { status: "active" });
|
||||
expect(re.body.data.status).toBe("active");
|
||||
}, 90_000);
|
||||
|
||||
it("多个目标版本按各自已接收单元完成,单版本入库不提前完成订阅", async () => {
|
||||
const { entityId, subId } = await seriesSlot("双版本剧", { mode: "once" });
|
||||
await db()
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
subscription_id: subId,
|
||||
name: "720p",
|
||||
hard_conditions: JSON.stringify({ resolutions: ["720p"] }),
|
||||
})
|
||||
.execute();
|
||||
await db()
|
||||
.transaction()
|
||||
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
|
||||
const first = await release(entityId, "Two.Versions.S01E01.1080p.WEB-DL.x264-GRP");
|
||||
const firstTask = await submit(await planFor(first.id));
|
||||
expect((await subRow(subId)).status).toBe("active");
|
||||
await completeAndImport(first.hash, firstTask);
|
||||
expect((await subRow(subId)).status).toBe("active");
|
||||
const second = await release(entityId, "Two.Versions.S01E01.720p.WEB-DL.x264-GRP");
|
||||
await submit(await planFor(second.id));
|
||||
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
|
||||
await qb.complete(second.hash, { writeFiles: true });
|
||||
await syncDownloads(h.d);
|
||||
}, 60_000);
|
||||
|
||||
it("pending(TMDB 未就绪)照常下载但不完成;暂停的订阅不评估", async () => {
|
||||
const { entityId, slotId, subId } = await seriesSlot("待定剧", { mode: "once", status: "pending" });
|
||||
await db()
|
||||
.transaction()
|
||||
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
|
||||
const r = await release(entityId, "Pending.Show.S01E01.1080p.WEB-DL.x264-GRP");
|
||||
await completeAndImport(r.hash, await submit(await planFor(r.id)));
|
||||
expect((await slotRow(slotId)).status).toBe("empty");
|
||||
expect((await subRow(subId)).status).toBe("pending");
|
||||
|
||||
const paused = await seriesSlot("暂停剧", { status: "active" });
|
||||
await db().updateTable("subscription").set({ status: "paused" }).where("id", "=", paused.subId).execute();
|
||||
const pr = await release(paused.entityId, "Paused.Show.S01E01.1080p.WEB-DL.x264-GRP");
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: pr.id });
|
||||
expect(ev).toMatchObject({ evaluated: 0, plans: [] });
|
||||
}, 60_000);
|
||||
|
||||
it("分集洗版:只替换能提升的单元;同档不换(trace 可读)", async () => {
|
||||
const { entityId, slotId } = await seriesSlot("洗版剧", {
|
||||
hard: { resolutions: ["720p", "1080p"] },
|
||||
upgrade: { enabled: true, allowResolutionUpgrade: true },
|
||||
});
|
||||
const r = await release(entityId, "Wash.Show.S01E01.720p.WEB-DL.x264-GRP");
|
||||
await completeAndImport(r.hash, await submit(await planFor(r.id)));
|
||||
const same = await release(entityId, "Wash.Show.S01E01.720p.WEB-DL.x264-OTHER");
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: same.id });
|
||||
expect(ev.plans).toHaveLength(0);
|
||||
const up = await release(entityId, "Wash.Show.S01E01-E02.1080p.WEB-DL.x264-GRP");
|
||||
// E02 还缺 → 追更(fill);只有 E01 覆盖时的纯升级场景见下一条
|
||||
const fill = await planFor(up.id);
|
||||
expect(await planRow(fill)).toMatchObject({ purpose: "fill", unit_keys: ["s1e2"] });
|
||||
await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("id", "=", fill).execute();
|
||||
await db().updateTable("acquisition_slot").set({ status: "empty" }).where("id", "=", slotId).execute();
|
||||
const up1 = await release(entityId, "Wash.Show.S01E01.1080p.WEB-DL.x264-GRP");
|
||||
const pu = await planFor(up1.id);
|
||||
expect(await planRow(pu)).toMatchObject({ purpose: "upgrade", unit_keys: ["s1e1"] });
|
||||
const art = await db()
|
||||
.selectFrom("artifact")
|
||||
.select("content")
|
||||
.where("subject_id", "=", slotId)
|
||||
.where("title", "=", "candidate_evaluation")
|
||||
.execute();
|
||||
const sameEval = art
|
||||
.map((a) => a.content as { sourceReleaseId: string; trace?: string[] })
|
||||
.find((a) => a.sourceReleaseId === same.id);
|
||||
expect(sameEval?.trace?.[0]).toBe("与当前版本同档,不反复更换");
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { addAliasIfAbsent } from "../../sources-identity/service/entities.ts";
|
||||
import { syncDownloads } from "../services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../services/organize.ts";
|
||||
import { organizeDecisionHook, pickMainVideo, takeoverParsed } from "../services/takeover.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// QB-1:接管任务(用户手动加进 qB、贴管理标签,没有站点资源)完成后按 种子名 + 主视频文件名 识别 → 直接整理;
|
||||
// 识别失败建带选项的事项(候选 / 选择作品 / 临时作品 / 忽略),处理后执行并闭环;目标已存在 → 替换 / 保留两者 / 跳过;
|
||||
// 手动按路径整理也关闭同一下载任务的整理事项;作品详情下载历史列出接管任务与整理记录。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 300_000;
|
||||
|
||||
function walkFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const n of readdirSync(dir)) {
|
||||
const p = join(dir, n);
|
||||
if (statSync(p).isDirectory()) out.push(...walkFiles(p));
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("接管识别:文件名解析", () => {
|
||||
it("主视频取要下载的最大视频,跳过 sample;文件名 + 目录 + 种子名合并", () => {
|
||||
expect(
|
||||
pickMainVideo([
|
||||
{ name: "Movie.2021/Sample/sample.mkv", size: 9e9 },
|
||||
{ name: "Movie.2021/Movie.2021.1080p.mkv", size: 5e9 },
|
||||
{ name: "Movie.2021/Extra.mkv", size: 6e9, priority: 0 },
|
||||
{ name: "Movie.2021/Movie.2021.nfo", size: 1e3 },
|
||||
]),
|
||||
).toBe("Movie.2021/Movie.2021.1080p.mkv");
|
||||
expect(pickMainVideo([{ name: "a.nfo", size: 1 }])).toBeNull();
|
||||
// 文件名只有集号:标题 / 季从目录名(=种子根目录)补
|
||||
const p = takeoverParsed("The.Bear.S02.1080p.WEB-DL", "The.Bear.S02.1080p.WEB-DL/E03.mkv");
|
||||
expect(p.title?.toLowerCase()).toContain("bear");
|
||||
expect(p.season).toBe(2);
|
||||
expect(p.episodes).toEqual({ start: 3, end: 3 });
|
||||
// 单文件种子:种子名即文件名
|
||||
const m = takeoverParsed("Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer.2023.2160p.UHD.BluRay.mkv");
|
||||
expect(m.year).toBe(2023);
|
||||
// 文件名解析不出标题:用种子名
|
||||
expect(takeoverParsed("Heat.1995.1080p.BluRay", "Heat.1995.1080p.BluRay/0001.mkv").year).toBe(1995);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("QB-1 接管任务识别与事项闭环(真实 PG + mock qB + 临时目录)", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
let n = 0;
|
||||
const actor = { type: "user", id: "tester" };
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
|
||||
cache = await createTempTree({}, { prefix: "umt-cache-" });
|
||||
h = await createHarness("takeover", {}, { withSources: true });
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
await h.setSettings("retention", { seedRetentionDays: 0 });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
await lib?.cleanup();
|
||||
await cache?.cleanup();
|
||||
});
|
||||
|
||||
/** 用户在 qB 里手动加的任务(贴管理标签)→ 同步接管 → 完成落盘 */
|
||||
async function adoptCompleted(name: string, file: string, size = SIZE) {
|
||||
n++;
|
||||
const hash = n.toString(16).padStart(40, "a");
|
||||
qb.seed({ hash, name, tags: ["umt"], save_path: cache.root });
|
||||
await qb.complete(hash, { files: [{ path: `${name}/${file}`, size }] });
|
||||
const sync = await syncDownloads(h.d);
|
||||
expect(sync.adopted).toBeGreaterThanOrEqual(1);
|
||||
const dt = await h.db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("torrent_hash", "=", hash)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(dt).toMatchObject({ origin: "adopted", source_release_id: null, status: "completed" });
|
||||
return dt;
|
||||
}
|
||||
|
||||
const decisionOf = (key: string) =>
|
||||
h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("dedupe_key", "=", key)
|
||||
.orderBy("created_at", "desc")
|
||||
.executeTakeFirst();
|
||||
|
||||
let movieId: string;
|
||||
let firstDtId: string;
|
||||
|
||||
it("识别成功(本地别名召回):不建事项,直接整理入库并回填下载任务的作品", async () => {
|
||||
movieId = await seedEntity(h.db, { kind: "movie", title: "Quiet Harbor", year: 2021 });
|
||||
await addAliasIfAbsent(h.db, movieId, "Quiet Harbor", "original", "user");
|
||||
const dt = await adoptCompleted(
|
||||
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
|
||||
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
|
||||
);
|
||||
firstDtId = dt.id;
|
||||
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
||||
expect(plan.status).toBe("running");
|
||||
const done = await executeRecord(h.d, plan.recordId!);
|
||||
expect(done.status).toBe("succeeded");
|
||||
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
|
||||
expect(row).toMatchObject({ media_entity_id: movieId, status: "imported" });
|
||||
expect(await decisionOf(`organize_identity:${dt.id}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
let unknownDtId: string;
|
||||
|
||||
it("识别失败:事项带 选择作品(本地 / TMDB)/ 临时作品 / 忽略;选临时作品 → 建临时作品并整理,事项闭环", async () => {
|
||||
const dt = await adoptCompleted("Zzqx.Vorlanth.2019.1080p.WEB-DL", "Zzqx.Vorlanth.2019.1080p.WEB-DL.mkv");
|
||||
unknownDtId = dt.id;
|
||||
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
||||
expect(plan).toMatchObject({ status: "failed", reason: "identity_unknown" });
|
||||
const dec = (await decisionOf(`organize_identity:${dt.id}`))!;
|
||||
expect(dec).toBeTruthy();
|
||||
const keys = (dec.options as { key: string }[]).map((o) => o.key);
|
||||
expect(keys).toEqual(expect.arrayContaining(["select_entity", "select_tmdb", "provisional", "dismiss"]));
|
||||
expect(dec.evidence).toMatchObject({ downloadTaskId: dt.id, outcome: "no_match" });
|
||||
expect((dec.evidence as { parsed: { year: number } }).parsed.year).toBe(2019);
|
||||
|
||||
const hook = organizeDecisionHook(h.d);
|
||||
expect(hook.match(dec)).toBe(true);
|
||||
// 选择作品(本地)但没给作品 → 错误,事项保持打开
|
||||
await expect(hook.execute(dec, { optionKey: "select_entity" }, actor)).rejects.toThrow(/mediaEntityId/);
|
||||
const out = await hook.execute(dec, { optionKey: "provisional" }, actor);
|
||||
expect(out).toMatchObject({ kind: "organize", status: "running" });
|
||||
const ent = await h.db
|
||||
.selectFrom("media_entity")
|
||||
.selectAll()
|
||||
.where("id", "=", out.mediaEntityId as string)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(ent).toMatchObject({ status: "provisional", year: 2019 });
|
||||
expect(h.engine.enqueued.some((e) => (e.input as { recordId?: string }).recordId === out.recordId)).toBe(true);
|
||||
const done = await executeRecord(h.d, out.recordId as string);
|
||||
expect(done.status).toBe("succeeded");
|
||||
const closed = (await decisionOf(`organize_identity:${dt.id}`))!;
|
||||
expect(closed.status).toBe("resolved");
|
||||
expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: out.recordId });
|
||||
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
|
||||
expect(row.media_entity_id).toBe(ent.id);
|
||||
});
|
||||
|
||||
it("待处理中心 API:选候选 / 本地作品 → 执行并关闭;缺作品 → 400 且事项保持打开", async () => {
|
||||
const target = await seedEntity(h.db, { kind: "movie", title: "Amber Coast", year: 2017 });
|
||||
const dt = await adoptCompleted("Qwzz.Nomatch.2017.1080p.WEB", "Qwzz.Nomatch.2017.1080p.WEB.mkv");
|
||||
await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
||||
const dec = (await decisionOf(`organize_identity:${dt.id}`))!;
|
||||
const bad = await h.api("POST", `/decisions/${dec.id}/resolve`, { optionKey: "select_entity" });
|
||||
expect(bad.status).toBe(400);
|
||||
expect((await decisionOf(`organize_identity:${dt.id}`))?.status).toBe("open");
|
||||
const ok = await h.api("POST", `/decisions/${dec.id}/resolve`, {
|
||||
optionKey: "select_entity",
|
||||
identity: { mediaEntityId: target },
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.data.status).toBe("resolved");
|
||||
expect(ok.body.data.resolution.execution).toMatchObject({ kind: "organize", mediaEntityId: target });
|
||||
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
|
||||
expect(row.media_entity_id).toBe(target);
|
||||
const recId = ok.body.data.resolution.execution.recordId as string;
|
||||
expect((await executeRecord(h.d, recId)).status).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("识别失败后在文件管理按路径手动整理:同一下载任务的整理事项关闭,回填作品", async () => {
|
||||
const other = await seedEntity(h.db, { kind: "movie", title: "Glass Orchard", year: 2018 });
|
||||
const dt = await adoptCompleted("Xqqz.Unmatched.2018.720p.HDTV", "Xqqz.Unmatched.2018.720p.HDTV.mkv");
|
||||
await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
||||
expect((await decisionOf(`organize_identity:${dt.id}`))?.status).toBe("open");
|
||||
const src = join(cache.root, "Xqqz.Unmatched.2018.720p.HDTV");
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src],
|
||||
mediaEntityId: other,
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
actorId: "tester",
|
||||
});
|
||||
expect(plan.status).toBe("running");
|
||||
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
|
||||
const closed = (await decisionOf(`organize_identity:${dt.id}`))!;
|
||||
expect(closed).toMatchObject({ status: "resolved" });
|
||||
expect(closed.resolution).toMatchObject({ key: "manual_organize" });
|
||||
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
|
||||
expect(row.media_entity_id).toBe(other);
|
||||
});
|
||||
|
||||
it("目标已存在:事项给 替换 / 保留两者 / 跳过;保留两者 → 新文件加序号入库,旧文件不动,事项闭环", async () => {
|
||||
// 同一作品的另一份(不同内容、同名)→ 目标文件已存在
|
||||
const dt = await adoptCompleted(
|
||||
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
|
||||
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
|
||||
SIZE + 1000,
|
||||
);
|
||||
// 同名种子在 qB 里是新 hash;接管识别仍命中同一作品
|
||||
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
||||
expect(plan.status).toBe("failed");
|
||||
expect(plan.reason).toContain("target_exists");
|
||||
const dec = (await decisionOf(`organize_blocked:${dt.id}`))!;
|
||||
expect((dec.options as { key: string }[]).map((o) => o.key)).toEqual(["replace", "keep_both", "skip"]);
|
||||
const before = walkFiles(lib.root).filter((p) => p.endsWith(".mkv"));
|
||||
expect(before.filter((p) => p.includes("Quiet"))).toHaveLength(1);
|
||||
const hook = organizeDecisionHook(h.d);
|
||||
const out = await hook.execute(dec, { optionKey: "keep_both" }, actor);
|
||||
expect(out).toMatchObject({ kind: "organize", choice: "keep_both", status: "running" });
|
||||
expect((await executeRecord(h.d, out.recordId as string)).status).toBe("succeeded");
|
||||
const after = walkFiles(lib.root).filter((p) => p.endsWith(".mkv"));
|
||||
expect(after).toHaveLength(before.length + 1);
|
||||
const renamed = after.find((p) => p.includes(" (2)."));
|
||||
expect(renamed && statSync(renamed).size).toBe(SIZE + 1000);
|
||||
for (const p of before) expect(existsSync(p)).toBe(true);
|
||||
expect((await decisionOf(`organize_blocked:${dt.id}`))?.status).toBe("resolved");
|
||||
});
|
||||
|
||||
it("目标已存在 → 跳过:其余照常,已存在的不整理;替换:核验后替换", async () => {
|
||||
const dt = await adoptCompleted(
|
||||
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
|
||||
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
|
||||
SIZE + 2000,
|
||||
);
|
||||
await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
||||
const dec = (await decisionOf(`organize_blocked:${dt.id}`))!;
|
||||
const hook = organizeDecisionHook(h.d);
|
||||
const skip = await hook.execute(dec, { optionKey: "skip" }, actor);
|
||||
const rec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.selectAll()
|
||||
.where("id", "=", skip.recordId as string)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rec.file_skipped).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const dt2 = await adoptCompleted(
|
||||
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
|
||||
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
|
||||
SIZE + 3000,
|
||||
);
|
||||
await planImport(h.d, { downloadTaskId: dt2.id, trigger: "auto", actorType: "system" });
|
||||
const dec2 = (await decisionOf(`organize_blocked:${dt2.id}`))!;
|
||||
const rep = await hook.execute(dec2, { optionKey: "replace" }, actor);
|
||||
expect(rep.status).toBe("running");
|
||||
expect((await executeRecord(h.d, rep.recordId as string)).status).toBe("succeeded");
|
||||
// 被替换的旧文件进库根回收站(.umt-trash),不在库内
|
||||
const inLib = walkFiles(lib.root).filter(
|
||||
(p) => p.endsWith(".mkv") && p.includes("Quiet") && !p.includes("/.umt-trash/"),
|
||||
);
|
||||
const sizes = inLib.map((p) => statSync(p).size);
|
||||
expect(sizes).toContain(SIZE + 3000);
|
||||
expect(sizes).not.toContain(SIZE);
|
||||
expect(walkFiles(lib.root).some((p) => p.includes("/.umt-trash/") && statSync(p).size === SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it("作品详情下载历史:列出接管的下载任务与整理记录", async () => {
|
||||
const r = await h.api("GET", `/library/${movieId}`);
|
||||
expect(r.status).toBe(200);
|
||||
const ids = r.body.data.downloads.map((d: { downloadTask: { id: string } }) => d.downloadTask.id);
|
||||
expect(ids).toContain(firstDtId);
|
||||
const first = r.body.data.downloads.find((d: { downloadTask: { id: string } }) => d.downloadTask.id === firstDtId);
|
||||
expect(first.downloadTask).toMatchObject({ origin: "adopted", mediaEntityId: movieId });
|
||||
expect(first.lastOrganizationStatus).toBe("succeeded");
|
||||
expect(r.body.data.organizationRecords.length).toBeGreaterThanOrEqual(3);
|
||||
const prov = await h.db
|
||||
.selectFrom("download_task")
|
||||
.select("media_entity_id")
|
||||
.where("id", "=", unknownDtId)
|
||||
.executeTakeFirstOrThrow();
|
||||
const r2 = await h.api("GET", `/library/${prov.media_entity_id}`);
|
||||
expect(r2.body.data.downloads.map((d: { downloadTask: { id: string } }) => d.downloadTask.id)).toContain(
|
||||
unknownDtId,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,523 +0,0 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { sql } from "@umt/db";
|
||||
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../services/acquisition.ts";
|
||||
import { cleanupDownloadCache, submitDownload, syncDownloads } from "../services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../services/organize.ts";
|
||||
import { reconcileLibrary } from "../services/reconcile.ts";
|
||||
import {
|
||||
createHarness,
|
||||
fakeDisk,
|
||||
type Harness,
|
||||
pgAvailable,
|
||||
seedBase,
|
||||
seedEntity,
|
||||
seedRelease,
|
||||
seedSlot,
|
||||
} from "./harness.ts";
|
||||
|
||||
// 纵切:release.resolved → 计划 → 提交 mock qB → 完成(落盘)→ Copy 入库 → 整理记录 API(含 changes 游标);
|
||||
// 以及硬阈值暂停/恢复、already_exists 接管、缓存清理安全边界、资产对账。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 300_000;
|
||||
|
||||
describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临时目录)", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
const disk = fakeDisk(10_000_000);
|
||||
const torrents = new Map<string, Uint8Array>();
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
|
||||
cache = await createTempTree({}, { prefix: "umt-cache-" });
|
||||
disk.state.watch = [cache.root];
|
||||
disk.setPct(40);
|
||||
h = await createHarness("vertical", {
|
||||
diskUsage: disk.fn,
|
||||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||||
});
|
||||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
await h.setSettings("retention", { seedRetentionDays: 0 });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
await lib?.cleanup();
|
||||
await cache?.cleanup();
|
||||
});
|
||||
|
||||
async function release(entityId: string, title: string, opts: { hr?: boolean } = {}) {
|
||||
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
|
||||
const id = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId,
|
||||
title,
|
||||
size: SIZE,
|
||||
hash: t.hash,
|
||||
...(opts.hr !== undefined ? { hr: opts.hr } : {}),
|
||||
});
|
||||
torrents.set(id, t.bytes);
|
||||
return { id, hash: t.hash, name: t.name };
|
||||
}
|
||||
|
||||
async function runToCompleted(entityId: string, title: string, opts: { hr?: boolean } = {}) {
|
||||
const rel = await release(entityId, title, opts);
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
|
||||
expect(ev.plans).toHaveLength(1);
|
||||
const sub = await submitDownload(h.d, ev.plans[0]!);
|
||||
expect(sub.status).toBe("submitted");
|
||||
await qb.complete(rel.hash, { writeFiles: true });
|
||||
const sync = await syncDownloads(h.d);
|
||||
expect(sync.completed).toContain(sub.downloadTaskId);
|
||||
return { rel, planId: ev.plans[0]!, dtId: sub.downloadTaskId! };
|
||||
}
|
||||
|
||||
let movieId: string;
|
||||
let slotId: string;
|
||||
let firstRecord: string;
|
||||
|
||||
it("release → 计划(预留 Slot)→ 提交 qB(管理标签)→ 完成 → Copy 入库 → 登记资产与绑定", async () => {
|
||||
movieId = await seedEntity(h.db, { kind: "movie", title: "沙丘2", year: 2024 });
|
||||
slotId = await seedSlot(h.db, { entityId: movieId, hard: { resolutions: ["1080p"] } });
|
||||
// 不满足硬条件的候选:写拒绝理由,不建计划
|
||||
const bad = await release(movieId, "Dune.Part.Two.2024.720p.WEB-DL.x264-AAA");
|
||||
const r0 = await evaluateAcquisition(h.d, { sourceReleaseId: bad.id });
|
||||
expect(r0.plans).toHaveLength(0);
|
||||
|
||||
const rel = await release(movieId, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
|
||||
expect(ev.plans).toHaveLength(1);
|
||||
const slot1 = await h.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("id", "=", slotId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(slot1.status).toBe("reserved");
|
||||
expect(h.engine.enqueued.map((e) => e.task)).toContain("submitDownload");
|
||||
|
||||
const sub = await submitDownload(h.d, ev.plans[0]!);
|
||||
expect(sub).toMatchObject({ status: "submitted", reason: "added" });
|
||||
const t = qb.get(rel.hash)!;
|
||||
expect(t.tags).toContain("umt");
|
||||
expect(t.save_path).toBe(cache.root);
|
||||
// 重放:账本已有结果,不重复提交
|
||||
expect((await submitDownload(h.d, ev.plans[0]!)).status).toBe("skipped");
|
||||
expect(qb.callsTo("torrents/add")).toHaveLength(1);
|
||||
expect((await h.outbox("download.submitted")).length).toBe(1);
|
||||
expect(
|
||||
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow())
|
||||
.status,
|
||||
).toBe("downloading");
|
||||
|
||||
// 未完成:同步只更新进度
|
||||
qb.setProgress(rel.hash, 0.5);
|
||||
const s1 = await syncDownloads(h.d);
|
||||
expect(s1.completed).toHaveLength(0);
|
||||
await qb.complete(rel.hash, { writeFiles: true });
|
||||
const s2 = await syncDownloads(h.d);
|
||||
expect(s2.completed).toEqual([sub.downloadTaskId]);
|
||||
const done = await h.outbox("download.status_changed");
|
||||
expect(done.filter((e) => e.payload.to === "completed")).toHaveLength(1);
|
||||
expect(
|
||||
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow())
|
||||
.status,
|
||||
).toBe("verifying");
|
||||
|
||||
const plan = await planImport(h.d, { downloadTaskId: sub.downloadTaskId, trigger: "auto", actorType: "system" });
|
||||
expect(plan.status).toBe("running");
|
||||
firstRecord = plan.recordId!;
|
||||
const ex = await executeRecord(h.d, firstRecord);
|
||||
expect(ex).toMatchObject({ status: "succeeded", succeeded: 1, failed: 0 });
|
||||
|
||||
const target = join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv");
|
||||
expect(existsSync(target)).toBe(true);
|
||||
// Copy:源继续做种
|
||||
expect(existsSync(join(cache.root, `${rel.name}`))).toBe(true);
|
||||
const slot = await h.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("id", "=", slotId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(slot.status).toBe("satisfied");
|
||||
expect(slot.current_asset_id).not.toBeNull();
|
||||
const binding = await h.db.selectFrom("path_binding").selectAll().where("media_entity_id", "=", movieId).execute();
|
||||
expect(binding.map((b) => b.relative_path)).toEqual(["沙丘2 (2024)"]);
|
||||
const loc = await h.db
|
||||
.selectFrom("asset_location")
|
||||
.selectAll()
|
||||
.where("media_asset_id", "=", slot.current_asset_id!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(loc.relative_path).toBe("沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv");
|
||||
const dt = await h.db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("id", "=", sub.downloadTaskId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(dt.status).toBe("imported");
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.select("status")
|
||||
.where("id", "=", ev.plans[0]!)
|
||||
.executeTakeFirstOrThrow()
|
||||
).status,
|
||||
).toBe("completed");
|
||||
|
||||
// 重跑执行:幂等,不重复登记
|
||||
await executeRecord(h.d, firstRecord);
|
||||
expect(
|
||||
await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", movieId).execute(),
|
||||
).toHaveLength(1);
|
||||
}, 60_000);
|
||||
|
||||
it("整理记录 API:详情含文件明细;changes 游标增量(含既有记录状态更新);changes 先于 {id}", async () => {
|
||||
const det = await h.api("GET", `/organization-records/${firstRecord}`);
|
||||
expect(det.status).toBe(200);
|
||||
expect(det.body.data).toMatchObject({
|
||||
id: firstRecord,
|
||||
status: "succeeded",
|
||||
action: "copy",
|
||||
origin: "auto",
|
||||
counts: { total: 1, succeeded: 1 },
|
||||
});
|
||||
expect(det.body.data.files[0]).toMatchObject({
|
||||
role: "main",
|
||||
status: "succeeded",
|
||||
target: { path: "沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv", view: "library" },
|
||||
});
|
||||
expect(det.body.data.media).toMatchObject({ entityId: movieId, title: "沙丘2" });
|
||||
|
||||
const poll = async (after: string) => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const r = await h.api("GET", `/organization-records/changes?after=${after}&limit=100`);
|
||||
if (r.body.data.items.length > 0) return r;
|
||||
await new Promise((res) => setTimeout(res, 100));
|
||||
}
|
||||
return h.api("GET", `/organization-records/changes?after=${after}&limit=100`);
|
||||
};
|
||||
const first = await poll("0");
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.data.items.map((i: { id: string }) => i.id)).toContain(firstRecord);
|
||||
const cursor = first.body.data.nextCursor as string;
|
||||
const again = await h.api("GET", `/organization-records/changes?after=${cursor}`);
|
||||
expect(again.body.data).toMatchObject({ items: [], nextCursor: cursor, hasMore: false });
|
||||
|
||||
// 既有记录状态更新 → 以更大的 changeSeq 再次出现
|
||||
await sql`update organization_record set reason = 'touched' where id = ${firstRecord}`.execute(h.db);
|
||||
const upd = await poll(cursor);
|
||||
expect(upd.body.data.items.map((i: { id: string }) => i.id)).toEqual([firstRecord]);
|
||||
expect(BigInt(upd.body.data.nextCursor)).toBeGreaterThan(BigInt(cursor));
|
||||
|
||||
const list = await h.api("GET", "/organization-records?limit=10");
|
||||
expect(list.body.data.items[0].files).toBeNull();
|
||||
expect((await h.api("GET", "/organization-records/changes?after=abc")).status).toBe(422);
|
||||
});
|
||||
|
||||
it("硬阈值:达到时暂停纳管下载中任务并阻止新计划;低于恢复水位只恢复阈值暂停项", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "阈值片", year: 2024 });
|
||||
await seedSlot(h.db, { entityId: e, hard: {} });
|
||||
// 一个下载中的纳管任务 + 一个用户手动暂停的纳管任务(贴标签接管)
|
||||
const a = makeTorrent({ name: "busy.mkv", length: SIZE, pieceSeed: 11 });
|
||||
const b = makeTorrent({ name: "user-paused.mkv", length: SIZE, pieceSeed: 12 });
|
||||
qb.seed({ hash: a.hash, name: "busy.mkv", tags: ["umt"], progress: 0.2, save_path: cache.root, size: SIZE });
|
||||
qb.seed({
|
||||
hash: b.hash,
|
||||
name: "user-paused.mkv",
|
||||
tags: ["umt"],
|
||||
progress: 0.2,
|
||||
save_path: cache.root,
|
||||
size: SIZE,
|
||||
state: "pausedDL",
|
||||
});
|
||||
const s0 = await syncDownloads(h.d);
|
||||
expect(s0.adopted).toBe(2);
|
||||
const rowB = await h.db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("torrent_hash", "=", b.hash)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rowB).toMatchObject({ status: "paused", pause_reason: "user", origin: "adopted" });
|
||||
|
||||
disk.setPct(95);
|
||||
const s1 = await syncDownloads(h.d);
|
||||
expect(s1.thresholdPaused).toBe(1);
|
||||
expect(qb.get(a.hash)!.state).toMatch(/^paused|^stopped/i);
|
||||
expect(
|
||||
(await h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", a.hash).executeTakeFirstOrThrow())
|
||||
.pause_reason,
|
||||
).toBe("threshold");
|
||||
const tripped = await h.outbox("download.threshold");
|
||||
expect(tripped.at(-1)?.payload.state).toBe("tripped");
|
||||
|
||||
const rel = await release(e, "Threshold.Movie.2024.1080p.WEB-DL-X");
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
|
||||
expect(ev.plans).toHaveLength(0);
|
||||
expect(ev.skipped[0]?.reason).toMatch(/^cache:/);
|
||||
// 手动恢复阈值暂停项被拒绝(不绕过阈值)
|
||||
const rowA = await h.db
|
||||
.selectFrom("download_task")
|
||||
.select("id")
|
||||
.where("torrent_hash", "=", a.hash)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((await h.api("POST", `/download-tasks/${rowA.id}/resume`)).status).toBe(412);
|
||||
|
||||
// 下一轮观察:暂停中不计入无进展,原因保持 threshold
|
||||
await syncDownloads(h.d);
|
||||
expect(
|
||||
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rowA.id).executeTakeFirstOrThrow())
|
||||
.pause_reason,
|
||||
).toBe("threshold");
|
||||
|
||||
disk.setPct(88); // 高于恢复水位:保持暂停
|
||||
expect((await syncDownloads(h.d)).thresholdResumed).toBe(0);
|
||||
disk.setPct(70);
|
||||
const s3 = await syncDownloads(h.d);
|
||||
expect(s3.thresholdResumed).toBe(1);
|
||||
expect(qb.get(a.hash)!.state).not.toMatch(/^paused|^stopped/i);
|
||||
expect(qb.get(b.hash)!.state).toMatch(/^paused|^stopped/i);
|
||||
expect(
|
||||
(await h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", b.hash).executeTakeFirstOrThrow())
|
||||
.pause_reason,
|
||||
).toBe("user");
|
||||
// 读不到磁盘:保守阻止
|
||||
disk.state.fail = true;
|
||||
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
|
||||
expect(ev2.plans).toHaveLength(0);
|
||||
disk.state.fail = false;
|
||||
disk.setPct(40);
|
||||
qb.setProgress(a.hash, 0);
|
||||
}, 60_000);
|
||||
|
||||
it("already_exists:种子原本在 qB 里 → 接管,只补管理标签,不改保存路径和其它标签", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "旧种", year: 2023 });
|
||||
await seedSlot(h.db, { entityId: e });
|
||||
const rel = await release(e, "Old.Seed.2023.1080p.BluRay-OLD");
|
||||
qb.seed({ hash: rel.hash, name: rel.name, tags: ["keep"], save_path: "/elsewhere", progress: 0.1, size: SIZE });
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
|
||||
const sub = await submitDownload(h.d, ev.plans[0]!);
|
||||
expect(sub).toMatchObject({ status: "submitted", reason: "already_exists" });
|
||||
const t = qb.get(rel.hash)!;
|
||||
expect(t.save_path).toBe("/elsewhere");
|
||||
expect(t.tags.sort()).toEqual(["keep", "umt"]);
|
||||
const row = await h.db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("id", "=", sub.downloadTaskId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(row).toMatchObject({
|
||||
origin: "adopted",
|
||||
managed: true,
|
||||
acquisition_plan_id: ev.plans[0],
|
||||
media_entity_id: e,
|
||||
});
|
||||
expect(qb.callsTo("torrents/add").length).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
it("H&R 未满足时 Move 变冲突:记录失败 + 待处理,不搬源、不静默改 Copy", async () => {
|
||||
await h.setSettings("organize", { defaultMode: "move" });
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "做种片", year: 2022 });
|
||||
const slot = await seedSlot(h.db, { entityId: e });
|
||||
const { dtId, rel } = await runToCompleted(e, "Seeding.Movie.2022.1080p.BluRay-HR", { hr: true });
|
||||
const plan = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
|
||||
expect(plan.status).toBe("failed");
|
||||
expect(plan.reason).toContain("hnr_unsatisfied");
|
||||
const rec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.selectAll()
|
||||
.where("id", "=", plan.recordId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rec.action).toBe("move");
|
||||
expect(existsSync(join(cache.root, rel.name))).toBe(true);
|
||||
expect(existsSync(join(lib.root, "做种片 (2022)"))).toBe(false);
|
||||
const dec = await h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("subject_id", "=", plan.recordId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(dec).toMatchObject({ kind: "approval_required", status: "open" });
|
||||
expect(
|
||||
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slot).executeTakeFirstOrThrow())
|
||||
.status,
|
||||
).toBe("attention");
|
||||
await h.setSettings("organize", { defaultMode: "copy" });
|
||||
}, 30_000);
|
||||
|
||||
it("缓存清理:低于阈值不动;超阈值只删已验证入库且满足保留/H&R 的任务,删数据必经安全检查", async () => {
|
||||
// 已有:第一部片(已入库、hr=false、保留 0 天)→ 可清理;做种片(hr 未满足,未入库)→ 锁定
|
||||
disk.setPct(50);
|
||||
const r0 = await cleanupDownloadCache(h.d);
|
||||
expect(r0.volumes[0]!.action).toBe("none");
|
||||
const firstDt = await h.db.selectFrom("download_task").selectAll().where("status", "=", "imported").execute();
|
||||
expect(firstDt.length).toBeGreaterThanOrEqual(1);
|
||||
// 另造一个"已入库"但 content_path 落在媒体库内的任务:安全检查必须拒绝删数据
|
||||
const trap = await h.db
|
||||
.insertInto("download_task")
|
||||
.values({
|
||||
instance_id: base.instanceId,
|
||||
torrent_hash: "f".repeat(40),
|
||||
name: "trap",
|
||||
source_release_id: firstDt[0]!.source_release_id,
|
||||
save_path: lib.root,
|
||||
content_path: join(lib.root, "沙丘2 (2024)"),
|
||||
status: "imported",
|
||||
managed: true,
|
||||
completed_at: new Date(0),
|
||||
size_bytes: 1,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({ trigger: "auto", actor_type: "system", action: "copy", status: "succeeded", download_task_id: trap.id })
|
||||
.execute();
|
||||
qb.seed({ hash: "f".repeat(40), name: "trap", tags: ["umt"], progress: 1, save_path: lib.root, size: 1 });
|
||||
|
||||
disk.setPct(95);
|
||||
const r = await cleanupDownloadCache(h.d);
|
||||
const v = r.volumes[0]!;
|
||||
expect(v.removed).toContain(firstDt[0]!.id);
|
||||
expect(v.removed).not.toContain(trap.id);
|
||||
expect(v.skipped.find((s) => s.downloadTaskId === trap.id)?.reason).toMatch(/拒绝删除|媒体库/);
|
||||
expect(qb.get(firstDt[0]!.torrent_hash)).toBeUndefined();
|
||||
// 库内副本不受影响
|
||||
expect(existsSync(join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"))).toBe(true);
|
||||
const locked = v.skipped.map((s) => s.reason);
|
||||
expect(locked.some((x) => x === "hnr_unsatisfied" || x === "import_not_verified")).toBe(false); // 做种片未入库,不在候选里
|
||||
const op = await h.db
|
||||
.selectFrom("asset_operation")
|
||||
.selectAll()
|
||||
.where("operation_id", "=", `cleanup:${firstDt[0]!.id}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(op).toMatchObject({ kind: "qb_remove", status: "succeeded" });
|
||||
disk.setPct(40);
|
||||
}, 30_000);
|
||||
|
||||
it("资产对账:库根离线只标 unavailable;缺失按 suspected → confirmed 推进,不删记录不补下载", async () => {
|
||||
const file = join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv");
|
||||
const content = readFileSync(file);
|
||||
const loc = await h.db
|
||||
.selectFrom("asset_location")
|
||||
.selectAll()
|
||||
.where("relative_path", "=", "沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv")
|
||||
.executeTakeFirstOrThrow();
|
||||
const { rm } = await import("node:fs/promises");
|
||||
await rm(join(lib.root, ".uhd-root"));
|
||||
const off = await reconcileLibrary(h.d);
|
||||
expect(off.stats.rootsOffline).toHaveLength(1);
|
||||
expect(
|
||||
(await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow())
|
||||
.status,
|
||||
).toBe("unavailable");
|
||||
await writeFile(join(lib.root, ".uhd-root"), "x");
|
||||
await rm(file);
|
||||
for (const want of ["suspected_missing", "suspected_missing", "confirmed_missing"]) {
|
||||
await reconcileLibrary(h.d);
|
||||
expect(
|
||||
(await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow())
|
||||
.status,
|
||||
).toBe(want);
|
||||
}
|
||||
expect(
|
||||
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow())
|
||||
.status,
|
||||
).toBe("attention");
|
||||
expect(
|
||||
await h.db.selectFrom("media_asset").select("id").where("id", "=", loc.media_asset_id).executeTakeFirst(),
|
||||
).toBeDefined();
|
||||
await writeFile(file, content);
|
||||
await reconcileLibrary(h.d);
|
||||
expect(
|
||||
(await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow())
|
||||
.status,
|
||||
).toBe("available");
|
||||
const runs = await h.db.selectFrom("reconcile_run").select(["kind", "status"]).execute();
|
||||
expect(runs.every((r) => r.kind === "asset")).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it("首页、媒体库、库存叠加 API", async () => {
|
||||
const dash = await h.api("GET", "/dashboard");
|
||||
expect(dash.status).toBe(200);
|
||||
expect(dash.body.data.recentOutput.length).toBeGreaterThan(0);
|
||||
expect(dash.body.data.cache.volumes[0]).toMatchObject({ hardThresholdPercent: 90, resumePercent: 85 });
|
||||
expect(dash.body.data.needsAttention.total).toBeGreaterThan(0);
|
||||
const auto = dash.body.data.automation;
|
||||
expect(auto.library.entities).toBeGreaterThanOrEqual(1);
|
||||
expect(auto.library.movies).toBeGreaterThanOrEqual(1);
|
||||
expect(auto.library.assets).toBeGreaterThanOrEqual(1);
|
||||
const slotTotal = Object.values(auto.slotCounts as Record<string, number>).reduce((a, b) => a + b, 0);
|
||||
expect(auto.slotSatisfiedRate).toBe(slotTotal ? auto.slotCounts.satisfied / slotTotal : null);
|
||||
const libList = await h.api("GET", "/library?q=沙丘");
|
||||
expect(libList.body.data.items[0]).toMatchObject({ entity: { id: movieId }, assetCount: 1 });
|
||||
const det = await h.api("GET", `/library/${movieId}`);
|
||||
expect(det.status).toBe(200);
|
||||
expect(det.body.data.assets[0].locations[0].absolutePath).toBe(
|
||||
join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"),
|
||||
);
|
||||
expect(det.body.data.candidates.some((c: { decision: string }) => c.decision === "reject")).toBe(true);
|
||||
expect(det.body.data.downloads[0].organizationRecordIds.length).toBeGreaterThan(0);
|
||||
// QB-1:下载任务直接记作品;整理记录按作品列出
|
||||
const dune = det.body.data.downloads.find((x: { downloadTask: { name: string } }) =>
|
||||
x.downloadTask.name.startsWith("Dune.Part.Two"),
|
||||
);
|
||||
expect(dune.downloadTask.mediaEntityId).toBe(movieId);
|
||||
expect(det.body.data.organizationRecords.map((r: { id: string }) => r.id)).toEqual(
|
||||
expect.arrayContaining(dune.organizationRecordIds),
|
||||
);
|
||||
const inv = await h.api("GET", `/inventory?entityIds=${movieId}`);
|
||||
expect(inv.body.data.items[0].versions[0]).toMatchObject({ resolution: "1080p", available: true });
|
||||
const ms = await h.api("GET", `/inventory/media-servers?mediaEntityId=${movieId}`);
|
||||
expect(ms.body.data).toEqual({ mediaEntityId: movieId, instances: [], partial: false });
|
||||
});
|
||||
|
||||
it("自动整理被拦(attention)后手动整理成功:Slot 经 verifying 落 satisfied,整理事项自动关闭(QA U1)", async () => {
|
||||
await h.setSettings("organize", { defaultMode: "move" });
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "被拦片", year: 2021 });
|
||||
const slot = await seedSlot(h.db, { entityId: e });
|
||||
const { dtId, planId } = await runToCompleted(e, "Blocked.Movie.2021.1080p.BluRay-HR", { hr: true });
|
||||
// 新作品首次下载:计划用途是补缺,不是升级(QA U7)
|
||||
expect(
|
||||
(await h.db.selectFrom("acquisition_plan").select("purpose").where("id", "=", planId).executeTakeFirstOrThrow())
|
||||
.purpose,
|
||||
).toBe("fill");
|
||||
const auto = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
|
||||
expect(auto.status).toBe("failed");
|
||||
const slotRow = () =>
|
||||
h.db.selectFrom("acquisition_slot").selectAll().where("id", "=", slot).executeTakeFirstOrThrow();
|
||||
expect((await slotRow()).status).toBe("attention");
|
||||
const dec = await h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("dedupe_key", "=", `organize_blocked:${dtId}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(dec.status).toBe("open");
|
||||
|
||||
await h.setSettings("organize", { defaultMode: "copy" });
|
||||
const manual = await planImport(h.d, { downloadTaskId: dtId, trigger: "manual", actorType: "user", actorId: null });
|
||||
expect(manual.status).toBe("running");
|
||||
const done = await executeRecord(h.d, manual.recordId!);
|
||||
expect(done.status).toBe("succeeded");
|
||||
const after = await slotRow();
|
||||
expect(after.status).toBe("satisfied");
|
||||
expect(after.current_asset_id).not.toBeNull();
|
||||
const asset = await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select("media_entity_id")
|
||||
.where("id", "=", after.current_asset_id!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(asset.media_entity_id).toBe(e);
|
||||
const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow();
|
||||
expect(closed.status).toBe("resolved");
|
||||
expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: manual.recordId });
|
||||
expect(closed.resolved_by).toBe("user");
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { MOCK_ROW_H, MOCK_ROW_Y0, MOCK_VIEWPORT } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { registerBrowserCapabilities } from "../capabilities.ts";
|
||||
import { createBrowserModule } from "../index.ts";
|
||||
import {
|
||||
ACCESS_KEY,
|
||||
type BrowserHarness,
|
||||
createBrowserHarness,
|
||||
type Loose,
|
||||
ORIGIN,
|
||||
OTHER_KEY,
|
||||
PASSKEY,
|
||||
PASSWORD,
|
||||
pgAvailable,
|
||||
READ_KEY,
|
||||
} from "./harness.ts";
|
||||
|
||||
// C1-E 浏览器 HTTP 端点(对 testkit 交互式 mock camofox 验证,未连真实实例):
|
||||
// 浏览器服务列表与探测、会话开关(数据库为准)、接管租约、坐标点击与凭证注入、截图、下载暂存核验与提交、异常路径。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const row = (i: number) => ({ x: 200, y: MOCK_ROW_Y0 + MOCK_ROW_H * i + MOCK_ROW_H / 2 });
|
||||
|
||||
describe.skipIf(!up)("浏览器 HTTP 端点", () => {
|
||||
let h: BrowserHarness;
|
||||
let sid: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("api");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("GET /browser/providers:凭证只给引用;probe 写回能力与健康,verified 只来自人工核验配置", async () => {
|
||||
const r = await h.req("/browser/providers");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.items).toHaveLength(1);
|
||||
expect(r.body.data.items[0]).toMatchObject({ kind: "camofox", enabled: true, capabilities: null, health: null });
|
||||
expect(JSON.stringify(r.body)).not.toContain(ACCESS_KEY);
|
||||
const p = await h.req("/browser/providers?probe=true");
|
||||
const item = p.body.data.items[0];
|
||||
expect(item.health.status).toBe("ok");
|
||||
expect(item.healthStatus).toBe("healthy");
|
||||
// mock 的版本号不在真机核验表里:verified 只来自配置
|
||||
expect(item.capabilities.upstreamVersion).toBe("1.17.0-mock");
|
||||
expect(item.capabilities.snapshot).toMatchObject({ supported: true, verified: true, source: "probed" });
|
||||
expect(item.capabilities.interact).toMatchObject({ supported: true, verified: false });
|
||||
expect(item.capabilities.takeover).toMatchObject({ supported: true, verified: false });
|
||||
// 探测带 Bearer 访问凭据
|
||||
expect(h.mock.callsTo("/openapi.json")[0]?.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
|
||||
expect((await h.req("/browser/providers", { key: READ_KEY })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("打开会话:profile 作 camofox userId,会话/标签页落库;同站点账号再开返回同一会话", async () => {
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/torrents.php` } });
|
||||
expect(r.status).toBe(200);
|
||||
const s = r.body.data;
|
||||
sid = s.id;
|
||||
expect(s).toMatchObject({ siteId: h.siteId, accountId: h.accountId, controller: "automation", lease: null });
|
||||
expect(s.profileKey).toBe(`umt-site-${h.siteId}-acct-${h.accountId}`);
|
||||
// 登录失效:落到登录页
|
||||
expect(s.currentUrl).toContain("/login.php");
|
||||
expect(s.tabs).toHaveLength(1);
|
||||
expect([...h.mock.tabs.values()][0]?.userId).toBe(s.profileKey);
|
||||
const dbRow = await h.db.selectFrom("browser_session").selectAll().where("id", "=", sid).executeTakeFirstOrThrow();
|
||||
expect(dbRow.controller).toBe("automation");
|
||||
const again = await h.req("/browser/sessions", { body: { siteId: h.siteId } });
|
||||
expect(again.body.data.id).toBe(sid);
|
||||
const list = await h.req(`/browser/sessions?siteId=${h.siteId}`);
|
||||
expect(list.body.data.items.map((x: Loose) => x.id)).toEqual([sid]);
|
||||
});
|
||||
|
||||
it("输入类动作要先接管;他人接管中 409;读(快照/截图)随时可用", async () => {
|
||||
const nav = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: `${ORIGIN}/` } });
|
||||
expect(nav.status).toBe(409);
|
||||
expect(nav.body.error.message).toContain("先接管");
|
||||
const shot = await h.req(`/browser/sessions/${sid}/screenshot`);
|
||||
expect(shot.status).toBe(200);
|
||||
expect(shot.body.data).toMatchObject({
|
||||
mimeType: "image/png",
|
||||
width: MOCK_VIEWPORT.width,
|
||||
height: MOCK_VIEWPORT.height,
|
||||
modelSafe: false,
|
||||
controller: "automation",
|
||||
});
|
||||
const png = Buffer.from(shot.body.data.dataBase64, "base64");
|
||||
expect(png.subarray(1, 4).toString()).toBe("PNG");
|
||||
|
||||
const a = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: { reason: "登录" } });
|
||||
expect(a.status).toBe(200);
|
||||
expect(a.body.data.lease).toMatchObject({ holderKind: "human", status: "active" });
|
||||
expect(a.body.data.session.controller).toBe("human");
|
||||
const other = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {}, key: OTHER_KEY });
|
||||
expect(other.status).toBe(409);
|
||||
const otherClick = await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(1) }, key: OTHER_KEY });
|
||||
expect(otherClick.status).toBe(409);
|
||||
});
|
||||
|
||||
it("人工:导航限站点域名;坐标点击 + 凭证注入登录;快照遮蔽密码与 passkey", async () => {
|
||||
const evil = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: "https://evil.example/" } });
|
||||
expect(evil.status).toBe(400);
|
||||
expect(evil.body.error.message).toContain("白名单");
|
||||
expect((await h.req(`/browser/sessions/${sid}/navigate`, { body: {} })).status).toBe(400);
|
||||
const nav = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: `${ORIGIN}/torrents.php` } });
|
||||
expect(nav.body.data).toMatchObject({ loginRequired: true, ok: true });
|
||||
|
||||
expect((await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(1) } })).status).toBe(200);
|
||||
await h.req(`/browser/sessions/${sid}/type`, { body: { credential: "username" } });
|
||||
await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(2) } });
|
||||
const typed = await h.req(`/browser/sessions/${sid}/type`, { body: { credential: "password" } });
|
||||
expect(typed.status).toBe(200);
|
||||
expect(JSON.stringify(typed.body)).not.toContain(PASSWORD);
|
||||
// 登录前快照:密码框有值,返回前已遮蔽
|
||||
const snap = await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
expect(snap.body.data.text).toContain("登录");
|
||||
expect(snap.body.data.text).not.toContain(PASSWORD);
|
||||
// 凭证确实下发到了浏览器(mock 记录),点击按钮完成登录
|
||||
expect(h.mock.actions.some((x) => x.action === "type" && x.body.text === PASSWORD)).toBe(true);
|
||||
await h.req(`/browser/sessions/${sid}/type`, { body: { key: "Enter" } });
|
||||
const after = await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
expect(after.body.data.text).toContain("种子列表");
|
||||
expect(after.body.data.text).not.toContain(PASSKEY);
|
||||
expect(after.body.data.url).toContain("/torrents.php");
|
||||
// 后退 / 滚动
|
||||
expect((await h.req(`/browser/sessions/${sid}/scroll`, { body: { direction: "down" } })).status).toBe(200);
|
||||
const back = await h.req(`/browser/sessions/${sid}/navigate`, { body: { action: "reload" } });
|
||||
expect(back.status).toBe(200);
|
||||
});
|
||||
|
||||
it("下载:点击捕获 → 暂存 0600 文件、sha256/infohash 核验、按 infohash 匹配资源,经 /acquisition-plans 提交;同一文件不重复提交", async () => {
|
||||
await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
const r = await h.req(`/browser/sessions/${sid}/download`, {
|
||||
body: { target: { ref: "e11" }, submit: { note: "browser" } },
|
||||
});
|
||||
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
||||
const d = r.body.data;
|
||||
expect(d.artifact.sha256).toBe(createHash("sha256").update(h.torrent.bytes).digest("hex"));
|
||||
expect(d.artifact.sourceUrl).not.toContain(PASSKEY);
|
||||
expect(d.verification).toMatchObject({ valid: true, infohashV1: h.torrent.infoHash, private: true });
|
||||
expect(d.sourceReleaseId).toBe(h.releaseId);
|
||||
// 资源未识别:与 POST /acquisition-plans 相同的确认结果,产物照常返回
|
||||
expect(d.submission).toMatchObject({ submitted: false, requiresConfirmation: true });
|
||||
const files = await readdir(h.stagingDir);
|
||||
expect(files).toHaveLength(1);
|
||||
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
|
||||
|
||||
// 识别到作品后再下载:同一文件第二次捕获标 duplicate、不提交
|
||||
const entity = await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.updateTable("source_release")
|
||||
.set({ media_entity_id: entity.id })
|
||||
.where("id", "=", h.releaseId)
|
||||
.execute();
|
||||
await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
const dup = await h.req(`/browser/sessions/${sid}/download`, { body: { target: { ref: "e11" }, submit: {} } });
|
||||
expect(dup.body.data).toMatchObject({ duplicate: true, submission: null });
|
||||
expect(dup.body.data.note).toContain("未重复提交");
|
||||
// 未接管时不能点击触发下载
|
||||
await h.req(`/browser/sessions/${sid}/control/release`, { body: {} });
|
||||
const denied = await h.req(`/browser/sessions/${sid}/download`, { body: { target: { ref: "e11" } } });
|
||||
expect(denied.status).toBe(409);
|
||||
});
|
||||
|
||||
it("交还后需重新读取页面;租约到期 → 等待处理 + 待处理事项,自动化被拒", async () => {
|
||||
const v = await h.req(`/browser/sessions/${sid}`);
|
||||
expect(v.body.data).toMatchObject({ controller: "automation", needsSnapshot: true, lease: null });
|
||||
await h.grant("browser.interact");
|
||||
await expect(
|
||||
h.registry.invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e11" } }, h.agentCtx()),
|
||||
).rejects.toMatchObject({ code: "conflict" });
|
||||
|
||||
await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {} });
|
||||
await h.db
|
||||
.updateTable("browser_control_lease")
|
||||
.set({ acquired_at: new Date(Date.now() - 120_000), expires_at: new Date(Date.now() - 1_000) })
|
||||
.where("session_id", "=", sid)
|
||||
.where("status", "=", "active")
|
||||
.execute();
|
||||
const w = await h.req(`/browser/sessions/${sid}`);
|
||||
expect(w.body.data).toMatchObject({ controller: "waiting", waitingReason: "lease_expired", lease: null });
|
||||
const dec = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", sid).execute();
|
||||
expect(dec).toHaveLength(1);
|
||||
expect(dec[0]).toMatchObject({ status: "awaiting_user", subject_type: "browser_session" });
|
||||
await expect(
|
||||
h.registry.invoke("browser.interact", { sessionId: sid, action: "scroll" }, h.agentCtx()),
|
||||
).rejects.toMatchObject({ code: "conflict" });
|
||||
// 人工重新接管并交还 → 自动化恢复,待处理事项关闭
|
||||
await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {} });
|
||||
await h.req(`/browser/sessions/${sid}/control/release`, { body: { reason: "处理完" } });
|
||||
const d2 = await h.db
|
||||
.selectFrom("decision")
|
||||
.select("status")
|
||||
.where("subject_id", "=", sid)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d2.status).toBe("resolved");
|
||||
});
|
||||
|
||||
it("服务重启:按数据库恢复会话,旧引用失效;上游回收的标签页标 lost 并自动重开", async () => {
|
||||
// 新模块实例 = 进程重启(适配器内存为空)
|
||||
const fresh = createBrowserModule({ stagingDir: h.stagingDir });
|
||||
const reg = (await import("../../agent/capability-registry.ts")).createCapabilityRegistry({
|
||||
logger: h.ctx.logger,
|
||||
db: h.db,
|
||||
settings: h.ctx.settings,
|
||||
});
|
||||
registerBrowserCapabilities(reg, () => fresh.service(h.ctx));
|
||||
await expect(
|
||||
reg.invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e11" } }, h.agentCtx()),
|
||||
).rejects.toMatchObject({ code: "conflict" });
|
||||
const snap = (await reg.invoke("browser.snapshot", { siteId: h.siteId }, h.agentCtx())) as Loose;
|
||||
expect(snap.snapshot.text).toContain("种子列表");
|
||||
expect(snap).toMatchObject({ controller: "automation", loginRequired: false });
|
||||
expect((await fresh.service(h.ctx).view(sid)).needsSnapshot).toBe(false);
|
||||
|
||||
const tab = (
|
||||
await h.db.selectFrom("browser_tab").selectAll().where("session_id", "=", sid).executeTakeFirstOrThrow()
|
||||
).upstream_tab_id;
|
||||
h.mock.dropTab(tab);
|
||||
// BR-3:上游回收后自愈 —— 同一会话新开标签页回到原地址,重试一次
|
||||
const healed = (await reg.invoke("browser.snapshot", { sessionId: sid }, h.agentCtx())) as Loose;
|
||||
expect(healed.snapshot.text).toContain("种子列表");
|
||||
expect(healed.snapshot.tabId).not.toBe(tab);
|
||||
const t = await h.db
|
||||
.selectFrom("browser_tab")
|
||||
.select("status")
|
||||
.where("upstream_tab_id", "=", tab)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(t.status).toBe("lost");
|
||||
});
|
||||
|
||||
it("站点停用 → 会话关闭、自动访问停止;关闭会话撤销租约并清上游标签页", async () => {
|
||||
const s2 = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null } });
|
||||
expect(s2.status).toBe(200);
|
||||
const id2 = s2.body.data.id;
|
||||
expect(id2).not.toBe(sid);
|
||||
await h.req(`/browser/sessions/${id2}/control/acquire`, { body: {} });
|
||||
const closed = await h.req(`/browser/sessions/${id2}`, { method: "DELETE" });
|
||||
expect(closed.body.data).toMatchObject({ controller: "closed", closeReason: "closed_by_user", lease: null });
|
||||
const lease = await h.db.selectFrom("browser_control_lease").selectAll().where("session_id", "=", id2).execute();
|
||||
expect(lease.every((l) => l.status === "revoked")).toBe(true);
|
||||
|
||||
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", h.siteId).execute();
|
||||
const r = await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
expect(r.status).toBe(412);
|
||||
const s = await h.db.selectFrom("browser_session").selectAll().where("id", "=", sid).executeTakeFirstOrThrow();
|
||||
expect(s).toMatchObject({ controller: "closed", close_reason: "site_disabled" });
|
||||
expect((await h.req("/browser/sessions", { body: { siteId: h.siteId } })).status).toBe(412);
|
||||
await h.db.updateTable("site").set({ enabled: true }).where("id", "=", h.siteId).execute();
|
||||
});
|
||||
|
||||
it("浏览器服务认证失败(401)与超时:报上游不可用,不无限重试", async () => {
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId } });
|
||||
const id = r.body.data.id;
|
||||
await h.req(`/browser/sessions/${id}/control/acquire`, { body: {} });
|
||||
h.mock.fault(/\/navigate$/, { status: 401, times: 5 });
|
||||
const a = await h.req(`/browser/sessions/${id}/navigate`, { body: { url: `${ORIGIN}/` } });
|
||||
expect(a.status).toBe(412);
|
||||
expect(JSON.stringify(a.body)).not.toContain(ACCESS_KEY);
|
||||
h.mock.clearFaults();
|
||||
h.mock.fault(/\/navigate$/, { delayMs: 2_500, times: 5 });
|
||||
const before = h.mock.callsTo(/\/navigate$/).length;
|
||||
const t = await h.req(`/browser/sessions/${id}/navigate`, { body: { url: `${ORIGIN}/` } });
|
||||
expect(t.status).toBe(502);
|
||||
// maxRetries = 1:共 2 次
|
||||
expect(h.mock.callsTo(/\/navigate$/).length - before).toBe(2);
|
||||
h.mock.clearFaults();
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import { siteCloudflareState } from "@umt/contracts";
|
||||
import { MOCK_CAMOFOX_IDENTITY, type MockCamofoxSite } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { browserServiceFor } from "../index.ts";
|
||||
import { type BrowserService, registerChallengeVision } from "../service.ts";
|
||||
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
|
||||
|
||||
// CF-2 浏览器一侧(对 testkit mock camofox 的 Cloudflare 模式):
|
||||
// ① 自动放行(轮询页面)② 复选框 / Turnstile 派视觉子流程,用插件真实鼠标点击 ③ 选图验证码 / 点不过 → 会话转等待 + 待处理事项;
|
||||
// 人在原站视图里过完交还 → 读回 cf_clearance / UA 通知站点请求继续;浏览器代抓页面受 profile 锁与站点限流约束。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const GOOD = "cf2-session-value";
|
||||
const CLEARANCE = "clr-from-browser";
|
||||
const LIST_HTML =
|
||||
'<html><head><title>种子</title></head><body><table class="torrents"><tr><td>Oppenheimer</td></tr></table></body></html>';
|
||||
|
||||
describe.skipIf(!up)("CF-2 浏览器过挑战 / 代抓", () => {
|
||||
let h: BrowserHarness;
|
||||
let svc: BrowserService;
|
||||
const cfCfg: NonNullable<MockCamofoxSite["cloudflare"]> = { mode: "auto", autoPolls: 2, clearance: CLEARANCE };
|
||||
const visionCalls: string[] = [];
|
||||
let visionClick = true;
|
||||
const url = `${ORIGIN}/torrents.php`;
|
||||
|
||||
const sessions = async () => (await h.req("/browser/sessions")).body.data.items as Loose[];
|
||||
const closeAll = async () => {
|
||||
for (const s of await sessions()) await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
};
|
||||
const profileKey = () => `umt-site-${h.siteId}-acct-${h.accountId}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("cf2", {
|
||||
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: GOOD },
|
||||
cloudflare: cfCfg,
|
||||
html: (u) =>
|
||||
u.includes("torrents.php") ? LIST_HTML : "<html><head><title>首页</title></head><body></body></html>",
|
||||
browserOptions: { challengePollMs: 5, challengeAutoWaitMs: 200, settleMs: 0 },
|
||||
});
|
||||
await sessions(); // 第一次请求时浏览器模块才为这个上下文建服务
|
||||
svc = browserServiceFor(h.ctx)!;
|
||||
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_uid=1; c_secure_pass=${GOOD}` });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active" })
|
||||
.where("id", "=", h.accountId)
|
||||
.execute();
|
||||
// 视觉子流程(假):点一下复选框所在的位置(第 2 行)
|
||||
registerChallengeVision(h.ctx, async (req) => {
|
||||
visionCalls.push(req.sessionId);
|
||||
if (visionClick)
|
||||
await svc.visionAct(
|
||||
req.sessionId,
|
||||
{ kind: "automation", holder: "system:cloudflare" },
|
||||
(p, ref) => p.click(ref, { x: 100, y: 70 }),
|
||||
{ wantImage: false, sensitiveAllowed: false },
|
||||
);
|
||||
return { status: visionClick ? "solved" : "needs_human", reason: "", childRunId: null };
|
||||
});
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await closeAll();
|
||||
h.mock.cfPassed.clear();
|
||||
h.mock.contentReads.clear();
|
||||
h.mock.actions.length = 0;
|
||||
for (const jar of h.mock.cookieJars.values())
|
||||
jar.splice(0, jar.length, ...jar.filter((c) => c.name !== "cf_clearance"));
|
||||
cfCfg.mode = "auto";
|
||||
visionCalls.length = 0;
|
||||
visionClick = true;
|
||||
await h.db
|
||||
.updateTable("site")
|
||||
.set({ config: JSON.stringify({}), enabled: true })
|
||||
.where("id", "=", h.siteId)
|
||||
.execute();
|
||||
await h.db
|
||||
.updateTable("settings")
|
||||
.set({ value: JSON.stringify({ enabled: true }) })
|
||||
.where("key", "=", "automation")
|
||||
.execute();
|
||||
});
|
||||
|
||||
it("① 自动放行:轮询到挑战页消失,读回 cf_clearance + navigator.userAgent + 出口", async () => {
|
||||
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
|
||||
expect(r).toEqual({
|
||||
status: "passed",
|
||||
by: "auto",
|
||||
clearance: CLEARANCE,
|
||||
userAgent: MOCK_CAMOFOX_IDENTITY.userAgent,
|
||||
browserEgress: "direct",
|
||||
});
|
||||
expect(visionCalls).toEqual([]);
|
||||
// 会话仍归自动化,没有待处理事项
|
||||
const [s] = await sessions();
|
||||
expect(s.controller).toBe("automation");
|
||||
});
|
||||
|
||||
it("② 复选框 / Turnstile:等不过 → 视觉子流程用插件真实鼠标点击 → 通过(by vision)", async () => {
|
||||
cfCfg.mode = "interactive";
|
||||
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
|
||||
expect(r).toMatchObject({ status: "passed", by: "vision", clearance: CLEARANCE });
|
||||
expect(visionCalls).toHaveLength(1);
|
||||
expect(h.mock.actions.some((a) => a.action === "mouse-click")).toBe(true);
|
||||
});
|
||||
|
||||
it("③ 视觉子流程点不过(最多 2 次)→ 会话转等待,待处理事项「观众需要你在原站视图里过一次验证」", async () => {
|
||||
cfCfg.mode = "interactive";
|
||||
visionClick = false;
|
||||
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
|
||||
expect(r).toMatchObject({ status: "needs_human" });
|
||||
expect(visionCalls).toHaveLength(2);
|
||||
const s = (await h.req(`/browser/sessions/${(r as Loose).sessionId}`)).body.data as Loose;
|
||||
expect(s.controller).toBe("waiting");
|
||||
expect(s.waitingReason).toBe("cf_challenge");
|
||||
const d = await h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("id", "=", (r as Loose).decisionId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d.title).toBe("观众需要你在原站视图里过一次验证");
|
||||
expect(d.status).toBe("awaiting_user");
|
||||
expect((d.evidence as Loose).cloudflare).toMatchObject({ siteId: h.siteId });
|
||||
// 已在等人:再来一次不打扰,直接返回同一个等待
|
||||
const again = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
|
||||
expect(again).toMatchObject({ status: "needs_human", sessionId: (r as Loose).sessionId });
|
||||
expect(visionCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("自动化总开关关闭:不派视觉子流程,直接转人工", async () => {
|
||||
cfCfg.mode = "interactive";
|
||||
await h.db
|
||||
.insertInto("settings")
|
||||
.values({ key: "automation", value: JSON.stringify({ enabled: false }) })
|
||||
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ enabled: false }) }))
|
||||
.execute();
|
||||
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
|
||||
expect(r).toMatchObject({ status: "needs_human" });
|
||||
expect(visionCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("③ 选图 / 输码类:不点、不等,直接转人工;人过完交还 → 读回 cf_clearance,站点请求继续", async () => {
|
||||
cfCfg.mode = "captcha";
|
||||
const r = (await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url })) as Loose;
|
||||
expect(r.status).toBe("needs_human");
|
||||
expect(r.reason).toContain("选图");
|
||||
expect(visionCalls).toEqual([]);
|
||||
// 站点请求一侧记下"等人处理"(级联在 sources-identity 里做,这里直接写状态)
|
||||
await h.db
|
||||
.updateTable("site")
|
||||
.set({ config: JSON.stringify({ cloudflare: { pending: "human", sessionId: r.sessionId } }) })
|
||||
.where("id", "=", h.siteId)
|
||||
.execute();
|
||||
// 人接管,在桌面里过了验证(桌面操作不经 API:直接改 mock 状态),交还
|
||||
expect(
|
||||
(await h.req(`/browser/sessions/${r.sessionId}/control/acquire`, { body: { reason: "过验证" } })).status,
|
||||
).toBe(200);
|
||||
h.mock.cfPassed.add(profileKey());
|
||||
h.mock.cookieJars.get(profileKey())!.push({
|
||||
name: "cf_clearance",
|
||||
value: "clr-human",
|
||||
domain: ".audiences.example",
|
||||
path: "/",
|
||||
expires: Math.floor(Date.now() / 1000) + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
});
|
||||
expect((await h.req(`/browser/sessions/${r.sessionId}/control/release`, { body: {} })).status).toBe(200);
|
||||
const site = await h.db.selectFrom("site").select("config").where("id", "=", h.siteId).executeTakeFirstOrThrow();
|
||||
expect(siteCloudflareState(site.config)).toMatchObject({ pending: null, lastSolvedBy: "human" });
|
||||
const acc = await h.db
|
||||
.selectFrom("site_account")
|
||||
.select("user_info")
|
||||
.where("id", "=", h.accountId)
|
||||
.executeTakeFirstOrThrow();
|
||||
const info = (acc.user_info as Loose).cloudflare;
|
||||
expect(info).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, egress: "direct" });
|
||||
expect(h.secrets.plain.get(info.secretRef)).toBe("clr-human");
|
||||
// 待处理事项已关闭
|
||||
const d = await h.db
|
||||
.selectFrom("decision")
|
||||
.select("status")
|
||||
.where("id", "=", r.decisionId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d.status).toBe("resolved");
|
||||
});
|
||||
|
||||
it("浏览器代抓:过了挑战的 profile 导航取 outerHTML;同一 profile 并发串行(只开一个会话);每次导航取站点令牌", async () => {
|
||||
h.mock.cfPassed.add(profileKey());
|
||||
const [a, b] = await Promise.all([
|
||||
svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url }),
|
||||
svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url }),
|
||||
]);
|
||||
expect(a).toMatchObject({ status: "ok", html: LIST_HTML });
|
||||
expect(b).toMatchObject({ status: "ok", html: LIST_HTML });
|
||||
expect(await sessions()).toHaveLength(1);
|
||||
const navs = h.mock.calls.filter(
|
||||
(c) => c.path.endsWith("/navigate") || (c.path === "/tabs" && c.method === "POST"),
|
||||
);
|
||||
expect(navs.length).toBeGreaterThanOrEqual(2);
|
||||
const count = await h.db
|
||||
.selectFrom("site_request_count")
|
||||
.select((eb) => eb.fn.sum<number>("count").as("n"))
|
||||
.where("site_id", "=", h.siteId)
|
||||
.executeTakeFirst();
|
||||
expect(Number(count?.n ?? 0)).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("浏览器代抓:仍是挑战页 → challenge;会话在人工接管中 → 不打扰;站点停用 → 不可用", async () => {
|
||||
cfCfg.mode = "captcha";
|
||||
expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({
|
||||
status: "challenge",
|
||||
});
|
||||
const [s] = await sessions();
|
||||
await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "看看" } });
|
||||
expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({
|
||||
status: "unavailable",
|
||||
});
|
||||
await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} });
|
||||
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", h.siteId).execute();
|
||||
expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({
|
||||
status: "unavailable",
|
||||
reason: "站点已停用",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readAccountCookie } from "../../sources-identity/service/credentials.ts";
|
||||
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
|
||||
|
||||
// BR-2 凭据统一(对 testkit mock camofox + mock NexusPHP 登录态判定):
|
||||
// 打开会话注入站点账号 Cookie → 首页 / 受保护页是登录态;Cookie 过期 → 需要人工登录;
|
||||
// 人工在画面里登录后交还 → 读回站点域名 Cookie 写进账号(来源 browser);关闭时也回写;没有插件时降级说明。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const GOOD = "good-session-value-7f3a";
|
||||
const STALE = "stale-session-value-11";
|
||||
const FRESH = "fresh-browser-login-9c2";
|
||||
|
||||
describe.skipIf(!up)("BR-2 站点账号 Cookie ⇄ 浏览器 profile", () => {
|
||||
let h: BrowserHarness;
|
||||
let cookieAcc: string;
|
||||
|
||||
const setCookieAccount = async (cookie: string) => {
|
||||
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: cookie });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active", user_info: "{}" })
|
||||
.where("id", "=", cookieAcc)
|
||||
.execute();
|
||||
};
|
||||
const open = async (path = "/torrents.php") =>
|
||||
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: cookieAcc, url: `${ORIGIN}${path}` } }))
|
||||
.body.data as Loose;
|
||||
const closeAll = async () => {
|
||||
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("cred", {
|
||||
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
|
||||
});
|
||||
cookieAcc = h.accountId;
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("注入:Cookie 账号打开会话前写进该 profile,受保护页直接是登录态;响应里没有 Cookie 值", async () => {
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
const s = await open();
|
||||
expect(s.controller).toBe("automation");
|
||||
expect(s.currentUrl).toBe(`${ORIGIN}/torrents.php`);
|
||||
expect(s.credential).toMatchObject({ status: "injected", source: "manual", count: 2 });
|
||||
expect(JSON.stringify(s)).not.toContain(GOOD);
|
||||
// 注入走 umt-cookies 插件,早于打开标签页;Cookie 按站点主机 host-only、https → secure
|
||||
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
|
||||
const inj = calls.indexOf(`POST /umt/sessions/${encodeURIComponent(s.profileKey)}/cookies`);
|
||||
expect(inj).toBeGreaterThanOrEqual(0);
|
||||
expect(inj).toBeLessThan(calls.lastIndexOf("POST /tabs"));
|
||||
const jar = h.mock.cookieJars.get(s.profileKey)!;
|
||||
expect(jar.find((c) => c.name === "c_secure_pass")).toMatchObject({
|
||||
value: GOOD,
|
||||
domain: "audiences.example",
|
||||
secure: true,
|
||||
});
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("过期:注入后仍落在登录页 → 需要人工登录;人工登录后交还 → 回写账号 Cookie(来源浏览器),下次抓取用新值", async () => {
|
||||
h.mock.loggedIn.clear();
|
||||
h.mock.cookieJars.clear();
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${STALE}`);
|
||||
const s = await open();
|
||||
expect(s.controller).toBe("waiting");
|
||||
expect(s.waitingReason).toBe("login_required");
|
||||
expect(s.credential).toMatchObject({ status: "expired" });
|
||||
expect(s.credential.message).toContain("登录一次");
|
||||
const d = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", s.id).executeTakeFirstOrThrow();
|
||||
expect(d.summary).toContain("交还后全站生效");
|
||||
|
||||
// 人工接管,在画面里登录(桌面模式里的导航不经 API:直接改 mock 的登录态与当前地址)
|
||||
expect((await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "登录" } })).status).toBe(200);
|
||||
const tab = [...h.mock.tabs.values()].find((t) => t.userId === s.profileKey)!;
|
||||
h.mock.loggedIn.add(s.profileKey);
|
||||
h.mock.cookieJars.get(s.profileKey)!.push(
|
||||
{
|
||||
name: "c_secure_pass",
|
||||
value: FRESH,
|
||||
domain: "audiences.example",
|
||||
path: "/",
|
||||
expires: -1,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
},
|
||||
// 别的域名的 Cookie 不回写
|
||||
{
|
||||
name: "_ga",
|
||||
value: "tracker",
|
||||
domain: ".analytics.example",
|
||||
path: "/",
|
||||
expires: -1,
|
||||
httpOnly: false,
|
||||
secure: true,
|
||||
},
|
||||
);
|
||||
tab.url = `${ORIGIN}/torrents.php`;
|
||||
const r = await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} });
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
const acc = await readAccountCookie(h.ctx, cookieAcc);
|
||||
expect(acc).toMatchObject({ source: "browser", applicable: true });
|
||||
expect(acc!.header).toContain(`c_secure_pass=${FRESH}`);
|
||||
expect(acc!.header).toContain("c_secure_uid=1001");
|
||||
expect(acc!.header).not.toContain("_ga");
|
||||
const row = await h.db.selectFrom("site_account").selectAll().where("id", "=", cookieAcc).executeTakeFirstOrThrow();
|
||||
expect((row.user_info as Loose).cookieOrigin).toMatchObject({ source: "browser", by: "browser" });
|
||||
const view = (await h.req(`/browser/sessions/${s.id}`)).body.data;
|
||||
expect(view.credential).toMatchObject({ status: "injected", source: "browser" });
|
||||
expect(view.credential.syncedAt).toBeTruthy();
|
||||
expect(JSON.stringify(view)).not.toContain(FRESH);
|
||||
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("关闭会话时回写;停在登录页不回写(未登录的 Cookie 不覆盖存储)", async () => {
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
h.mock.cookieJars.clear();
|
||||
const s = await open();
|
||||
expect(s.credential.status).toBe("injected");
|
||||
// 站点在浏览器里轮换了会话 Cookie
|
||||
const jar = h.mock.cookieJars.get(s.profileKey)!;
|
||||
jar.find((c) => c.name === "c_secure_pass")!.value = "rotated-by-site-44";
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain("c_secure_pass=rotated-by-site-44");
|
||||
|
||||
// 登录页:不回写
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
const s2 = await open("/login.php");
|
||||
h.mock.cookieJars.get(s2.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "anonymous-junk";
|
||||
await h.req(`/browser/sessions/${s2.id}`, { method: "DELETE" });
|
||||
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
|
||||
});
|
||||
|
||||
it("有活动时每 10 分钟读回一次", async () => {
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
h.mock.cookieJars.clear();
|
||||
const s = await open();
|
||||
h.mock.cookieJars.get(s.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "periodic-rotated-5";
|
||||
// 刚打开:不读回
|
||||
await h.req(`/browser/sessions/${s.id}/snapshot`);
|
||||
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
|
||||
// 会话创建时间拨到 11 分钟前:下一次活动触发后台读回
|
||||
await h.db
|
||||
.updateTable("browser_session")
|
||||
.set({ created_at: new Date(Date.now() - 11 * 60_000) })
|
||||
.where("id", "=", s.id)
|
||||
.execute();
|
||||
await h.req(`/browser/sessions/${s.id}/snapshot`);
|
||||
await expect
|
||||
.poll(async () => (await readAccountCookie(h.ctx, cookieAcc))!.header, { timeout: 3000 })
|
||||
.toContain("c_secure_pass=periodic-rotated-5");
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("匿名会话与 API Key 账号不注入", async () => {
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null, url: `${ORIGIN}/` } });
|
||||
expect(r.body.data.credential).toMatchObject({ status: "not_applicable" });
|
||||
await closeAll();
|
||||
const ref = await h.secrets.create({ purpose: "site_api_key", plaintext: "api-key-xyz" });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "api_key", secret_ref: ref })
|
||||
.where("id", "=", cookieAcc)
|
||||
.execute();
|
||||
const before = h.mock.callsTo(/\/cookies$/).length;
|
||||
const s = await open("/");
|
||||
expect(s.credential).toMatchObject({ status: "not_applicable" });
|
||||
expect(h.mock.callsTo(/\/cookies$/).length).toBe(before);
|
||||
await closeAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("BR-2 浏览器服务没有 umt-cookies 插件", () => {
|
||||
let h: BrowserHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("cred_noplugin", {
|
||||
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
|
||||
cookieApi: "none",
|
||||
});
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("注入报不支持,会话照常打开并说明原因", async () => {
|
||||
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_pass=${GOOD}` });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "cookie", secret_ref: ref })
|
||||
.where("id", "=", h.accountId)
|
||||
.execute();
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/` } });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.credential).toMatchObject({ status: "unsupported", count: 0 });
|
||||
expect(r.body.data.credential.message).toContain("umt-cookies");
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { MOCK_ROW_H, MOCK_ROW_Y0 } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BrowserHarness,
|
||||
createBrowserHarness,
|
||||
type Loose,
|
||||
ORIGIN,
|
||||
PASSKEY,
|
||||
PASSWORD,
|
||||
pgAvailable,
|
||||
} from "./harness.ts";
|
||||
|
||||
// C1-E e2e(mock 站点,未连真实 camofox):
|
||||
// Agent 打开站点 → 登录失效 + 验证码 → 会话转等待处理(待处理事项)→ 自动化被拒 →
|
||||
// 人在原站视图接管(截图坐标点击 + 输入,密码由服务端注入)完成登录 → 交还 →
|
||||
// Agent 的旧引用被拒,重新读取页面后继续:捕获种子下载、核验并匹配到本地资源。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const at = (i: number) => ({ x: 320, y: MOCK_ROW_Y0 + MOCK_ROW_H * i + 10 });
|
||||
|
||||
describe.skipIf(!up)("e2e:登录失效 → 人工接管 → 交还 → Agent 重新读取并继续", () => {
|
||||
let h: BrowserHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("e2e", { captcha: true });
|
||||
await h.grant("browser.navigate", "browser.interact", "browser.download");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("全流程", async () => {
|
||||
const agent = h.agentCtx();
|
||||
const invoke = (id: string, input: unknown) => h.registry.invoke(id, input, agent) as Promise<Loose>;
|
||||
|
||||
// 1. Agent 打开种子列表:登录已失效,落到登录页
|
||||
const nav = await invoke("browser.navigate", { siteId: h.siteId, url: `${ORIGIN}/torrents.php` });
|
||||
expect(nav).toMatchObject({ loginRequired: true, controller: "automation" });
|
||||
const sid = nav.sessionId as string;
|
||||
|
||||
// 2. Agent 读页面:有验证码 → 会话转等待处理,生成待处理事项
|
||||
const snap = await invoke("browser.snapshot", { sessionId: sid });
|
||||
expect(snap).toMatchObject({ controller: "waiting", waitingReason: "captcha", loginRequired: true });
|
||||
expect(snap.snapshot.text).toContain("验证码");
|
||||
const decision = await h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("subject_id", "=", sid)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(decision).toMatchObject({ status: "awaiting_user", kind: "other" });
|
||||
expect(decision.title).toContain("观众");
|
||||
|
||||
// 3. 等待处理期间 Agent 不能输入(不无限重试)
|
||||
await expect(
|
||||
invoke("browser.interact", { sessionId: sid, action: "type", target: { ref: "e3" }, credential: "password" }),
|
||||
).rejects.toMatchObject({ code: "conflict" });
|
||||
|
||||
// 4. 人在原站视图接管:截图 → 坐标点击 → 输入(密码走凭证注入)→ 验证码 → 登录
|
||||
const view = await h.req(`/browser/sessions/${sid}`);
|
||||
expect(view.body.data).toMatchObject({ controller: "waiting", waitingReason: "captcha" });
|
||||
const acq = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: { reason: "验证码" } });
|
||||
expect(acq.body.data.session.controller).toBe("human");
|
||||
const shot = await h.req(`/browser/sessions/${sid}/screenshot`);
|
||||
expect(shot.body.data).toMatchObject({ controller: "human", modelSafe: false });
|
||||
const human = async (path: string, body: unknown) => {
|
||||
const r = await h.req(`/browser/sessions/${sid}/${path}`, { body });
|
||||
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
||||
};
|
||||
await human("click", { target: at(1) });
|
||||
await human("type", { text: "fixture-user" });
|
||||
await human("click", { target: at(2) });
|
||||
await human("type", { credential: "password" });
|
||||
await human("click", { target: at(4) });
|
||||
await human("type", { text: "4321" });
|
||||
await human("click", { target: at(5) });
|
||||
const loggedIn = await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
expect(loggedIn.body.data.text).toContain("种子列表");
|
||||
expect(JSON.stringify(loggedIn.body)).not.toContain(PASSWORD);
|
||||
expect(JSON.stringify(loggedIn.body)).not.toContain(PASSKEY);
|
||||
|
||||
// 5. 交还:Agent 恢复前必须重新读页面;待处理事项关闭
|
||||
const rel = await h.req(`/browser/sessions/${sid}/control/release`, { body: { reason: "已登录" } });
|
||||
expect(rel.body.data.session).toMatchObject({ controller: "automation", needsSnapshot: true, waitingReason: null });
|
||||
const d2 = await h.db
|
||||
.selectFrom("decision")
|
||||
.select("status")
|
||||
.where("id", "=", decision.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d2.status).toBe("resolved");
|
||||
|
||||
// 6. 旧引用(交接前拿到的 eN)被拒,不重放
|
||||
await expect(
|
||||
invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e6" } }),
|
||||
).rejects.toMatchObject({ code: "conflict", message: expect.stringContaining("重新读取页面") });
|
||||
|
||||
// 7. 重新读取:人工登录后的状态;继续捕获下载 → 暂存核验 → 匹配本地资源(提交仍走 download.plan)
|
||||
const fresh = await invoke("browser.snapshot", { siteId: h.siteId });
|
||||
expect(fresh).toMatchObject({ controller: "automation", loginRequired: false, waitingReason: null });
|
||||
expect(fresh.snapshot.text).toContain("种子列表");
|
||||
const afterAgent = (await h.req(`/browser/sessions/${sid}`)).body.data;
|
||||
expect(afterAgent.needsSnapshot).toBe(false);
|
||||
// 原站视图据此显示「Agent 正在操作」;人工动作不刷新它
|
||||
expect(Date.now() - Date.parse(afterAgent.lastAutomationAt)).toBeLessThan(60_000);
|
||||
const dl = await invoke("browser.download", { sessionId: sid, target: { ref: "e11" }, timeoutMs: 2_000 });
|
||||
expect(dl).toMatchObject({
|
||||
sourceReleaseId: h.releaseId,
|
||||
duplicate: false,
|
||||
verification: { valid: true, infohashV1: h.torrent.infoHash },
|
||||
});
|
||||
expect(dl.note).toContain("download.plan");
|
||||
expect(JSON.stringify(dl)).not.toContain(PASSKEY);
|
||||
|
||||
// 审计:能力调用留痕,输入里没有凭证
|
||||
const audits = await h.db.selectFrom("audit_event").select(["action", "target_id", "payload"]).execute();
|
||||
expect(audits.some((a) => a.target_id === "browser.download" && a.action === "capability.invoke")).toBe(true);
|
||||
expect(JSON.stringify(audits)).not.toContain(PASSWORD);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { MOCK_CAMOFOX_IDENTITY } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { sourcesIdentityModule } from "../../sources-identity/index.ts";
|
||||
import { readBrowserUserAgent } from "../../sources-identity/service/user-agent.ts";
|
||||
import { egressProxy } from "../service.ts";
|
||||
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
|
||||
|
||||
// BR-4:打开会话前按站点下发出口(站点开代理 = 与 HTTP 同一代理,否则直连);浏览器固定身份写进 browser_provider.identity,
|
||||
// 站点请求 UA 经钩子跟随它。
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe("egressProxy", () => {
|
||||
it("URL → 上下文代理(口令拆出);非法为 null", () => {
|
||||
expect(egressProxy("http://u:p%[email protected]:7891")).toEqual({
|
||||
server: "http://172.17.0.1:7891",
|
||||
username: "u",
|
||||
password: "p@ss",
|
||||
});
|
||||
expect(egressProxy("socks5://127.0.0.1:1080")).toEqual({ server: "socks5://127.0.0.1:1080" });
|
||||
expect(egressProxy("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("BR-4 浏览器身份与出口", () => {
|
||||
let h: BrowserHarness;
|
||||
|
||||
const open = async () =>
|
||||
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null, url: `${ORIGIN}/torrents.php` } }))
|
||||
.body.data as Loose;
|
||||
const closeAll = async () => {
|
||||
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("br4id");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("站点不开代理:该 profile 直连;打开时读浏览器身份存库,站点请求 UA 跟随", async () => {
|
||||
const s = await open();
|
||||
expect(h.mock.egress.get(s.profileKey)).toBeNull();
|
||||
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
|
||||
expect(calls.indexOf(`PUT /umt/sessions/${encodeURIComponent(s.profileKey)}/proxy`)).toBeLessThan(
|
||||
calls.lastIndexOf("POST /tabs"),
|
||||
);
|
||||
const row = await h.db
|
||||
.selectFrom("browser_provider")
|
||||
.select("identity")
|
||||
.where("id", "=", h.providerId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(row.identity).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, os: "windows" });
|
||||
expect(await readBrowserUserAgent(h.ctx)).toBe(MOCK_CAMOFOX_IDENTITY.userAgent);
|
||||
expect(await sourcesIdentityModule.runtime(h.ctx).defaultUserAgent()).toBe(MOCK_CAMOFOX_IDENTITY.userAgent);
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("站点开代理且设了代理地址:该 profile 走同一代理(口令单独传)", async () => {
|
||||
await h.db.updateTable("site").set({ proxy: true }).where("id", "=", h.siteId).execute();
|
||||
await h.db
|
||||
.insertInto("settings")
|
||||
.values({ key: "sites", value: JSON.stringify({ proxyUrl: "http://user:[email protected]:7891" }) })
|
||||
.onConflict((oc) =>
|
||||
oc.column("key").doUpdateSet({ value: JSON.stringify({ proxyUrl: "http://user:[email protected]:7891" }) }),
|
||||
)
|
||||
.execute();
|
||||
const s = await open();
|
||||
expect(h.mock.egress.get(s.profileKey)).toEqual({
|
||||
server: "http://172.17.0.1:7891",
|
||||
username: "user",
|
||||
password: "pw",
|
||||
});
|
||||
await closeAll();
|
||||
await h.db.updateTable("site").set({ proxy: false }).where("id", "=", h.siteId).execute();
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { browserServiceFor } from "../index.ts";
|
||||
import { BrowserKeepalive } from "../keepalive.ts";
|
||||
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
|
||||
|
||||
// AG-4 浏览器会话保活(对 testkit mock camofox):桌面连接 / 人工接管 / 智能体会话正在用的会话定期做一次
|
||||
// GET /tabs/{id}/downloads(不带 includeData / consume);用完不再保活;保活时标签页已被回收 → BR-3 自愈重连。
|
||||
|
||||
describe("BrowserKeepalive(登记与复查)", () => {
|
||||
const fake = () => {
|
||||
const calls: string[] = [];
|
||||
const alive = new Set<string>(["s1", "s2"]);
|
||||
return {
|
||||
calls,
|
||||
alive,
|
||||
target: {
|
||||
keepAlive: async (id: string) => {
|
||||
calls.push(id);
|
||||
return alive.has(id);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it("引用计数:同一会话多个桌面连接,全部断开才停;撤销可重复调用", async () => {
|
||||
const f = fake();
|
||||
const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 });
|
||||
const a = k.hold("s1", "desktop", "desktop");
|
||||
const b = k.hold("s1", "desktop", "desktop");
|
||||
await k.tick();
|
||||
expect(f.calls).toEqual(["s1"]);
|
||||
a();
|
||||
a();
|
||||
expect(k.sessions()).toEqual([{ sessionId: "s1", reasons: ["desktop"] }]);
|
||||
b();
|
||||
expect(k.sessions()).toEqual([]);
|
||||
await k.tick();
|
||||
expect(f.calls).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("复查不通过的登记撤销;会话已关闭(keepAlive 返回 false)整条撤销;ensure 不加引用计数", async () => {
|
||||
const f = fake();
|
||||
const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 });
|
||||
let running = true;
|
||||
k.ensure("s1", "agent:r1", "agent", async () => running);
|
||||
k.ensure("s1", "agent:r1", "agent", async () => running);
|
||||
k.hold("s2", "desktop", "desktop");
|
||||
await k.tick();
|
||||
expect(f.calls.sort()).toEqual(["s1", "s2"]);
|
||||
running = false;
|
||||
f.alive.delete("s2");
|
||||
await k.tick();
|
||||
expect(f.calls).toHaveLength(3); // s1 复查不通过不再保活;s2 保活一次后发现已关闭
|
||||
expect(k.sessions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("保活失败只记日志,下一轮继续;intervalMs = 0 时不登记", async () => {
|
||||
const logs: string[] = [];
|
||||
let fail = true;
|
||||
const k = new BrowserKeepalive(
|
||||
{
|
||||
keepAlive: async () => {
|
||||
if (fail) throw new Error("浏览器服务不可用");
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{ intervalMs: 60_000, log: (_l, msg, extra) => logs.push(`${msg} ${String(extra?.err)}`) },
|
||||
);
|
||||
k.hold("s1", "takeover", "takeover");
|
||||
await k.tick();
|
||||
expect(logs).toEqual(["浏览器会话保活失败 浏览器服务不可用"]);
|
||||
fail = false;
|
||||
await k.tick();
|
||||
expect(k.sessions()).toHaveLength(1);
|
||||
k.stop();
|
||||
|
||||
const off = new BrowserKeepalive(fake().target, { intervalMs: 0 });
|
||||
off.hold("s1", "desktop", "desktop");
|
||||
expect(off.sessions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe.skipIf(!up)("AG-4 浏览器会话保活(mock camofox)", () => {
|
||||
let h: BrowserHarness;
|
||||
const svc = () => browserServiceFor(h.ctx)!;
|
||||
const open = async (path: string) =>
|
||||
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: h.accountId, url: `${ORIGIN}${path}` } }))
|
||||
.body.data as Loose;
|
||||
const pings = (tabId: string) =>
|
||||
h.mock.callsTo(`/tabs/${encodeURIComponent(tabId)}/downloads`).filter((c) => c.method === "GET");
|
||||
const view = async (id: string) => (await h.req(`/browser/sessions/${id}`)).body.data as Loose;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("ag4ka");
|
||||
await h.grant("browser.navigate");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
svc()?.keepalive.stop();
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("人工接管期间保活:只读调用、不记自动化活动;交还后停止", async () => {
|
||||
const s = await open("/torrents.php");
|
||||
const before = (await view(s.id)).lastAutomationAt;
|
||||
const acq = await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "核对" } });
|
||||
expect(acq.status).toBe(200);
|
||||
expect(svc().keepalive.sessions()).toEqual([{ sessionId: s.id, reasons: ["takeover"] }]);
|
||||
|
||||
await svc().keepalive.tick();
|
||||
const p = pings(s.currentTabId);
|
||||
expect(p).toHaveLength(1);
|
||||
// 不带 includeData / consume:不读下载内容、不清下载记录
|
||||
expect(p[0]!.query).toEqual({ userId: s.profileKey });
|
||||
const v = await view(s.id);
|
||||
expect(v.controller).toBe("human");
|
||||
expect(v.lastAutomationAt).toBe(before);
|
||||
|
||||
await h.req(`/browser/sessions/${s.id}/control/release`, { body: { to: "automation", reason: "done" } });
|
||||
expect(svc().keepalive.sessions()).toEqual([]);
|
||||
await svc().keepalive.tick();
|
||||
expect(pings(s.currentTabId)).toHaveLength(1);
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
});
|
||||
|
||||
it("桌面连接期间标签页被空闲回收 → 保活时按 BR-3 自愈重连回到原地址;断开后停止", async () => {
|
||||
const s = await open("/details.php?id=42");
|
||||
const release = svc().keepalive.hold(s.id, "desktop", "desktop");
|
||||
// 上游空闲回收了标签页
|
||||
h.mock.tabs.delete(s.currentTabId);
|
||||
await svc().keepalive.tick();
|
||||
const v = await view(s.id);
|
||||
expect(v.currentTabId).not.toBe(s.currentTabId);
|
||||
// 回到会话记录的地址(测试账号没有登录态,mock 站点跳登录页并带 returnto)
|
||||
expect(decodeURIComponent(v.currentUrl)).toContain("/details.php?id=42");
|
||||
expect(v.reconnect).toMatchObject({ status: "ok", trigger: "tab_gone" });
|
||||
// 新标签页上重试了保活
|
||||
expect(pings(v.currentTabId)).toHaveLength(1);
|
||||
|
||||
release();
|
||||
expect(svc().keepalive.sessions()).toEqual([]);
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
});
|
||||
|
||||
it("智能体会话在用:运行中保活;会话结束后停止;浏览器会话关闭也停止", async () => {
|
||||
const run = await h.db
|
||||
.insertInto("agent_run")
|
||||
.values({
|
||||
trigger: "user",
|
||||
goal: "看看种子列表",
|
||||
status: "running",
|
||||
mode: "execute",
|
||||
scope: JSON.stringify({}),
|
||||
budget: JSON.stringify({}),
|
||||
created_by: "user:admin",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const nav = (await h.registry.invoke(
|
||||
"browser.navigate",
|
||||
{ siteId: h.siteId, url: `${ORIGIN}/torrents.php` },
|
||||
{ ...h.agentCtx(), agentRunId: run.id },
|
||||
)) as Loose;
|
||||
const sid = nav.sessionId as string;
|
||||
expect(svc().keepalive.sessions()).toEqual([{ sessionId: sid, reasons: ["agent"] }]);
|
||||
const tab = (await view(sid)).currentTabId as string;
|
||||
await svc().keepalive.tick();
|
||||
expect(pings(tab)).toHaveLength(1);
|
||||
|
||||
await h.db.updateTable("agent_run").set({ status: "succeeded" }).where("id", "=", run.id).execute();
|
||||
await svc().keepalive.tick();
|
||||
expect(pings(tab)).toHaveLength(1);
|
||||
expect(svc().keepalive.sessions()).toEqual([]);
|
||||
|
||||
// 再用一次(新会话在跑)→ 登记;关闭浏览器会话 → 撤销
|
||||
await h.db.updateTable("agent_run").set({ status: "running" }).where("id", "=", run.id).execute();
|
||||
await h.registry.invoke("browser.snapshot", { sessionId: sid }, { ...h.agentCtx(), agentRunId: run.id });
|
||||
expect(svc().keepalive.sessions()).toHaveLength(1);
|
||||
await h.req(`/browser/sessions/${sid}`, { method: "DELETE" });
|
||||
expect(svc().keepalive.sessions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { BrowserService, BrowserServiceError, ENV_PROVIDER_NAME } from "../service.ts";
|
||||
import { ACCESS_KEY, type BrowserHarness, createBrowserHarness, type Loose, pgAvailable } from "./harness.ts";
|
||||
|
||||
// BR-1:浏览器服务登记 —— 环境变量(BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED → camofox-env)
|
||||
// 与设置页端点(POST / PATCH /browser/providers、POST /browser/providers/{id}/probe)。
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe.skipIf(!up)("浏览器服务登记", () => {
|
||||
let h: BrowserHarness;
|
||||
const withEnv = (env: Record<string, unknown>) =>
|
||||
new BrowserService({ ...h.ctx, config: { ...(h.ctx.config as object), ...env } } as ServerContext, {});
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("provcfg");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("BROWSER_PROVIDER_URL:登记 camofox-env,凭据只在进程内;探测带 Bearer;地址/凭据不能在设置页改", async () => {
|
||||
const svc = withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ACCESS_KEY: ACCESS_KEY, BROWSER_ENABLED: true });
|
||||
const list = await svc.listProviders(true);
|
||||
const env = list.find((p) => p.name === ENV_PROVIDER_NAME)!;
|
||||
expect(env).toMatchObject({ managedBy: "env", hasAccessKey: true, secretRef: null, enabled: true });
|
||||
expect(env.health?.status).toBe("ok");
|
||||
const row = await h.db
|
||||
.selectFrom("browser_provider")
|
||||
.selectAll()
|
||||
.where("name", "=", ENV_PROVIDER_NAME)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(JSON.stringify(row)).not.toContain(ACCESS_KEY);
|
||||
expect(h.mock.calls.filter((c) => c.path === "/openapi.json").at(-1)?.headers.authorization).toBe(
|
||||
`Bearer ${ACCESS_KEY}`,
|
||||
);
|
||||
await expect(svc.updateProvider(env.id, { baseUrl: "http://x:1" })).rejects.toBeInstanceOf(BrowserServiceError);
|
||||
// 启用可以改
|
||||
expect((await svc.updateProvider(env.id, { enabled: false })).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("BROWSER_ENABLED=false / 去掉地址:camofox-env 停用,其它服务不受影响", async () => {
|
||||
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: false }).syncEnvProvider();
|
||||
const off = await h.db
|
||||
.selectFrom("browser_provider")
|
||||
.selectAll()
|
||||
.where("name", "=", ENV_PROVIDER_NAME)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(off.enabled).toBe(false);
|
||||
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: true }).syncEnvProvider();
|
||||
await withEnv({}).syncEnvProvider();
|
||||
const rows = await h.db.selectFrom("browser_provider").select(["name", "enabled"]).orderBy("name").execute();
|
||||
expect(rows).toEqual([
|
||||
{ name: "camofox", enabled: true },
|
||||
{ name: ENV_PROVIDER_NAME, enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("设置页:登记 / 改凭据 / 测试连通;凭据明文不出 API,名称 camofox-env 保留", async () => {
|
||||
const bad = await h.req("/browser/providers", { body: { name: ENV_PROVIDER_NAME, baseUrl: h.mock.url } });
|
||||
expect(bad.status).toBe(400);
|
||||
const c = await h.req("/browser/providers", {
|
||||
body: { name: "my-camofox", baseUrl: h.mock.url, accessKey: "wrong-key", enabled: true },
|
||||
});
|
||||
expect(c.status).toBe(200);
|
||||
const id = c.body.data.id as string;
|
||||
expect(c.body.data).toMatchObject({ managedBy: null, hasAccessKey: true, enabled: true });
|
||||
expect(JSON.stringify(c.body)).not.toContain("wrong-key");
|
||||
const dup = await h.req("/browser/providers", { body: { name: "my-camofox", baseUrl: h.mock.url } });
|
||||
expect(dup.status).toBe(409);
|
||||
// 错误凭据:/health 免认证是好的,但 /openapi.json 401 → 标离线并注明 auth
|
||||
const p1 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
|
||||
expect(p1.body.data).toMatchObject({
|
||||
healthStatus: "offline",
|
||||
health: { status: "down", detail: { error: "auth" } },
|
||||
});
|
||||
const u = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: ACCESS_KEY } });
|
||||
expect(u.status).toBe(200);
|
||||
const p2 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
|
||||
expect(p2.status).toBe(200);
|
||||
expect((p2.body.data as Loose).capabilities.upstreamVersion).toBe("1.17.0-mock");
|
||||
// 空串 = 保持原凭据(FIX-8 只写凭证统一语义)
|
||||
const kept = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: "", enabled: true } });
|
||||
expect(kept.body.data).toMatchObject({ hasAccessKey: true, secretRef: u.body.data.secretRef });
|
||||
const cleared = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: null } });
|
||||
expect(cleared.body.data).toMatchObject({ hasAccessKey: false, secretRef: null });
|
||||
expect((await h.req(`/browser/providers/${id}`, { method: "DELETE" })).status).toBe(200);
|
||||
const env = (await h.req("/browser/providers")).body.data.items.find((p: Loose) => p.name === ENV_PROVIDER_NAME);
|
||||
expect((await h.req(`/browser/providers/${env.id}`, { method: "DELETE" })).status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
|
||||
|
||||
// BR-3 浏览器服务重启后自愈(对 testkit mock camofox):容器重启 → 旧标签页 410、GET /tabs 为空、实例标识换新、
|
||||
// profile 登录态丢失(SIGKILL 未落盘)。任一会话操作 → 注入账号 Cookie → 同一会话新开标签页回到原地址 → 重试原操作;
|
||||
// 失败 503 browser_reconnect_failed;POST .../reconnect 幂等;人工接管中重连控制权不变并提示核对。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const GOOD = "good-session-value-br3";
|
||||
|
||||
describe.skipIf(!up)("BR-3 浏览器服务重启后自动重连", () => {
|
||||
let h: BrowserHarness;
|
||||
|
||||
const setCookieAccount = async (cookie: string) => {
|
||||
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: cookie });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active", user_info: "{}" })
|
||||
.where("id", "=", h.accountId)
|
||||
.execute();
|
||||
};
|
||||
const open = async (path: string) =>
|
||||
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: h.accountId, url: `${ORIGIN}${path}` } }))
|
||||
.body.data as Loose;
|
||||
const closeAll = async () => {
|
||||
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
};
|
||||
const tabRow = (upstream: string) =>
|
||||
h.db.selectFrom("browser_tab").selectAll().where("upstream_tab_id", "=", upstream).executeTakeFirstOrThrow();
|
||||
const cookiePosts = (profileKey: string) =>
|
||||
h.mock.callsTo(`/umt/sessions/${encodeURIComponent(profileKey)}/cookies`).filter((c) => c.method === "POST").length;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("br3", {
|
||||
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: "fresh-br3" },
|
||||
});
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("重启后截图:标签页 410 → 注入 Cookie、新开标签页回到原地址、重试成功;旧标签页标 lost", async () => {
|
||||
const s = await open("/details.php?id=42");
|
||||
expect(s.currentUrl).toBe(`${ORIGIN}/details.php?id=42`);
|
||||
const old = s.currentTabId as string;
|
||||
const injected = cookiePosts(s.profileKey);
|
||||
|
||||
h.mock.restart({ loseProfiles: true });
|
||||
const r = await h.req(`/browser/sessions/${s.id}/screenshot`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.url).toBe(`${ORIGIN}/details.php?id=42`);
|
||||
|
||||
// Cookie 注入先于新标签页;profile 丢了登录态也能回到受保护页(不是登录页)
|
||||
expect(cookiePosts(s.profileKey)).toBe(injected + 1);
|
||||
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
|
||||
expect(calls.lastIndexOf(`POST /umt/sessions/${encodeURIComponent(s.profileKey)}/cookies`)).toBeLessThan(
|
||||
calls.lastIndexOf("POST /tabs"),
|
||||
);
|
||||
const tab = [...h.mock.tabs.values()].find((t) => t.userId === s.profileKey)!;
|
||||
expect(tab.sessionKey).toBe(s.id);
|
||||
expect(tab.url).toBe(`${ORIGIN}/details.php?id=42`);
|
||||
expect(h.mock.loggedIn.has(s.profileKey)).toBe(true);
|
||||
|
||||
const v = (await h.req(`/browser/sessions/${s.id}`)).body.data as Loose;
|
||||
expect(v.currentTabId).toBe(tab.tabId);
|
||||
expect(v.currentTabId).not.toBe(old);
|
||||
expect(v).toMatchObject({ controller: "automation", needsReconnect: false, needsSnapshot: true });
|
||||
expect(v.reconnect).toMatchObject({ status: "ok", humanNotice: false, reason: null });
|
||||
expect((await tabRow(old)).status).toBe("lost");
|
||||
expect(JSON.stringify(v)).not.toContain(GOOD);
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("重启检测:实例标识变了 → 会话标需要重连;POST .../reconnect 幂等", async () => {
|
||||
const s = await open("/torrents.php");
|
||||
h.mock.restart();
|
||||
// 会话详情轮询发现重启(实例标识缓存 5 秒:用显式 reconnect 强制刷新)
|
||||
const r1 = await h.req(`/browser/sessions/${s.id}/reconnect`, { body: {} });
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.body.data).toMatchObject({
|
||||
needsReconnect: false,
|
||||
reconnect: { status: "ok", trigger: "provider_restarted" },
|
||||
});
|
||||
expect(r1.body.data.currentUrl).toBe(`${ORIGIN}/torrents.php`);
|
||||
|
||||
const opened = h.mock.callsTo(/^\/tabs$/).filter((c) => c.method === "POST").length;
|
||||
const r2 = await h.req(`/browser/sessions/${s.id}/reconnect`, { body: {} });
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r2.body.data.currentTabId).toBe(r1.body.data.currentTabId);
|
||||
expect(h.mock.callsTo(/^\/tabs$/).filter((c) => c.method === "POST").length).toBe(opened);
|
||||
|
||||
// Agent 快照也走同一条自愈
|
||||
h.mock.restart();
|
||||
const snap = await h.req(`/browser/sessions/${s.id}/snapshot`);
|
||||
expect(snap.status).toBe(200);
|
||||
expect(snap.body.data.url).toBe(`${ORIGIN}/torrents.php`);
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("人工接管中重连:控制者与租约不变,提示页面已重新加载", async () => {
|
||||
const s = await open("/torrents.php");
|
||||
const acq = await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "核对" } });
|
||||
const leaseId = acq.body.data.lease.id;
|
||||
h.mock.restart();
|
||||
const r = await h.req(`/browser/sessions/${s.id}/screenshot`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.controller).toBe("human");
|
||||
const v = (await h.req(`/browser/sessions/${s.id}`)).body.data as Loose;
|
||||
expect(v.lease).toMatchObject({ id: leaseId, status: "active" });
|
||||
expect(v.reconnect).toMatchObject({ status: "ok", humanNotice: true });
|
||||
// 接管者的输入照常可用
|
||||
const nav = await h.req(`/browser/sessions/${s.id}/navigate`, { body: { url: `${ORIGIN}/index.php` } });
|
||||
expect(nav.status).toBe(200);
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("重连失败:503 browser_reconnect_failed 带原因;短时间内不反复重连;恢复后手动重连成功", async () => {
|
||||
const s = await open("/torrents.php");
|
||||
h.mock.restart();
|
||||
h.mock.fault(/^\/tabs$/, { status: 500, times: 10 });
|
||||
const r = await h.req(`/browser/sessions/${s.id}/screenshot`);
|
||||
expect(r.status).toBe(503);
|
||||
expect(r.body.error.code).toBe("browser_reconnect_failed");
|
||||
expect(r.body.error.message).toContain("HTTP 500");
|
||||
const v = (await h.req(`/browser/sessions/${s.id}`)).body.data as Loose;
|
||||
expect(v).toMatchObject({ needsReconnect: true, reconnect: { status: "failed", trigger: "tab_gone" } });
|
||||
|
||||
// 退避期内的截图直接报上次原因,不再打上游
|
||||
const before = h.mock.calls.length;
|
||||
const again = await h.req(`/browser/sessions/${s.id}/screenshot`);
|
||||
expect(again.status).toBe(503);
|
||||
expect(h.mock.calls.slice(before).some((c) => c.path === "/tabs")).toBe(false);
|
||||
|
||||
h.mock.clearFaults();
|
||||
const ok = await h.req(`/browser/sessions/${s.id}/reconnect`, { body: {} });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.data).toMatchObject({ needsReconnect: false, reconnect: { status: "ok" } });
|
||||
expect((await h.req(`/browser/sessions/${s.id}/screenshot`)).status).toBe(200);
|
||||
await closeAll();
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { getSiteDefinition } from "@umt/adapters";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { resourceIdFromUrl } from "../resource.ts";
|
||||
import { type BrowserHarness, createBrowserHarness, ORIGIN, pgAvailable, READ_KEY } from "./harness.ts";
|
||||
|
||||
const def = getSiteDefinition("audiences")!;
|
||||
describe("原站资源详情定位", () => {
|
||||
it("仅按本站详情路径定位,不把列表、登录或别站同 id 当作资源", () => {
|
||||
expect(resourceIdFromUrl(`${ORIGIN}/details.php?id=73`, `${ORIGIN}/`, def)).toBe("73");
|
||||
expect(resourceIdFromUrl(`${ORIGIN}/torrents.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
|
||||
expect(resourceIdFromUrl(`${ORIGIN}/login.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
|
||||
expect(resourceIdFromUrl("https://else.example/details.php?id=73", `${ORIGIN}/`, def)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const up = await pgAvailable();
|
||||
describe.skipIf(!up)("原站详情页交接(隔离 PG 与浏览器 fixture)", () => {
|
||||
let h: BrowserHarness;
|
||||
let sid: string;
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("resource", {
|
||||
html: () => `<!doctype html><html><head><title>资源详情</title></head><body>
|
||||
<h1 id="top">Example.Movie.2024.2160p.WEB-DL.DV</h1>
|
||||
<table><tr><td class="rowhead">副标题</td><td class="rowfollow">测试译名与简介</td></tr>
|
||||
<tr><td class="rowhead">基本信息</td><td class="rowfollow">大小: 2 GB 类型: 电影</td></tr></table>
|
||||
<div id="kdescr">详情页提供的影片简介 <a href="https://www.imdb.com/title/tt1234567/">IMDb</a></div>
|
||||
</body></html>`,
|
||||
});
|
||||
h.mock.loggedIn.add(`umt-site-${h.siteId}-acct-${h.accountId}`);
|
||||
const session = await h.req("/browser/sessions", {
|
||||
body: { siteId: h.siteId, url: `${ORIGIN}/details.php?id=73` },
|
||||
});
|
||||
expect(session.status).toBe(200);
|
||||
sid = session.body.data.id;
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("未在快照/搜索中的详情可直接登记,缓存PT内容供元数据兜底,不点击原站下载按钮", async () => {
|
||||
const before = h.mock.actions.length;
|
||||
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
|
||||
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
||||
expect(r.body.data.release).toMatchObject({
|
||||
siteId: h.siteId,
|
||||
siteReleaseId: "73",
|
||||
title: "Example.Movie.2024.2160p.WEB-DL.DV",
|
||||
});
|
||||
expect(r.body.data.detailUrl).toBe(`${ORIGIN}/details.php?id=73`);
|
||||
const row = await h.db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where("id", "=", r.body.data.release.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(row.detail_fetched_at).not.toBeNull();
|
||||
expect(row.raw).toMatchObject({ _detail: { imdbId: "tt1234567", sourceUrl: `${ORIGIN}/details.php?id=73` } });
|
||||
expect(h.mock.actions.slice(before).some((a) => a.action === "click")).toBe(false);
|
||||
const again = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
|
||||
expect(again.body.data.release.id).toBe(row.id);
|
||||
});
|
||||
|
||||
it("使用当前页面而非旧会话地址;列表页明确提示进入详情", async () => {
|
||||
const tab = [...h.mock.tabs.values()][0]!;
|
||||
tab.url = `${ORIGIN}/torrents.php`;
|
||||
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.error.message).toContain("详情页");
|
||||
});
|
||||
|
||||
it("只读账号不能通过该入口登记下载资源", async () => {
|
||||
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {}, key: READ_KEY });
|
||||
expect(r.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CapabilityRegistry, InvokeContext } from "@umt/agent";
|
||||
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
|
||||
import {
|
||||
buildFakeTorrent,
|
||||
createMockCamofox,
|
||||
type FakeTorrent,
|
||||
type MockCamofox,
|
||||
type MockCamofoxSite,
|
||||
} from "@umt/testkit";
|
||||
import { Kysely as K, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { withSchemaLock } from "../../../../test/auth-settings/harness.ts";
|
||||
import { DEV_KEY, memoryLogger, memoryStore } from "../../../../test/helpers.ts";
|
||||
import { fakeEngine, fakeSecrets, PG_URL, plainSettings } from "../../../../test/sources-identity/harness.ts";
|
||||
import { createOutboxPort } from "../../../context.ts";
|
||||
import { createApp } from "../../../http/app.ts";
|
||||
import { EventBus } from "../../../http/sse.ts";
|
||||
import { createAcquisitionOrganizeModule } from "../../acquisition-organize/index.ts";
|
||||
import { createCapabilityRegistry } from "../../agent/capability-registry.ts";
|
||||
import { createAgentModule } from "../../agent/index.ts";
|
||||
import { createSourcesIdentityModule } from "../../sources-identity/index.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { registerBrowserCapabilities } from "../capabilities.ts";
|
||||
import { createBrowserModule } from "../index.ts";
|
||||
import type { BrowserModuleOptions } from "../service.ts";
|
||||
|
||||
// C1-E 浏览器模块测试装配:独立 PG schema + 真实迁移 + 交互式 mock camofox(观众风格站点)+ 真实能力注册中心。
|
||||
|
||||
export { pgAvailable } from "../../../../test/sources-identity/harness.ts";
|
||||
|
||||
export const ORIGIN = "https://audiences.example";
|
||||
export const PASSWORD = "brw-s3cret-login-pw";
|
||||
export const PASSKEY = "0f0e0d0c0b0a09080706050403020100";
|
||||
export const ACCESS_KEY = "camofox-access-key-c1e";
|
||||
/** 第二位管理员(接管冲突) */
|
||||
export const OTHER_KEY = "brw-other-admin-key";
|
||||
export const READ_KEY = "brw-read-key";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
|
||||
export type Loose = any;
|
||||
|
||||
export interface BrowserHarness {
|
||||
db: Kysely<DB>;
|
||||
ctx: ServerContext;
|
||||
mock: MockCamofox;
|
||||
torrent: FakeTorrent;
|
||||
registry: CapabilityRegistry;
|
||||
siteId: string;
|
||||
accountId: string;
|
||||
providerId: string;
|
||||
releaseId: string;
|
||||
stagingDir: string;
|
||||
secrets: ReturnType<typeof fakeSecrets>;
|
||||
req(path: string, init?: { method?: string; body?: unknown; key?: string }): Promise<{ status: number; body: Loose }>;
|
||||
agentCtx(): InvokeContext;
|
||||
grant(...actions: string[]): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createBrowserHarness(
|
||||
name: string,
|
||||
opts: {
|
||||
captcha?: boolean;
|
||||
/** BR-2:mock 站点按这个 Cookie 判定登录态 */
|
||||
sessionCookie?: { name: string; valid: string[]; fresh: string };
|
||||
cookieApi?: "plugin" | "upstream" | "none";
|
||||
cookieImportKey?: string;
|
||||
/** CF-2:mock 站点的 Cloudflare 挑战与页面 HTML */
|
||||
cloudflare?: MockCamofoxSite["cloudflare"];
|
||||
html?: MockCamofoxSite["html"];
|
||||
/** 浏览器模块的额外选项(挑战轮询间隔等) */
|
||||
browserOptions?: Partial<BrowserModuleOptions>;
|
||||
} = {},
|
||||
): Promise<BrowserHarness> {
|
||||
const torrent = buildFakeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay.x265-GRP" });
|
||||
const mock = await createMockCamofox({
|
||||
accessKey: ACCESS_KEY,
|
||||
...(opts.cookieApi ? { cookieApi: opts.cookieApi } : {}),
|
||||
...(opts.cookieImportKey ? { cookieImportKey: opts.cookieImportKey } : {}),
|
||||
site: {
|
||||
...(opts.sessionCookie ? { sessionCookie: opts.sessionCookie } : {}),
|
||||
...(opts.cloudflare ? { cloudflare: opts.cloudflare } : {}),
|
||||
...(opts.html ? { html: opts.html } : {}),
|
||||
origin: ORIGIN,
|
||||
username: "fixture-user",
|
||||
password: PASSWORD,
|
||||
passkey: PASSKEY,
|
||||
...(opts.captcha ? { captcha: true, captchaAnswer: "4321" } : {}),
|
||||
releases: [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }],
|
||||
torrent: () => torrent.bytes,
|
||||
},
|
||||
});
|
||||
const stagingDir = await mkdtemp(join(tmpdir(), "umt-browser-"));
|
||||
const schema = `c1e_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const admin = new K<unknown>({
|
||||
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }),
|
||||
});
|
||||
await sql.raw(`create schema ${schema}`).execute(admin);
|
||||
await import("@umt/db");
|
||||
const db = new K<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
}) as unknown as Kysely<DB>;
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
|
||||
const { logger } = memoryLogger();
|
||||
const secrets = fakeSecrets(() => db);
|
||||
const settings = plainSettings(() => db);
|
||||
const registry = createCapabilityRegistry({ logger, db, settings });
|
||||
const ctx = {
|
||||
role: "all",
|
||||
db,
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never,
|
||||
logger,
|
||||
events: new EventBus(5),
|
||||
engine: fakeEngine(),
|
||||
outbox: createOutboxPort(),
|
||||
secrets,
|
||||
settings,
|
||||
capabilities: registry,
|
||||
} as ServerContext;
|
||||
const sources = createSourcesIdentityModule({ tmdb: () => null });
|
||||
const browser = createBrowserModule({
|
||||
runtime: (c) => sources.runtime(c),
|
||||
stagingDir,
|
||||
downloadPollMs: 20,
|
||||
...opts.browserOptions,
|
||||
});
|
||||
registerBrowserCapabilities(registry, () => browser.service(ctx));
|
||||
const app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger,
|
||||
bus: new EventBus(5),
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
// POST /acquisition-plans 在 agent 模块
|
||||
modules: [sources, createAcquisitionOrganizeModule(), createAgentModule(), browser],
|
||||
ctx,
|
||||
apiKeyStore: memoryStore({
|
||||
[OTHER_KEY]: { id: "other-admin", scopes: ["admin"] },
|
||||
[READ_KEY]: { id: "reader", scopes: ["read"] },
|
||||
}),
|
||||
});
|
||||
|
||||
const siteId = (
|
||||
await db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: "audiences",
|
||||
name: "观众",
|
||||
adapter: "nexusphp",
|
||||
base_url: `${ORIGIN}/`,
|
||||
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const pwRef = await secrets.create({ purpose: "site_password", plaintext: PASSWORD });
|
||||
const accountId = (
|
||||
await db
|
||||
.insertInto("site_account")
|
||||
.values({ site_id: siteId, label: "main", auth_kind: "password", secret_ref: pwRef, login_name: "fixture-user" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const keyRef = await secrets.create({ purpose: "other", plaintext: ACCESS_KEY });
|
||||
const providerId = (
|
||||
await db
|
||||
.insertInto("browser_provider")
|
||||
.values({
|
||||
name: "camofox",
|
||||
kind: "camofox",
|
||||
base_url: mock.url,
|
||||
secret_ref: keyRef,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ verified: { snapshot: true }, navigationTimeoutMs: 2_000, maxRetries: 1 }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const releaseId = (
|
||||
await db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
site_release_id: "42",
|
||||
title: "Oppenheimer 2023 2160p UHD BluRay",
|
||||
infohash_v1: torrent.infoHash,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
|
||||
return {
|
||||
db,
|
||||
ctx,
|
||||
mock,
|
||||
torrent,
|
||||
registry,
|
||||
siteId,
|
||||
accountId,
|
||||
providerId,
|
||||
releaseId,
|
||||
stagingDir,
|
||||
secrets,
|
||||
async req(path, init = {}) {
|
||||
const res = await app.request(`/api/v1${path}`, {
|
||||
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
|
||||
headers: {
|
||||
authorization: `Bearer ${init.key ?? DEV_KEY}`,
|
||||
...(init.body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as Loose };
|
||||
},
|
||||
agentCtx: () => ({
|
||||
actorId: "agent:c1e",
|
||||
taskId: crypto.randomUUID(),
|
||||
scopeIds: [],
|
||||
signal: new AbortController().signal,
|
||||
source: "agent",
|
||||
}),
|
||||
async grant(...actions) {
|
||||
for (const action of actions)
|
||||
await db.insertInto("automation_grant").values({ action, granted_by: "test" }).execute();
|
||||
},
|
||||
async close() {
|
||||
await db.destroy();
|
||||
await withSchemaLock(() => sql.raw(`drop schema if exists ${schema} cascade`).execute(admin));
|
||||
await admin.destroy();
|
||||
await mock.close();
|
||||
await rm(stagingDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuthEnv, Principal, SessionVar } from "../../../http/auth.ts";
|
||||
import { ApiException, errorBody } from "../../../http/errors.ts";
|
||||
import {
|
||||
basicAuth,
|
||||
type DesktopDeps,
|
||||
type DesktopRole,
|
||||
deriveDesktopPasswords,
|
||||
desktopUpstream,
|
||||
registerDesktopRoutes,
|
||||
sameOrigin,
|
||||
} from "../desktop.ts";
|
||||
|
||||
// 桌面模式反代:口令派生与容器 init 脚本一致、角色 → 口令注入、只接受控制台会话、响应头与路径约束。
|
||||
|
||||
const SID = "7d3c1a52-9b0e-4b7a-8a51-0c2f7f7f0a11";
|
||||
const KEY = "p0key";
|
||||
|
||||
function harness(
|
||||
opts: { principal?: Partial<Principal>; role?: DesktopRole; check?: DesktopDeps["checkSession"] } = {},
|
||||
) {
|
||||
const calls: { url: string; headers: Headers; method: string }[] = [];
|
||||
const app = new OpenAPIHono<AuthEnv>();
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400);
|
||||
throw err;
|
||||
});
|
||||
app.use("*", async (c, next) => {
|
||||
const p: Principal = { kind: "admin_session", id: "u1", name: "admin", scopes: ["admin"], ...opts.principal };
|
||||
c.set("principal", p);
|
||||
c.set("session", p.kind === "admin_session" ? ({ id: "s1" } as SessionVar) : undefined);
|
||||
await next();
|
||||
});
|
||||
registerDesktopRoutes(app, {
|
||||
upstream: "http://camofox:3000",
|
||||
accessKey: KEY,
|
||||
checkSession:
|
||||
opts.check ??
|
||||
(async (_id, holder) => {
|
||||
expect(holder).toBe("admin_session:admin");
|
||||
return { ok: true, role: opts.role ?? "viewer", leaseExpiresAt: null };
|
||||
}),
|
||||
consoleSessionValid: async () => true,
|
||||
probeTtlMs: 0,
|
||||
fetch: (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, headers: new Headers(init?.headers), method: init?.method ?? "GET" });
|
||||
if (url.endsWith("/api/secret-401"))
|
||||
return new Response("no", { status: 401, headers: { "www-authenticate": "Basic" } });
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html", "www-authenticate": "Basic", "x-frame-options": "DENY" },
|
||||
});
|
||||
}) as typeof fetch,
|
||||
});
|
||||
const root = new OpenAPIHono<AuthEnv>();
|
||||
root.onError((err, c) => {
|
||||
if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400);
|
||||
throw err;
|
||||
});
|
||||
root.route("/api/v1", app);
|
||||
return { app: root, calls };
|
||||
}
|
||||
|
||||
describe("桌面口令与上游地址", () => {
|
||||
it("口令与容器 init-umt-desktop 的 openssl HMAC 派生一致", () => {
|
||||
// printf '%s' umt-desktop/control | openssl dgst -sha256 -hmac p0key -r | cut -c1-48
|
||||
const p = deriveDesktopPasswords(KEY);
|
||||
expect(p.controller).toBe("0b29251f95456b1e4612e3f4c45c2e353fd24e5ecbdf4959");
|
||||
expect(p.viewer).toHaveLength(48);
|
||||
expect(p.viewer).not.toBe(p.controller);
|
||||
});
|
||||
|
||||
it("缺省由浏览器服务地址推出(端口换 3000),可显式覆盖", () => {
|
||||
expect(desktopUpstream({ providerUrl: "http://camofox:9377" })).toBe("http://camofox:3000");
|
||||
expect(desktopUpstream({ providerUrl: "http://camofox:9377", desktopUrl: "http://desk:3000/" })).toBe(
|
||||
"http://desk:3000",
|
||||
);
|
||||
expect(desktopUpstream({})).toBeNull();
|
||||
});
|
||||
|
||||
it("WebSocket 只接受同源页面", () => {
|
||||
const req = (origin: string | null, host = "media.example.com") =>
|
||||
new Request("https://media.example.com/x", { headers: { host, ...(origin ? { origin } : {}) } });
|
||||
expect(sameOrigin(req("https://media.example.com"))).toBe(true);
|
||||
expect(sameOrigin(req("https://evil.example"))).toBe(false);
|
||||
expect(sameOrigin(req(null))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("桌面反代", () => {
|
||||
it("外部 API Key 不能用桌面模式", async () => {
|
||||
const { app } = harness({ principal: { kind: "api_key" } });
|
||||
const r = await app.request(`/api/v1/browser/desktop/${SID}/`);
|
||||
expect(r.status).toBe(403);
|
||||
});
|
||||
|
||||
it("状态:按租约给角色,并返回 iframe 地址", async () => {
|
||||
const { app } = harness({ role: "controller" });
|
||||
const r = await app.request(`/api/v1/browser/desktop/${SID}/_status`);
|
||||
const body = (await r.json()) as { data: { available: boolean; role: string; path: string } };
|
||||
expect(body.data).toMatchObject({ available: true, role: "controller", path: `/api/v1/browser/desktop/${SID}/` });
|
||||
});
|
||||
|
||||
it("静态资源:不带口令、不转发 cookie;响应只允许本站嵌入,不外露认证质询", async () => {
|
||||
const { app, calls } = harness();
|
||||
const r = await app.request(`/api/v1/browser/desktop/${SID}/assets/index.js?v=1`, {
|
||||
headers: { cookie: "umt_session=secret" },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(calls.at(-1)?.url).toBe("http://camofox:3000/assets/index.js?v=1");
|
||||
expect(calls.at(-1)?.headers.get("authorization")).toBeNull();
|
||||
expect(calls.at(-1)?.headers.get("cookie")).toBeNull();
|
||||
expect(r.headers.get("content-security-policy")).toBe("frame-ancestors 'self'");
|
||||
expect(r.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
||||
expect(r.headers.get("www-authenticate")).toBeNull();
|
||||
});
|
||||
|
||||
it("Selkies 接口:只读 / 控制口令按角色注入", async () => {
|
||||
const pw = deriveDesktopPasswords(KEY);
|
||||
for (const role of ["viewer", "controller"] as const) {
|
||||
const { app, calls } = harness({ role });
|
||||
await app.request(`/api/v1/browser/desktop/${SID}/api/websockets`);
|
||||
expect(calls.at(-1)?.headers.get("authorization")).toBe(basicAuth(pw[role]));
|
||||
}
|
||||
});
|
||||
|
||||
it("上游 401 不透传质询,报口令不匹配", async () => {
|
||||
const { app } = harness();
|
||||
const r = await app.request(`/api/v1/browser/desktop/${SID}/api/secret-401`);
|
||||
expect(r.status).toBe(502);
|
||||
expect(r.headers.get("www-authenticate")).toBeNull();
|
||||
});
|
||||
|
||||
it("会话不属于自带浏览器服务 → 409;非法会话 id → 404;WebSocket 跨源 → 403", async () => {
|
||||
const { app } = harness({
|
||||
check: async () => ({ ok: false, code: "unsupported", message: "只有部署自带的浏览器服务支持桌面模式" }),
|
||||
});
|
||||
expect((await app.request(`/api/v1/browser/desktop/${SID}/api/websockets`)).status).toBe(409);
|
||||
expect((await app.request("/api/v1/browser/desktop/not-a-uuid/")).status).toBe(404);
|
||||
const ws = await harness().app.request(`/api/v1/browser/desktop/${SID}/api/websockets`, {
|
||||
headers: { upgrade: "websocket", origin: "https://evil.example", host: "media.example.com" },
|
||||
});
|
||||
expect(ws.status).toBe(403);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user