- 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]>
524 lines
24 KiB
TypeScript
524 lines
24 KiB
TypeScript
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);
|
|
});
|