import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, seedAsset, seedBinding, seedDownload, seedEntity, } from "./harness.ts"; // 删除保护与回收站:引用检查(做种 / 资产 / 绑定 / 根本身)、退役联动、回收站往返、原位被占 409、purge 只清过期、 // 永久删除开关与 admin 会话、API Key 需一次性审批。 const skip = !(await pgAvailable()); const exists = (p: string) => access(p).then( () => true, () => false, ); describe.skipIf(skip)("files 删除与回收站", () => { let h: FmHarness; let entity: string; beforeAll(async () => { h = await createFmHarness("trash"); entity = await seedEntity(h.db, { kind: "movie", title: "Gone", year: 2001 }); }); afterAll(async () => h?.close()); const del = (body: Record, k?: string) => h.api("POST", "/files/delete", { idempotencyKey: key(), ...body }, k); it("纳管做种文件 409;根本身拒绝;dryRun 列出引用", async () => { await writeFile(join(h.dl, "Seed.2020.mkv"), "seed"); await seedDownload(h.db, h, { name: "Seed.2020.mkv", status: "completed" }); const dry = await del({ items: [{ root: h.dlRoot, path: "Seed.2020.mkv" }], dryRun: true }); expect(dry.status).toBe(200); expect(dry.body.data.checks[0]).toMatchObject({ blocked: "download_active" }); expect(dry.body.data.checks[0].downloads[0].hnr).toBe("unknown"); const exec = await del({ items: [{ root: h.dlRoot, path: "Seed.2020.mkv" }], dryRun: false }); expect(exec.status).toBe(409); expect(await exists(join(h.dl, "Seed.2020.mkv"))).toBe(true); const root = await del({ items: [{ root: h.libRoot, path: "" }], dryRun: false }); expect(root.status).toBe(400); }); it("已登记资产:未勾选退役 409;勾选后进回收站、location=removed、asset=retired、Slot→attention + 待处理", async () => { await mkdir(join(h.lib, "Gone (2001)")); await writeFile(join(h.lib, "Gone (2001)", "Gone.2001.mkv"), "gone"); const a = await seedAsset(h.db, h, { entityId: entity, rel: "Gone (2001)/Gone.2001.mkv" }); const sub = await h.db .insertInto("subscription") .values({ media_entity_id: entity, media_kind: "movie", mode: "continuous" }) .returning("id") .executeTakeFirstOrThrow(); const slot = await h.db .insertInto("acquisition_slot") .values({ media_entity_id: entity, subscription_id: sub.id, name: "main", status: "satisfied", current_asset_id: a.assetId, }) .returning("id") .executeTakeFirstOrThrow(); const items = [{ root: h.libRoot, path: "Gone (2001)/Gone.2001.mkv" }]; const no = await del({ items, dryRun: false }); expect(no.status).toBe(409); expect(no.body.error.message).toMatch(/^needs_retire:/); const ok = await del({ items, dryRun: false, retireAssets: true }); expect(ok.status).toBe(200); expect(ok.body.data.trashed).toHaveLength(1); const t = ok.body.data.trashed[0]; expect(t.trashPath).toContain(join(h.lib, ".umt-trash")); expect(await exists(join(h.lib, "Gone (2001)", "Gone.2001.mkv"))).toBe(false); expect(await readFile(t.trashPath, "utf8")).toBe("gone"); const loc = await h.db .selectFrom("asset_location") .select(["status"]) .where("id", "=", a.locationId) .executeTakeFirstOrThrow(); expect(loc.status).toBe("removed"); const asset = await h.db .selectFrom("media_asset") .select(["status"]) .where("id", "=", a.assetId) .executeTakeFirstOrThrow(); expect(asset.status).toBe("retired"); const s = await h.db .selectFrom("acquisition_slot") .select(["status"]) .where("id", "=", slot.id) .executeTakeFirstOrThrow(); expect(s.status).toBe("attention"); const dec = await h.db.selectFrom("decision").select(["subject_id"]).where("subject_id", "=", slot.id).execute(); expect(dec).toHaveLength(1); // 回收站列表 → 还原到原位 const list = await h.api("GET", "/files/trash"); expect(list.body.data.items.map((x: { id: string }) => x.id)).toContain(t.id); const restore = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [t.id] }); expect(restore.status).toBe(200); expect(restore.body.data.restored).toEqual([{ id: t.id, root: h.libRoot, path: "Gone (2001)/Gone.2001.mkv" }]); expect(await readFile(join(h.lib, "Gone (2001)", "Gone.2001.mkv"), "utf8")).toBe("gone"); }); it("还原时原位被占 → 该项 conflict;可还原到其他目录", async () => { await writeFile(join(h.lib, "dup.txt"), "v1"); const d1 = await del({ items: [{ root: h.libRoot, path: "dup.txt" }], dryRun: false }); const id = d1.body.data.trashed[0].id; await writeFile(join(h.lib, "dup.txt"), "v2"); const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] }); expect(r.body.data.failed).toEqual([expect.objectContaining({ id, code: "exists" })]); expect(await readFile(join(h.lib, "dup.txt"), "utf8")).toBe("v2"); await mkdir(join(h.lib, "restored")); const r2 = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id], target: { root: h.libRoot, path: "restored" }, }); expect(r2.body.data.restored[0].path).toBe("restored/dup.txt"); expect(await readFile(join(h.lib, "restored", "dup.txt"), "utf8")).toBe("v1"); }); it("绑定目录需勾选解除绑定", async () => { await mkdir(join(h.lib, "Bound")); const b = await seedBinding(h.db, h, { entityId: entity, rel: "Bound" }); const no = await del({ items: [{ root: h.libRoot, path: "Bound" }], dryRun: false }); expect(no.status).toBe(409); expect(no.body.error.message).toMatch(/^needs_unbind:/); const ok = await del({ items: [{ root: h.libRoot, path: "Bound" }], dryRun: false, unbind: true }); expect(ok.status).toBe(200); const row = await h.db.selectFrom("path_binding").select(["status"]).where("id", "=", b).executeTakeFirstOrThrow(); expect(row.status).toBe("superseded"); }); it("purge 只清过期项;API Key 不能 purge", async () => { await writeFile(join(h.lib, "old.txt"), "o"); await writeFile(join(h.lib, "new.txt"), "n"); const a = await del({ items: [{ root: h.libRoot, path: "old.txt" }], dryRun: false }); const b = await del({ items: [{ root: h.libRoot, path: "new.txt" }], dryRun: false }); const oldId = a.body.data.trashed[0].id; const newId = b.body.data.trashed[0].id; await h.db .updateTable("trash_item") .set({ expires_at: new Date(Date.now() - 1000) }) .where("id", "=", oldId) .execute(); const denied = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, EXEC_KEY); expect(denied.status).toBe(403); const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }); expect(r.status).toBe(200); expect(r.body.data.purged).toEqual([oldId]); expect(await exists(a.body.data.trashed[0].trashPath)).toBe(false); expect(await exists(b.body.data.trashed[0].trashPath)).toBe(true); const left = await h.db.selectFrom("trash_item").select(["id"]).execute(); expect(left.map((x) => x.id)).toContain(newId); expect(left.map((x) => x.id)).not.toContain(oldId); }); it("永久删除默认关闭;API Key 删除需一次性审批", async () => { await writeFile(join(h.lib, "perm.txt"), "p"); const p = await del({ items: [{ root: h.libRoot, path: "perm.txt" }], mode: "permanent", dryRun: false }); expect(p.status).toBe(403); expect(p.body.error.message).toMatch(/^permanent_disabled:/); const items = [{ root: h.libRoot, path: "perm.txt" }]; const dry = await del({ items, dryRun: true }, EXEC_KEY); expect(dry.status).toBe(200); const noApproval = await del({ items, dryRun: false, planId: dry.body.data.planId }, EXEC_KEY); expect(noApproval.status).toBe(403); expect(noApproval.body.error.message).toMatch(/^approval_required:/); // 管理员批准该计划(绑定内容哈希)后可执行一次 const art = await h.db .selectFrom("artifact") .select(["content_hash"]) .where("id", "=", dry.body.data.planId) .executeTakeFirstOrThrow(); await h.db .insertInto("approval") .values({ plan_type: "files_plan", plan_id: dry.body.data.planId, payload_hash: art.content_hash, expected_revision: 0, status: "approved", requested_by: "api_key:x", }) .execute(); const ok = await del({ items, dryRun: false, planId: dry.body.data.planId }, EXEC_KEY); expect(ok.status).toBe(200); expect(await exists(join(h.lib, "perm.txt"))).toBe(false); }); it("离线根:删除检查返回 root_unavailable,不当作已删除", async () => { const { rename } = await import("node:fs/promises"); await writeFile(join(h.lib, "keep.txt"), "k"); await rename(join(h.lib, ".uhd-root"), join(h.lib, ".uhd-root.bak")); try { const r = await del({ items: [{ root: h.libRoot, path: "keep.txt" }], dryRun: false }); expect(r.status).toBe(503); } finally { await rename(join(h.lib, ".uhd-root.bak"), join(h.lib, ".uhd-root")); } expect(await exists(join(h.lib, "keep.txt"))).toBe(true); }); });