- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
425 lines
20 KiB
TypeScript
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, 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 - 第 3-4 集.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,
|
|
},
|
|
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));
|
|
}
|
|
});
|
|
});
|