import { randomUUID } from "node:crypto"; import { access, copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { findCrossDeviceBase } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts"; import { fsErrorToFiles } from "../../src/modules/file-manager/common.ts"; import { ADMIN_KEY, createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, READ_KEY, SESSION, seedAsset, seedBinding, seedEntity, sql, } from "./harness.ts"; // W5-A2:移动/复制走 planImport(目录跨卷逐文件可恢复)、整理逐项覆写生效、删除/还原写整理记录、 // 下载守卫、记录回查、文件系统竞态不 500。 const skip = !(await pgAvailable()); const xdev = findCrossDeviceBase(); const exists = (p: string) => access(p).then( () => true, () => false, ); describe.skipIf(skip)("files W5-A2", () => { let h: FmHarness; beforeAll(async () => { h = await createFmHarness( "w5a2", { options: { allowPermanentDelete: true, downloadMaxBytes: 100, transferWaitMs: 200 } }, { runTransfers: false }, ); }); afterAll(async () => h?.close()); it.skipIf(!xdev)( "目录跨卷移动:逐文件 Copy→核验→删源;中途 kill 后续跑 resumed 对账;位置跟随、绑定修订、删空目录", async () => { // 第二个库根放在另一设备上 const xlib = await mkdtemp(join(xdev!, "fm-xlib-")); try { await writeFile(join(xlib, ".uhd-root"), "sentinel\n"); await mkdir(join(xlib, "tv")); const x = await h.db .insertInto("library_root") .values({ name: "跨卷库", media_kind: "mixed", local_path: xlib, management_mode: "managed" }) .returning("id") .executeTakeFirstOrThrow(); const show = await seedEntity(h.db, { kind: "series", title: "Show", year: 2020 }); await mkdir(join(h.lib, "Show", "extras"), { recursive: true }); await writeFile(join(h.lib, "Show", "Show.S01E01.mkv"), "episode-one"); await writeFile(join(h.lib, "Show", "Show.S01E02.mkv"), "episode-two"); await writeFile(join(h.lib, "Show", "extras", "bonus.nfo"), ""); const a = await seedAsset(h.db, h, { entityId: show, rel: "Show/Show.S01E01.mkv" }); const bind = await seedBinding(h.db, h, { entityId: show, rel: "Show" }); const body = { items: [{ root: h.libRoot, path: "Show" }], target: { root: `lib:${x.id}`, path: "tv" }, mode: "move", }; // 跨卷 Move 未开启:预览给 warning,执行 400(不产生记录) await sql`insert into settings (key, value) values ('organize', ${JSON.stringify({ allowCrossVolumeMove: false })}::jsonb) on conflict (key) do update set value = excluded.value`.execute(h.db); const off = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true }); expect(off.body.data.warnings.join()).toMatch(/未开启跨卷移动/); const offExec = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: false, planId: off.body.data.planId, }); expect(offExec.status).toBe(400); await sql`insert into settings (key, value) values ('organize', ${JSON.stringify({ allowCrossVolumeMove: true })}::jsonb) on conflict (key) do update set value = excluded.value`.execute(h.db); const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true }); expect(dry.status).toBe(200); expect(dry.body.data.pairs[0]).toMatchObject({ files: 3, crossDevice: true, registered: true, conflict: false, }); expect(dry.body.data.warnings).toEqual([]); // 执行:假引擎不跑 worker → 等待窗口内未完成,返回 pending + recordId/taskId const exec = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: false, planId: dry.body.data.planId, }); expect(exec.status).toBe(200); const ex = exec.body.data.executed; expect(ex).toMatchObject({ pending: true, succeeded: [], failed: [], status: "running" }); expect(ex.taskId).toBeTruthy(); const recordId = ex.recordId as string; expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "verifyAndImport", input: { recordId } }); // 模拟上一次 attempt:第一个文件已复制到目标,删源前 worker 被 kill(账本停在 started) const first = await h.db .selectFrom("organization_file_result") .select(["ordinal", "source_path", "target_path"]) .where("organization_record_id", "=", recordId) .where("source_path", "=", join(h.lib, "Show", "Show.S01E01.mkv")) .executeTakeFirstOrThrow(); expect(first.target_path).toBe("tv/Show/Show.S01E01.mkv"); await mkdir(join(xlib, "tv", "Show"), { recursive: true }); await copyFile(first.source_path, join(xlib, first.target_path!)); await sql`insert into tasks_op_ledger (op_key, attempt_token, attempts, status) values (${`org:${recordId}:${first.ordinal}`}, ${randomUUID()}, 1, 'started')`.execute(h.db); const r = await executeRecord(h.module.deps().ao, recordId); expect(r.status).toBe("succeeded"); expect(await readFile(join(xlib, "tv", "Show", "Show.S01E01.mkv"), "utf8")).toBe("episode-one"); expect(await readFile(join(xlib, "tv", "Show", "Show.S01E02.mkv"), "utf8")).toBe("episode-two"); expect(await readFile(join(xlib, "tv", "Show", "extras", "bonus.nfo"), "utf8")).toBe(""); // 源目录(含空子目录)已删 expect(await exists(join(h.lib, "Show"))).toBe(false); const op = await h.db .selectFrom("asset_operation") .select("result") .where("operation_id", "=", `org:${recordId}:${first.ordinal}`) .executeTakeFirstOrThrow(); const res = op.result as { resumed?: boolean; steps?: { step: string; status: string }[] }; expect(res.resumed).toBe(true); expect(res.steps?.find((s) => s.step === "copy")?.status).toBe("skipped"); // 位置跟随到新库根;绑定写修订 const loc = await h.db .selectFrom("asset_location") .select(["library_root_id", "relative_path"]) .where("id", "=", a.locationId) .executeTakeFirstOrThrow(); expect(loc).toEqual({ library_root_id: x.id, relative_path: "tv/Show/Show.S01E01.mkv" }); const old = await h.db .selectFrom("path_binding") .select(["status", "superseded_by_id"]) .where("id", "=", bind) .executeTakeFirstOrThrow(); expect(old.status).toBe("superseded"); const nb = await h.db .selectFrom("path_binding") .select(["library_root_id", "relative_path", "status"]) .where("id", "=", old.superseded_by_id!) .executeTakeFirstOrThrow(); expect(nb).toEqual({ library_root_id: x.id, relative_path: "tv/Show", status: "active" }); const rec = await h.db .selectFrom("organization_record") .select(["action", "status", "file_succeeded", "media_entity_id"]) .where("id", "=", recordId) .executeTakeFirstOrThrow(); expect(rec).toEqual({ action: "move", status: "succeeded", file_succeeded: 3, media_entity_id: show }); // 重复 finalize 幂等 expect((await executeRecord(h.module.deps().ao, recordId)).status).toBe("succeeded"); // planId 回查整理记录(整理页预填) const q = await h.api( "GET", `/files/records?planIds=${dry.body.data.planId}&includeFiles=true`, undefined, READ_KEY, ); expect(q.status).toBe(200); expect(q.body.data.planRecords[dry.body.data.planId]).toBe(recordId); expect(q.body.data.items[0].files).toHaveLength(3); } finally { await sql`delete from settings where key = 'organize'`.execute(h.db); await rm(xlib, { recursive: true, force: true }); } }, 60_000, ); it("复制目录走同一计划:目标已存在的文件不覆盖、记失败(partial),其余照常", async () => { await mkdir(join(h.dl, "Pack", "sub"), { recursive: true }); await writeFile(join(h.dl, "Pack", "a.mkv"), "aaa"); await writeFile(join(h.dl, "Pack", "sub", "b.srt"), "bbb"); await mkdir(join(h.lib, "dest", "Pack"), { recursive: true }); await writeFile(join(h.lib, "dest", "Pack", "a.mkv"), "old"); const body = { items: [{ root: h.dlRoot, path: "Pack" }], target: { root: h.libRoot, path: "dest" } }; const dry = await h.api("POST", "/files/copy", { ...body, idempotencyKey: key(), dryRun: true }); expect(dry.status).toBe(200); expect(dry.body.data.pairs[0]).toMatchObject({ files: 2, conflict: true }); expect(dry.body.data.warnings.join()).toMatch(/已存在/); const exec = await h.api("POST", "/files/copy", { ...body, idempotencyKey: key(), dryRun: false, planId: dry.body.data.planId, }); const recordId = exec.body.data.executed.recordId; const r = await executeRecord(h.module.deps().ao, recordId); expect(r.status).toBe("partial"); expect(await readFile(join(h.lib, "dest", "Pack", "a.mkv"), "utf8")).toBe("old"); expect(await readFile(join(h.lib, "dest", "Pack", "sub", "b.srt"), "utf8")).toBe("bbb"); expect(await exists(join(h.dl, "Pack", "a.mkv"))).toBe(true); }); it("整理逐项覆写:季/集改变计划路径;目标目录与操作覆写分组;无法应用的才进 warnings;Slot 覆写在核验后生效", async () => { const series = await seedEntity(h.db, { kind: "series", title: "剧", year: 2020 }); const film = await seedEntity(h.db, { kind: "movie", title: "Film", year: 2019 }); await writeFile(join(h.dl, "weird-a.mkv"), "x".repeat(64)); await writeFile(join(h.dl, "weird-b.mkv"), "y".repeat(64)); await writeFile(join(h.dl, "Film.2019.1080p.mkv"), "z".repeat(64)); await mkdir(join(h.lib, "picked"), { recursive: true }); const slot = await h.db .insertInto("acquisition_slot") .values({ media_entity_id: film, name: "Film 主版本", status: "empty" } as never) .returning("id") .executeTakeFirstOrThrow(); const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: true, items: [ { root: h.dlRoot, path: "weird-a.mkv", mediaEntityId: series, season: 2, episodes: [3, 4] }, { root: h.dlRoot, path: "weird-b.mkv", mediaEntityId: series, season: 2, episodes: [5, 6] }, { root: h.dlRoot, path: "Film.2019.1080p.mkv", mediaEntityId: film, season: 1, slotId: slot.id, targetDir: { root: h.libRoot, path: "picked" }, operation: "copy", }, ], }); expect(dry.status).toBe(200); const groups = dry.body.data.groups; expect(groups).toHaveLength(2); const tv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === series); const mv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === film); const tvTargets = tv.files.map((f: { target: string | null }) => f.target); expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E03-E04 - 第 3-4 集.mkv"))).toBe(true); expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E05-E06 - 第 5-6 集.mkv"))).toBe(true); expect(tv.warnings).toEqual([]); expect(mv.warnings).toEqual([expect.stringMatching(/电影不用指定季集/)]); expect(mv).toMatchObject({ action: "copy", slotId: slot.id, targetDir: join(h.lib, "picked") }); expect(mv.files[0].target).toBe(join(h.lib, "picked", "Film (2019) - 1080p.mkv")); const exec = await h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: false, planIds: groups.map((g: { planId: string }) => g.planId), items: [ { root: h.dlRoot, path: "Film.2019.1080p.mkv", mediaEntityId: film, season: 1, slotId: slot.id, targetDir: { root: h.libRoot, path: "picked" }, operation: "copy", }, ], }); expect(exec.status).toBe(200); const g = exec.body.data.groups[0]; expect(g.error).toBeNull(); await executeRecord(h.module.deps().ao, g.recordId); expect(await readFile(join(h.lib, "picked", "Film (2019) - 1080p.mkv"), "utf8")).toBe("z".repeat(64)); const s = await h.db .selectFrom("acquisition_slot") .select(["status", "current_asset_id"]) .where("id", "=", slot.id) .executeTakeFirstOrThrow(); expect(s.current_asset_id).not.toBeNull(); expect(s.status).toBe("satisfied"); // 目标目录覆写不新建路径绑定 const binds = await h.db.selectFrom("path_binding").select("id").where("media_entity_id", "=", film).execute(); expect(binds).toEqual([]); }); it("删除/永久删除/还原都写整理记录(文件级结果)", async () => { await mkdir(join(h.lib, "gone", "in"), { recursive: true }); await writeFile(join(h.lib, "gone", "x.srt"), "1"); await writeFile(join(h.lib, "gone", "in", "y.srt"), "2"); const del = await h.api("POST", "/files/delete", { idempotencyKey: key(), items: [{ root: h.libRoot, path: "gone" }], dryRun: false, }); expect(del.status).toBe(200); const recId = del.body.data.recordId; expect(recId).toBeTruthy(); const rec = await h.db .selectFrom("organization_record") .select(["action", "status", "file_total", "file_succeeded"]) .where("id", "=", recId) .executeTakeFirstOrThrow(); expect(rec).toEqual({ action: "trash", status: "succeeded", file_total: 2, file_succeeded: 2 }); const files = await h.db .selectFrom("organization_file_result") .select(["source_path", "target_path", "mode"]) .where("organization_record_id", "=", recId) .orderBy("ordinal") .execute(); expect(files.map((f) => f.source_path).sort()).toEqual( [join(h.lib, "gone", "in", "y.srt"), join(h.lib, "gone", "x.srt")].sort(), ); expect(files.every((f) => f.mode === "trash" && f.target_path?.includes(".umt-trash"))).toBe(true); const rs = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [del.body.data.trashed[0].id], }); expect(rs.status).toBe(200); expect(rs.body.data.restored).toHaveLength(1); const rrec = await h.db .selectFrom("organization_record") .select(["action", "status", "target_library_root_id", "target_path"]) .where("id", "=", rs.body.data.recordId) .executeTakeFirstOrThrow(); expect(rrec).toEqual({ action: "restore", status: "succeeded", target_library_root_id: h.libraryRootId, target_path: "gone", }); const perm = await h.api( "POST", "/files/delete", { idempotencyKey: key(), items: [{ root: h.libRoot, path: "gone/x.srt" }], mode: "permanent", confirmName: "x.srt", dryRun: false, }, SESSION, ); expect(perm.status).toBe(200); const prec = await h.db .selectFrom("organization_record") .select(["action", "file_total"]) .where("id", "=", perm.body.data.recordId) .executeTakeFirstOrThrow(); expect(prec).toEqual({ action: "delete", file_total: 1 }); // 按 planId/记录 id 回查 const q = await h.api("GET", `/files/records?ids=${recId},${perm.body.data.recordId}`); expect(q.body.data.items.map((i: { action: string }) => i.action).sort()).toEqual(["delete", "trash"]); }); it("下载:仅管理员会话;只允许 nfo/字幕/图片且不超上限;路径守卫;流式 + 审计", async () => { await mkdir(join(h.lib, "dl-test"), { recursive: true }); await writeFile(join(h.lib, "dl-test", "海报.jpg"), "JPEGDATA"); await writeFile(join(h.lib, "dl-test", "movie.nfo"), ""); await writeFile(join(h.lib, "dl-test", "big.srt"), "x".repeat(200)); await writeFile(join(h.lib, "dl-test", "v.mkv"), "video"); await symlink(join(h.outside, "secret.txt"), join(h.lib, "dl-test", "leak.nfo")); const url = (path: string, extra = "") => `/files/download?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent(path)}${extra}`; const ok = await h.raw("GET", url("dl-test/海报.jpg", "&inline=true"), SESSION); expect(ok.status).toBe(200); expect(await ok.text()).toBe("JPEGDATA"); expect(ok.headers.get("content-type")).toBe("image/jpeg"); expect(ok.headers.get("content-disposition")).toMatch(/^inline; .*filename\*=UTF-8''%E6%B5%B7%E6%8A%A5\.jpg/); expect(ok.headers.get("x-content-type-options")).toBe("nosniff"); const nfo = await h.raw("GET", url("dl-test/movie.nfo", "&inline=true"), SESSION); expect(nfo.headers.get("content-disposition")).toMatch(/^attachment;/); expect(await nfo.text()).toBe(""); expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, READ_KEY)).status).toBe(403); expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, EXEC_KEY)).status).toBe(403); // 验收 07 S-2:admin scope 的 Key 与 DEV key 同样 403(注册表 auth:"session",中间件执行) expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, ADMIN_KEY)).status).toBe(403); expect((await h.api("GET", url("dl-test/movie.nfo"))).status).toBe(403); const video = await h.api("GET", url("dl-test/v.mkv"), undefined, SESSION); expect(video.status).toBe(403); const big = await h.api("GET", url("dl-test/big.srt"), undefined, SESSION); expect(big.status).toBe(413); expect(big.body.error.message).toMatch(/^too_large:/); const leak = await h.api("GET", url("dl-test/leak.nfo"), undefined, SESSION); expect(leak.status).toBe(400); expect(leak.body.error.message).toMatch(/^path_escape:/); expect((await h.api("GET", url("dl-test"), undefined, SESSION)).status).toBe(400); expect((await h.api("GET", url("dl-test/none.nfo"), undefined, SESSION)).status).toBe(404); expect((await h.api("GET", url("../outside/secret.txt"), undefined, SESSION)).status).toBeGreaterThanOrEqual(400); const audits = await h.db .selectFrom("audit_event") .select(["action"]) .where("action", "=", "files.download") .execute(); expect(audits.length).toBe(2); }); it("文件系统竞态/离线不 500:errno 映射;下载根目录整个不见 → 503", async () => { const e = (code: string) => Object.assign(new Error(code), { code }); expect(fsErrorToFiles(e("ENOTCONN"))?.status).toBe(503); expect(fsErrorToFiles(e("EIO"))?.reason).toBe("root_unavailable"); expect(fsErrorToFiles(e("ENOENT"))?.status).toBe(404); expect(fsErrorToFiles(e("EACCES"))?.status).toBe(403); expect(fsErrorToFiles(e("ELOOP"))?.status).toBe(400); expect(fsErrorToFiles(new Error("x"))).toBeNull(); const hidden = `${h.dl}.away`; await rm(hidden, { recursive: true, force: true }); await import("node:fs/promises").then((fs) => fs.rename(h.dl, hidden)); try { const list = await h.api("GET", `/files?root=${encodeURIComponent(h.dlRoot)}&path=`); expect(list.status).toBe(503); expect(list.body.error.message).toMatch(/^root_unavailable:/); const st = await h.api("GET", `/files/stat?root=${encodeURIComponent(h.dlRoot)}&path=x`); expect(st.status).toBe(503); } finally { await import("node:fs/promises").then((fs) => fs.rename(hidden, h.dl)); } }); });