Files
UHD_Media_Tool/apps/server/test/file-manager/legacy-organize-recovery.test.ts
T

351 lines
15 KiB
TypeScript

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<FrozenPlan>) };
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);
});
});