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
@@ -0,0 +1,126 @@
|
||||
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 "../../src/modules/schedules/index.ts";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/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);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
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 "../../src/http/auth.ts";
|
||||
import { createSettingsReader } from "../../src/modules/auth-settings/provided.ts";
|
||||
import { registerSettingsRoutes } from "../../src/modules/auth-settings/settings-routes.ts";
|
||||
import { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
|
||||
import { classifyEntity } from "../../src/modules/acquisition-organize/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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
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("../../src/modules/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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createMockQb, type MockQb } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { syncDownloads } from "../../src/modules/acquisition-organize/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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
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 "../../src/modules/acquisition-organize/services/common.ts";
|
||||
import { type QueueScrapeInput, queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/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 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
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 "../../src/config.ts";
|
||||
import { createOutboxPort } from "../../src/context.ts";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { hashApiKey } from "../../src/http/auth.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createLogger } from "../../src/logger.ts";
|
||||
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
import { type AoOverrides, createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
|
||||
import type { AoDeps } from "../../src/modules/acquisition-organize/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;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { DB } from "@umt/db";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { backfillVersionSpecs } from "../../src/modules/acquisition-organize/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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { ManualAcquisitionRequest } from "@umt/contracts";
|
||||
import { createMockQb, createTempTree, makeTorrent } from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { submitDownload } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { planManualAcquisition } from "../../src/modules/acquisition-organize/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
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 "../../src/modules/acquisition-organize/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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
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 "../../src/modules/acquisition-organize/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);
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
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 "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
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 "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { executeRecord, type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/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);
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
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 "../../src/modules/sources-identity/runtime.ts";
|
||||
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
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 "../../src/modules/acquisition-organize/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { makeTorrent } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
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 "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { entityScrapeLocations, runScrapeRecord, SCRAPE_TASK, scrapeEntity } from "../../src/modules/acquisition-organize/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);
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
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 "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { applyEpisodeTotal, subscriptionProgress, sweepDueSlots } from "../../src/modules/acquisition-organize/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);
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
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 "../../src/modules/sources-identity/service/entities.ts";
|
||||
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { organizeDecisionHook, pickMainVideo, takeoverParsed } from "../../src/modules/acquisition-organize/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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
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 "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { cleanupDownloadCache, submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { reconcileLibrary } from "../../src/modules/acquisition-organize/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);
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
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 "../../src/modules/browser/capabilities.ts";
|
||||
import { createBrowserModule } from "../../src/modules/browser/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("../../src/modules/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);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
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 "../../src/modules/browser/index.ts";
|
||||
import { type BrowserService, registerChallengeVision } from "../../src/modules/browser/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: "站点已停用",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readAccountCookie } from "../../src/modules/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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MOCK_CAMOFOX_IDENTITY } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { sourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
|
||||
import { readBrowserUserAgent } from "../../src/modules/sources-identity/service/user-agent.ts";
|
||||
import { egressProxy } from "../../src/modules/browser/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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { browserServiceFor } from "../../src/modules/browser/index.ts";
|
||||
import { BrowserKeepalive } from "../../src/modules/browser/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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
import { BrowserService, BrowserServiceError, ENV_PROVIDER_NAME } from "../../src/modules/browser/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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getSiteDefinition } from "@umt/adapters";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { resourceIdFromUrl } from "../../src/modules/browser/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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
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 "../auth-settings/harness.ts";
|
||||
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
|
||||
import { fakeEngine, fakeSecrets, PG_URL, plainSettings } from "../sources-identity/harness.ts";
|
||||
import { createOutboxPort } from "../../src/context.ts";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
|
||||
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||
import { createAgentModule } from "../../src/modules/agent/index.ts";
|
||||
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
import { registerBrowserCapabilities } from "../../src/modules/browser/capabilities.ts";
|
||||
import { createBrowserModule } from "../../src/modules/browser/index.ts";
|
||||
import type { BrowserModuleOptions } from "../../src/modules/browser/service.ts";
|
||||
|
||||
// C1-E 浏览器模块测试装配:独立 PG schema + 真实迁移 + 交互式 mock camofox(观众风格站点)+ 真实能力注册中心。
|
||||
|
||||
export { pgAvailable } from "../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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuthEnv, Principal, SessionVar } from "../../src/http/auth.ts";
|
||||
import { ApiException, errorBody } from "../../src/http/errors.ts";
|
||||
import {
|
||||
basicAuth,
|
||||
type DesktopDeps,
|
||||
type DesktopRole,
|
||||
deriveDesktopPasswords,
|
||||
desktopUpstream,
|
||||
registerDesktopRoutes,
|
||||
sameOrigin,
|
||||
} from "../../src/modules/desktop/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);
|
||||
});
|
||||
});
|
||||
File renamed without changes.
+1
-1
@@ -14,7 +14,7 @@ import { createE2E, type E2E, pgAvailable, runEvents, runRow, waitFor } from "./
|
||||
// 运行(凭证只经环境变量传入,不写进代码与报告):
|
||||
// UMT_EVAL_REAL=1 UMT_EVAL_API_KEY=… UMT_EVAL_PROVIDER=opencode-go UMT_EVAL_MODEL=deepseek-v4.1-flash \
|
||||
// UMT_EVAL_LEGACY_PROMPT=/tmp/legacy-system-prompt.ts UMT_EVAL_CASES=health,acquire,why UMT_EVAL_OUT=/tmp/eval.json \
|
||||
// DATABASE_URL=… deno run -A npm:vitest run test/e2e/e16-agent-prompt-eval.real.test.ts
|
||||
// DATABASE_URL=… deno run -A npm:vitest run test/e2e/agent-prompt-eval.real.test.ts
|
||||
// UMT_EVAL_LEGACY_PROMPT 用 `git show main:packages/agent/src/system-prompt.ts > /tmp/legacy-system-prompt.ts` 生成;
|
||||
// UMT_EVAL_PROMPTS=legacy,layered 控制跑哪几组(缺省两组都跑)。
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { createE2E, type E2E, pgAvailable, runEvents, runRow, waitFor } from "./
|
||||
// - fake 模型按"合格模型"的路线调用能力:断言调用顺序、只读调用都成功、需要批准的被拒绝(不向用户要 ID:
|
||||
// download.inspect 的 ID 来自 download.list);
|
||||
// - 汇报格式约束:合格汇报(只用工具结果里的数据写)通过 lintReport(无 UUID / 原始字节 / 内部字段,≤8 行,结论先行)。
|
||||
// 真实模型的前后对比见 e16-agent-prompt-eval.real.test.ts 与 docs/design/agent-prompt-eval.md。
|
||||
// 真实模型的前后对比见 agent-prompt-eval.real.test.ts 与 docs/design/agent-prompt-eval.md。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -20,7 +20,7 @@ import { createApp } from "../../src/http/app.ts";
|
||||
import { deriveCsrfToken, hashApiKey, hashSessionToken } from "../../src/http/auth.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createLogger } from "../../src/logger.ts";
|
||||
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
import { fakeEngine } from "../acquisition-organize/harness.ts";
|
||||
import { executeRecord, type FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||
|
||||
File renamed without changes.
@@ -4,7 +4,7 @@ import {
|
||||
type Harness,
|
||||
pgAvailable,
|
||||
seedEntity,
|
||||
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
} from "../acquisition-organize/harness.ts";
|
||||
import { MEDIA_SERVER_LIMITS } from "../../src/modules/acquisition-organize/routes/library.ts";
|
||||
|
||||
// C1-D:GET /inventory/media-servers 按实例并行(allSettled)+ 实例内并发上限 + 整实例超时;部分失败 partial=true。
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
seedEntity,
|
||||
seedRelease,
|
||||
seedSlot,
|
||||
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
} from "../acquisition-organize/harness.ts";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
|
||||
// W9-D 规则组:关键字表、规则串解析/序列化、CRUD + 修订、抑制记录、试算、默认链、评估接入、旧端点别名。
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -10,7 +10,7 @@ import {
|
||||
seedBase,
|
||||
seedEntity,
|
||||
seedRelease,
|
||||
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
} from "../acquisition-organize/harness.ts";
|
||||
import {
|
||||
evaluateAcquisition,
|
||||
subscriptionSavePath,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
seedBase,
|
||||
seedEntity,
|
||||
seedRelease,
|
||||
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
} from "../acquisition-organize/harness.ts";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
|
||||
// W9-E 订阅重做后端:总览分组/排序/计数、覆盖矩阵(单集/季包/多版本/在途/临时单元/电影版本)、
|
||||
|
||||
Reference in new issue
Block a user