import { randomUUID } from "node:crypto"; import { access, mkdir, rmdir, symlink, unlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { ensureTaskRow } from "../../src/modules/acquisition-organize/services/common.ts"; import { executeRecord, type FrozenPlan, planImport, } from "../../src/modules/acquisition-organize/services/organize.ts"; import { loadRecordsPublic } from "../../src/modules/acquisition-organize/services/organize-records.ts"; import { OrganizeRecoveryError, resolveOrganizeRecovery, retryOrganizeRecord, } from "../../src/modules/acquisition-organize/services/organize-retry.ts"; import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts"; describe.skipIf(!(await pgAvailable()))("历史整理恢复沿用已知事实", () => { let h: FmHarness; beforeAll(async () => { h = await createFmHarness("legacy_recovery", {}, { runTransfers: false }); }); afterAll(async () => { await h?.close(); }); const actor = { type: "user", id: "legacy-recovery" }; const record = (id: string) => h.db.selectFrom("organization_record").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); async function removeSnapshot(id: string) { const row = await record(id); const plan = { ...(row.plan as unknown as Partial) }; delete plan.request; await h.db .updateTable("organization_record") .set({ plan: JSON.stringify(plan) }) .where("id", "=", id) .execute(); } async function source(label: string) { const title = `${label} ${randomUUID().slice(0, 8)}`; const path = join(h.dl, `${title.replaceAll(" ", ".")}.2024.1080p.mkv`); await writeFile(path, title.padEnd(128, "x")); return { title, path }; } it("旧任务保存完整请求时恢复Move及一次性设置,完成后幂等重放不读取已移动来源", async () => { const file = await source("Legacy Task"); const targetDir = join(h.lib, "task-target"); await mkdir(targetDir); const input = { sourcePaths: [file.path], action: "move" as const, targetDir, namingMode: "keep" as const, scrapeAfterImport: false, minSizeBytes: 10, onExisting: "conflict" as const, keepBoth: true, }; const taskId = await ensureTaskRow(h.db, { kind: "verifyAndImport", idempotencyKey: key(), input }); const original = await planImport(h.module.deps().ao, { ...input, taskId, trigger: "manual", actorType: "user" }); expect(original.reason).toBe("identity_unknown"); await h.db.updateTable("organization_record").set({ plan: "{}" }).where("id", "=", original.recordId!).execute(); const resolved = await resolveOrganizeRecovery(h.db, await record(original.recordId!)); expect(resolved.recovery).toMatchObject({ ready: true, source: "task", missing: [], request: input }); const entity = await seedEntity(h.db, { kind: "movie", title: file.title, year: 2024 }); const options = { mediaEntityId: entity, idempotencyKey: `legacy:${original.recordId}` }; const next = await retryOrganizeRecord(h.module.deps().ao, original.recordId!, actor, "test", options); expect((await record(next.recordId!)).plan).toMatchObject({ request: { ...input, mediaEntityId: entity } }); expect((await executeRecord(h.module.deps().ao, next.recordId!)).status).toBe("succeeded"); await expect(access(file.path)).rejects.toThrow(); expect(await retryOrganizeRecord(h.module.deps().ao, original.recordId!, actor, "test", options)).toMatchObject({ recordId: next.recordId, status: "succeeded", }); }); it("完整冻结计划缺原设置时预填事实,向导确认后保留原选项和关联,不从头选择来源", async () => { const file = await source("Legacy Plan"); const entity = await seedEntity(h.db, { kind: "movie", title: file.title, year: 2024 }); const targetDir = join(h.lib, "plan-target"); await mkdir(targetDir); const original = await planImport(h.module.deps().ao, { sourcePaths: [file.path], mediaEntityId: entity, action: "copy", targetDir, namingMode: "keep", scrapeAfterImport: false, minSizeBytes: 0, trigger: "manual", actorType: "user", }); await removeSnapshot(original.recordId!); await h.db .updateTable("organization_record") .set({ status: "failed" }) .where("id", "=", original.recordId!) .execute(); await h.db .deleteFrom("organization_file_result") .where("organization_record_id", "=", original.recordId!) .execute(); const resolved = await resolveOrganizeRecovery(h.db, await record(original.recordId!)); expect(resolved.recovery).toMatchObject({ ready: false, source: "plan", missing: ["options"], request: { sourcePaths: [file.path], mediaEntityId: entity, targetDir, namingMode: "keep", scrapeAfterImport: false, }, }); await expect(retryOrganizeRecord(h.module.deps().ao, original.recordId!, actor, "test")).rejects.toBeInstanceOf( OrganizeRecoveryError, ); const refs = await h.api("GET", `/files/record-sources?recordId=${original.recordId}`); expect(refs.body.data.items).toEqual([{ root: h.dlRoot, path: file.path.slice(h.dl.length + 1) }]); expect(refs.body.data.targetDir).toEqual({ root: h.libRoot, path: "plan-target" }); const row = await record(original.recordId!); expect((await loadRecordsPublic(h.db, [row], false))[0]).not.toHaveProperty("recovery"); expect((await loadRecordsPublic(h.db, [row], true))[0]!.recovery).toMatchObject({ source: "plan", ready: false }); const payload = { recoveryRecordId: original.recordId, action: "copy", onExisting: "conflict", minSizeBytes: 0, items: refs.body.data.items.map((item: { root: string; path: string }) => ({ ...item, mediaEntityId: entity, targetDir: refs.body.data.targetDir, })), }; const dry = await h.api("POST", "/files/organize", { ...payload, idempotencyKey: key(), dryRun: true }); expect(dry.body.data.groups[0].error).toBeNull(); const exec = await h.api("POST", "/files/organize", { ...payload, idempotencyKey: key(), dryRun: false, planIds: dry.body.data.groups.map((g: { planId: string }) => g.planId), }); expect(exec.body.data.groups[0].error).toBeNull(); const next = await record(exec.body.data.recordIds[0]); expect(next.plan).toMatchObject({ retryOfRecordId: original.recordId, request: { targetDir, namingMode: "keep", scrapeAfterImport: false }, }); }); it("旧向导任务仅有基础参数不能冒充完整请求,未知来源明确缺项", async () => { const file = await source("Legacy Basic"); const input = { sourcePaths: [file.path], action: "copy", planId: randomUUID() }; const taskId = await ensureTaskRow(h.db, { kind: "verifyAndImport", idempotencyKey: key(), input }); const original = await planImport(h.module.deps().ao, { sourcePaths: [file.path], taskId, trigger: "manual", actorType: "user", }); await h.db .updateTable("organization_record") .set({ plan: "{}", source_path: null }) .where("id", "=", original.recordId!) .execute(); expect((await resolveOrganizeRecovery(h.db, await record(original.recordId!))).recovery).toMatchObject({ ready: false, missing: ["options"], request: { sourcePaths: [file.path] }, }); await h.db.deleteFrom("decision").where("subject_id", "=", original.recordId!).execute(); await h.db.updateTable("organization_record").set({ task_id: null }).where("id", "=", original.recordId!).execute(); expect((await resolveOrganizeRecovery(h.db, await record(original.recordId!))).recovery.missing).toEqual([ "options", "sourcePaths", ]); }); it("恢复向导拒绝非原文件,历史来源后来变为越界符号链接时不预填可操作引用", async () => { const file = await source("Legacy Guard"); const entity = await seedEntity(h.db, { kind: "movie", title: file.title, year: 2024 }); const original = await planImport(h.module.deps().ao, { sourcePaths: [file.path], mediaEntityId: entity, action: "copy", minSizeBytes: 0, trigger: "manual", actorType: "user", }); const other = await source("Unrelated"); const denied = await h.api("POST", "/files/organize", { recoveryRecordId: original.recordId, action: "copy", idempotencyKey: key(), dryRun: true, items: [{ root: h.dlRoot, path: other.path.slice(h.dl.length + 1), mediaEntityId: entity }], }); expect(denied.status).toBe(400); const escaped = join(h.outside, "outside.mkv"); await writeFile(escaped, "outside"); await unlink(file.path); await symlink(escaped, file.path); const refs = await h.api("GET", `/files/record-sources?recordId=${original.recordId}`); expect(refs.body.data.items).toEqual([]); expect(refs.body.data.unresolved).toEqual([file.path]); }); it("失效的原目标明确缺项,确认自动目标不能继续沿用失效目录", async () => { const file = await source("Lost Target"); const entity = await seedEntity(h.db, { kind: "movie", title: file.title, year: 2024 }); const targetDir = join(h.lib, `lost-${randomUUID()}`); await mkdir(targetDir); const original = await planImport(h.module.deps().ao, { sourcePaths: [file.path], mediaEntityId: entity, targetDir, action: "copy", minSizeBytes: 0, trigger: "manual", actorType: "user", }); await rmdir(targetDir); const refs = await h.api("GET", `/files/record-sources?recordId=${original.recordId}`); expect(refs.body.data.targetDir).toBeUndefined(); expect(refs.body.data.recovery).toMatchObject({ ready: false, missing: ["options"] }); const body = { recoveryRecordId: original.recordId, action: "copy", minSizeBytes: 0, dryRun: true, items: refs.body.data.items.map((x: { root: string; path: string }) => ({ ...x, mediaEntityId: entity })), }; const denied = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key() }); expect(denied.body.data.groups[0].error).toBeNull(); const accepted = await h.api("POST", "/files/organize", { ...body, targetLibraryRootId: h.libraryRootId, idempotencyKey: key(), }); expect(accepted.body.data.groups[0].error).toBeNull(); }); it("部分移动保留成功目标上的原季集覆写,换作品清除旧归属参数", async () => { const title = `Partial ${randomUUID().slice(0, 8)}`; const entity = await seedEntity(h.db, { kind: "series", title, year: 2024 }); const folder = join(h.dl, title.replaceAll(" ", ".")); await mkdir(folder); const paths = [1, 2].map((n) => join(folder, `${title.replaceAll(" ", ".")}.S01E0${n}.mkv`)); for (const path of paths) await writeFile(path, "video".padEnd(128, "x")); const original = await planImport(h.module.deps().ao, { sourcePaths: paths, mediaEntityId: entity, action: "move", minSizeBytes: 0, keepBoth: true, scrapeAfterImport: false, fileOverrides: Object.fromEntries( paths.map((p, i) => [p, { season: 1, episodes: { start: i + 7, end: i + 7 } }]), ), trigger: "manual", actorType: "user", }); const files = await h.db .selectFrom("organization_file_result") .selectAll() .where("organization_record_id", "=", original.recordId!) .where("role", "=", "main") .orderBy("ordinal") .execute(); expect(files).toHaveLength(2); const conflict = join(h.lib, files[1]!.target_path!); await mkdir(dirname(conflict), { recursive: true }); await writeFile(conflict, "different existing content"); expect((await executeRecord(h.module.deps().ao, original.recordId!)).status).toBe("partial"); const target = join(h.lib, files[0]!.target_path!); await expect(access(paths[0]!)).rejects.toThrow(); await access(target); const refs = await h.api("GET", `/files/record-sources?recordId=${original.recordId}`); expect(refs.body.data.items).toHaveLength(2); const targetDir = join(h.lib, `retry-${randomUUID()}`); await mkdir(targetDir); async function submit(mediaEntityId: string) { const body = { recoveryRecordId: original.recordId, action: "copy", minSizeBytes: 0, items: refs.body.data.items.map((x: { root: string; path: string }) => ({ ...x, mediaEntityId, targetDir: { root: h.libRoot, path: targetDir.slice(h.lib.length + 1) }, })), }; const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true }); expect(dry.body.data.groups[0].error).toBeNull(); const exec = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: false, planIds: dry.body.data.groups.map((g: { planId: string }) => g.planId), }); expect(exec.body.data.groups[0].error).toBeNull(); return (await record(exec.body.data.recordIds[0])).plan as unknown as FrozenPlan; } const previous = await record(original.recordId!); const previousPlan = previous.plan as unknown as FrozenPlan; previousPlan.request = { ...previousPlan.request!, sourceConfigId: randomUUID(), episodeStructure: { groupId: null, layout: null }, }; await h.db .updateTable("organization_record") .set({ plan: JSON.stringify(previousPlan) }) .where("id", "=", previous.id) .execute(); const same = await submit(entity); expect(same.request).toMatchObject({ keepBoth: true, fileOverrides: { [target]: { season: 1, episodes: { start: 7, end: 7 } }, [paths[1]!]: { season: 1, episodes: { start: 8, end: 8 } }, }, }); expect(same.request).not.toHaveProperty("sourceConfigId"); expect(same.request).not.toHaveProperty("episodeStructure"); const row = await record(original.recordId!); const plan = row.plan as unknown as FrozenPlan; plan.request = { ...plan.request!, contentUnitId: randomUUID(), slotId: randomUUID(), episodeStructure: { groupId: null, layout: null }, importMode: "replace", replacementAssetIds: [randomUUID()], }; await h.db .updateTable("organization_record") .set({ plan: JSON.stringify(plan) }) .where("id", "=", row.id) .execute(); const other = await seedEntity(h.db, { kind: "series", title: `Changed ${randomUUID()}`, year: 2024 }); const changed = await submit(other); for (const field of [ "contentUnitId", "slotId", "episodeStructure", "fileOverrides", "importMode", "replacementAssetIds", ]) expect(changed.request).not.toHaveProperty(field); }); });