test(storage): 真实 OpenList 往返测试与 alist-e2e 验收脚本

- packages/adapters alist.real.test.ts:上传 / 冲突 / 同驱动与跨驱动复制移动 / 下载比对(环境变量开启)
- apps/server alist-organize.real.test.ts:本地 → OpenList(整理复制)→ 本地(移动)→ OpenList 内整理
- scripts/alist-e2e.ts:对任意地址 + 令牌或账号密码跑往返,退出码表示是否全部通过
- 写后目标暂不可见时刷新父目录列表(OpenList 的 get 不认 refresh)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-07 00:01:27 +08:00
1 parent cf364435eb
commit fed2202e74
4 files changed
+408 -2

No files matched your search

@@ -0,0 +1,158 @@
import { randomBytes } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { type AlistAuth, AlistClient } from "@umt/adapters";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { overrideStorage } from "../../src/storage/registry.ts";
import { toVPath } from "../../src/storage/vpath.ts";
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "../file-manager/harness.ts";
// 真实 OpenList 上跑一次整理往返(环境变量开启,缺省跳过;变量同 packages/adapters/src/alist/alist.real.test.ts):
// 本地下载目录 → 整理(复制)到 OpenList 上的媒体库 → 从 OpenList 整理(移动)回本地媒体库 → OpenList 内移动。
const URL_ = Deno.env.get("UMT_ALIST_REAL_URL");
const ROOT = Deno.env.get("UMT_ALIST_REAL_ROOT");
const auth: AlistAuth | null = Deno.env.get("UMT_ALIST_REAL_TOKEN")
? { kind: "token", token: Deno.env.get("UMT_ALIST_REAL_TOKEN")! }
: Deno.env.get("UMT_ALIST_REAL_USER")
? {
kind: "password",
username: Deno.env.get("UMT_ALIST_REAL_USER")!,
password: Deno.env.get("UMT_ALIST_REAL_PASSWORD") ?? "",
}
: null;
const enabled = !!URL_ && !!ROOT && !!auth && (await pgAvailable());
describe.skipIf(!enabled)("真实 OpenList:整理往返 本地 → OpenList → 本地", () => {
let h: FmHarness;
let client: AlistClient;
let storageId: string;
let base: string;
let libId: string;
const body = `real-video-${randomBytes(64).toString("hex")}`;
beforeAll(async () => {
h = await createFmHarness("alist_real", {}, { runTransfers: false });
client = new AlistClient({ baseUrl: URL_!, auth: auth! });
base = `${ROOT!.replace(/\/+$/, "")}/umt-org-${randomBytes(3).toString("hex")}`;
await client.mkdir(`${base}/library`);
await client.mkdir(`${base}/incoming`);
const row = await h.db
.insertInto("storage_instance")
.values({ name: "真实 OpenList", base_url: URL_!, root_path: base })
.returning("id")
.executeTakeFirstOrThrow();
storageId = row.id;
overrideStorage(storageId, { client, name: "真实 OpenList", rootPath: base });
libId = (
await h.db
.insertInto("library_root")
.values({
name: "OpenList 媒体库",
media_kind: "mixed",
local_path: toVPath(storageId, `${base}/library`),
management_mode: "managed",
sentinel_file: null,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}, 60_000);
afterAll(async () => {
await client?.removePath(base).catch(() => undefined);
await h?.close();
});
async function organize(items: unknown[], action: "copy" | "move", targetLibraryRootId: string) {
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action,
targetLibraryRootId,
dryRun: true,
});
expect(dry.body.data.groups[0].error, JSON.stringify(dry.body)).toBeNull();
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action,
targetLibraryRootId,
dryRun: false,
planIds: [dry.body.data.groups[0].planId],
});
const recordId = exec.body.data.recordIds[0] as string;
await executeRecord(h.module.deps().ao, recordId);
const rec = await h.db
.selectFrom("organization_record")
.select(["status"])
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
const main = await h.db
.selectFrom("organization_file_result")
.select(["target_path", "checksum", "status"])
.where("organization_record_id", "=", recordId)
.where("role", "=", "main")
.executeTakeFirstOrThrow();
return { status: rec.status, main };
}
it("本地 → OpenList(复制)→ 本地(移动)→ OpenList 内移动", async () => {
const film = await seedEntity(h.db, { kind: "movie", title: "Real Round Trip", year: 2024 });
const name = "Real.Round.Trip.2024.1080p.WEB-DL.mkv";
await writeFile(join(h.dl, name), body);
const up = await organize([{ root: h.dlRoot, path: name, mediaEntityId: film }], "copy", libId);
console.log(`[real] 本地 → OpenList 复制:${up.status},目标 ${up.main.target_path},${up.main.checksum}`);
expect(up.status).toBe("succeeded");
const remoteFile = `${base}/library/${up.main.target_path}`;
expect((await client.get(remoteFile, { refresh: true })).size).toBe(body.length);
// 从 OpenList 移回本地媒体库(文件管理「移动到」:下载成功后删远端源,已登记位置跟随到本地)
const rel = `library/${up.main.target_path}`;
const src = { root: `st:${storageId}`, path: rel };
const dryMv = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [src],
target: { root: h.libRoot, path: "" },
mode: "move",
dryRun: true,
});
expect(dryMv.status, JSON.stringify(dryMv.body)).toBe(200);
const mv = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [src],
target: { root: h.libRoot, path: "" },
mode: "move",
dryRun: false,
planId: dryMv.body.data.planId,
});
expect(mv.status, JSON.stringify(mv.body)).toBe(200);
const recordId = mv.body.data.executed.recordId as string;
await executeRecord(h.module.deps().ao, recordId);
const back = await h.db
.selectFrom("organization_record")
.select("status")
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
const localName = up.main.target_path!.split("/").pop()!;
console.log(`[real] OpenList → 本地 移动:${back.status},目标 ${localName}`);
expect(back.status).toBe("succeeded");
expect(await readFile(join(h.lib, localName), "utf8")).toBe(body);
expect(await client.stat(remoteFile, { refresh: true })).toBeNull();
// OpenList 内整理(同实例 API move):另一部作品放在 OpenList 的 incoming
const film2 = await seedEntity(h.db, { kind: "movie", title: "Real Inside Move", year: 2023 });
const name2 = "Real.Inside.Move.2023.1080p.WEB-DL.mkv";
await writeFile(join(h.dl, name2), body);
await client.upload(join(h.dl, name2), `${base}/incoming/${name2}`);
const inside = await organize(
[{ root: `st:${storageId}`, path: `incoming/${name2}`, mediaEntityId: film2 }],
"move",
libId,
);
console.log(`[real] OpenList 内移动:${inside.status},目标 ${inside.main.target_path}`);
expect(inside.status).toBe("succeeded");
expect(await client.stat(`${base}/incoming/${name2}`, { refresh: true })).toBeNull();
expect((await client.get(`${base}/library/${inside.main.target_path}`, { refresh: true })).size).toBe(body.length);
}, 120_000);
});