Files
UHD_Media_Tool/apps/server/test/file-manager/w5a2.test.ts
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

425 lines
20 KiB
TypeScript

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"), "<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("<nfo/>");
// 源目录(含空子目录)已删
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"), "<movie/>");
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("<movie/>");
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));
}
});
});