feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download

- /files/move|copy go through planImport's new `transfer` variant (keep names, directory
  expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file
  ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then
  remove empty source dirs; shared plan store + plan_stale; asset_location follows per file,
  path_binding revised when a whole directory moved (finalizeTransfer); request waits up to
  FILES_TRANSFER_WAIT_SEC, else executed.pending
- /files/organize per-item overrides take effect: season/episodes (domain planOrganize
  overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied
  after verify); warnings only for inapplicable overrides
- delete/trash/restore write organization_record (action trash|delete|restore, file-level
  results; migration 0026)
- GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited)
- GET /files/records?ids|planIds for the organize page
- fs race errors map to 503/404/403/400 instead of 500
- runbook updated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:53:50 +08:00
1 parent 3641ab9573
commit 36aa99b8aa
25 files changed
+1766 -271

No files matched your search

+33 -2
View File
@@ -14,6 +14,7 @@ import { hashApiKey } from "../../src/http/auth.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts";
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
import { executeRecord, type FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createFileManagerModule, type FmOverrides } from "../../src/modules/file-manager/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
@@ -47,6 +48,7 @@ type Loose = any;
export interface FmHarness {
db: Kysely<DB>;
engine: ReturnType<typeof fakeEngine>;
module: ReturnType<typeof createFileManagerModule>;
events: { type: string; payload: Loose }[];
base: string;
lib: string;
@@ -57,13 +59,16 @@ export interface FmHarness {
libraryRootId: string;
instanceId: string;
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
/** 原始响应(非 JSON 接口,如 /files/download) */
raw(method: string, path: string, key?: string): Promise<Response>;
close(): Promise<void>;
}
export async function createFmHarness(
name: string,
overrides: FmOverrides = {},
opts: { catalog?: boolean } = {},
/** runTransfers(默认 true):假引擎收到 /files 移动/复制的 verifyAndImport 时就地执行(模拟 worker) */
opts: { catalog?: boolean; runTransfers?: boolean } = {},
): Promise<FmHarness> {
const schema = `fm_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new pg.Client({ connectionString: DB_URL });
@@ -144,8 +149,30 @@ export async function createFmHarness(
...overrides.ao,
options: { backgroundLoops: false, minVideoBytes: 10, verifyHash: "sha256", ...(overrides.ao?.options ?? {}) },
},
options: { backgroundLoops: false, dataDir: join(base, "data"), ...overrides.options },
options: { backgroundLoops: false, dataDir: join(base, "data"), transferWaitMs: 10_000, ...overrides.options },
});
if (opts.runTransfers !== false) {
const enqueue = engine.enqueue.bind(engine);
engine.enqueue = async (task, input, o) => {
const r = await enqueue(task, input, o);
const recordId = (input as { recordId?: string }).recordId;
if (task === "verifyAndImport" && recordId)
setTimeout(async () => {
try {
const rec = await db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", recordId)
.executeTakeFirst();
if ((rec?.plan as unknown as FrozenPlan | undefined)?.transfer)
await executeRecord(module.deps().ao, recordId);
} catch {
// 模拟 worker:失败留给测试断言(记录保持 running)
}
}, 0);
return r;
};
}
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
@@ -178,6 +205,7 @@ export async function createFmHarness(
return {
db,
engine,
module,
events,
base,
lib,
@@ -198,6 +226,9 @@ export async function createFmHarness(
});
return { status: res.status, body: await res.json() };
},
raw(method, path, key = DEV_KEY) {
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: { authorization: `Bearer ${key}` } }));
},
async close() {
await db.destroy();
const c = new pg.Client({ connectionString: DB_URL });
+414
View File
@@ -0,0 +1,414 @@
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 {
createFmHarness,
EXEC_KEY,
type FmHarness,
key,
pgAvailable,
READ_KEY,
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, 7] },
{
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.mkv"))).toBe(true);
// 非连续集号不应用(只按季覆写,文件名无集号 → 计划排除),其余覆写不进 warnings
expect(tv.warnings).toEqual([expect.stringMatching(/weird-b\.mkv: 集号 5,7 不连续/)]);
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,
});
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"));
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"));
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);
const video = await h.api("GET", url("dl-test/v.mkv"));
expect(video.status).toBe(403);
const big = await h.api("GET", url("dl-test/big.srt"));
expect(big.status).toBe(413);
expect(big.body.error.message).toMatch(/^too_large:/);
const leak = await h.api("GET", url("dl-test/leak.nfo"));
expect(leak.status).toBe(400);
expect(leak.body.error.message).toMatch(/^path_escape:/);
expect((await h.api("GET", url("dl-test"))).status).toBe(400);
expect((await h.api("GET", url("dl-test/none.nfo"))).status).toBe(404);
expect((await h.api("GET", url("../outside/secret.txt"))).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));
}
});
});
+4 -1
View File
@@ -210,6 +210,8 @@ describe.skipIf(skip)("files 写操作", () => {
size: 8,
conflict: false,
registered: false,
files: 1,
crossDevice: false,
},
]);
const planId = dry.body.data.planId;
@@ -271,7 +273,8 @@ describe.skipIf(skip)("files 写操作", () => {
.select(["action", "status"])
.where("id", "=", exec.body.data.executed.recordId)
.executeTakeFirstOrThrow();
expect(rec).toEqual({ action: "relocate", status: "succeeded" });
// W5-A2:搬运走 planImport → verifyAndImport,记录 action 与整理一致(move/copy)
expect(rec).toEqual({ action: "move", status: "succeeded" });
});
it("TOCTOU:预览后把源换成指向根外的符号链接,执行被拒绝且根外文件不动", async () => {