351 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|