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:
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { type AlistAuth, AlistClient } from "./client.ts";
|
||||||
|
|
||||||
|
// 真实 Alist / OpenList(环境变量开启,缺省跳过):
|
||||||
|
// UMT_ALIST_REAL_URL 地址,如 http://127.0.0.1:55244
|
||||||
|
// UMT_ALIST_REAL_TOKEN 永久令牌;或 UMT_ALIST_REAL_USER + UMT_ALIST_REAL_PASSWORD
|
||||||
|
// UMT_ALIST_REAL_ROOT 可写测试目录(会在里面建 umt-it-<随机>/,结束删除),如 /s1
|
||||||
|
// UMT_ALIST_REAL_ROOT2 可选:另一个挂载(驱动)下的可写目录,测跨驱动复制 / 移动(异步任务)
|
||||||
|
// 跑法:docs/runbook/storage-alist.md「真机验收」。
|
||||||
|
|
||||||
|
const URL_ = Deno.env.get("UMT_ALIST_REAL_URL");
|
||||||
|
const ROOT = Deno.env.get("UMT_ALIST_REAL_ROOT");
|
||||||
|
const ROOT2 = Deno.env.get("UMT_ALIST_REAL_ROOT2");
|
||||||
|
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;
|
||||||
|
const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
|
||||||
|
|
||||||
|
describe.skipIf(!enabled)("真实 Alist / OpenList:本地 → Alist → 本地往返", () => {
|
||||||
|
let c: AlistClient;
|
||||||
|
let dir: string;
|
||||||
|
let remote: string;
|
||||||
|
let remote2: string | null = null;
|
||||||
|
const data = randomBytes(3 * 1024 * 1024 + 123);
|
||||||
|
beforeAll(async () => {
|
||||||
|
c = new AlistClient({ baseUrl: URL_!, auth: auth!, qps: 10, concurrency: 4 });
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "umt-alist-real-"));
|
||||||
|
remote = `${ROOT!.replace(/\/+$/, "")}/umt-it-${randomBytes(3).toString("hex")}`;
|
||||||
|
if (ROOT2) remote2 = `${ROOT2.replace(/\/+$/, "")}/umt-it-${randomBytes(3).toString("hex")}`;
|
||||||
|
await writeFile(join(dir, "source.bin"), data);
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await c?.removePath(remote).catch(() => undefined);
|
||||||
|
if (remote2) await c?.removePath(remote2).catch(() => undefined);
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("连接:版本 / 用户 / 根目录", async () => {
|
||||||
|
const v = await c.version();
|
||||||
|
const me = await c.me();
|
||||||
|
console.log(`[real] Alist 版本 ${v},用户 ${me.username}`);
|
||||||
|
expect((await c.get(ROOT!)).isDir).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("上传(临时名 → 改名、带秒传头)→ 同名冲突不覆盖", async () => {
|
||||||
|
let last = 0;
|
||||||
|
const r = await c.upload(join(dir, "source.bin"), `${remote}/电影 A/source.bin`, { onProgress: (d) => (last = d) });
|
||||||
|
console.log(`[real] 上传 ${r.bytes} 字节,sha256 ${r.hashes.sha256.slice(0, 12)}…,进度回调到 ${last}`);
|
||||||
|
expect(r.obj.size).toBe(data.length);
|
||||||
|
expect(last).toBe(data.length);
|
||||||
|
const names = (await c.list(`${remote}/电影 A`, { refresh: true })).map((o) => o.name);
|
||||||
|
expect(names).toEqual(["source.bin"]);
|
||||||
|
await expect(c.upload(join(dir, "source.bin"), `${remote}/电影 A/source.bin`)).rejects.toMatchObject({
|
||||||
|
kind: "conflict",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("同驱动复制(改名走暂存目录)/ 移动 / 改名", async () => {
|
||||||
|
const cp = await c.copyFile(`${remote}/电影 A/source.bin`, `${remote}/library/Movie (2020).bin`);
|
||||||
|
expect(cp.size).toBe(data.length);
|
||||||
|
const mv = await c.moveFile(`${remote}/library/Movie (2020).bin`, `${remote}/library2/Moved.bin`);
|
||||||
|
expect(mv.path).toBe(`${remote}/library2/Moved.bin`);
|
||||||
|
expect(await c.stat(`${remote}/library/Movie (2020).bin`)).toBeNull();
|
||||||
|
const lib = (await c.list(`${remote}/library`, { refresh: true })).map((o) => o.name);
|
||||||
|
expect(lib.filter((n) => n.startsWith(".umt-stage-"))).toEqual([]);
|
||||||
|
console.log(`[real] Alist 内复制、移动完成,暂存目录已清理`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(!ROOT2)("跨驱动复制 / 移动:轮询 Alist 任务到完成", async () => {
|
||||||
|
const cp = await c.copyFile(`${remote}/library2/Moved.bin`, `${remote2}/cross/Copied.bin`);
|
||||||
|
expect(cp.size).toBe(data.length);
|
||||||
|
const mv = await c.moveFile(`${remote2}/cross/Copied.bin`, `${remote}/back/Back.bin`);
|
||||||
|
expect(mv.size).toBe(data.length);
|
||||||
|
console.log(`[real] 跨驱动复制、移动完成(任务轮询)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("下载回本地:内容与源一致;本地已存在冲突", async () => {
|
||||||
|
const out = join(dir, "back.bin");
|
||||||
|
const r = await c.download(`${remote}/library2/Moved.bin`, out);
|
||||||
|
const got = new Uint8Array(await readFile(out));
|
||||||
|
console.log(`[real] 下载 ${r.bytes} 字节,与源 sha256 一致:${sha(got) === sha(data)}`);
|
||||||
|
expect(sha(got)).toBe(sha(data));
|
||||||
|
await expect(c.download(`${remote}/library2/Moved.bin`, out)).rejects.toMatchObject({ kind: "conflict" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不存在 ≠ 失败;容量拿不到返回 null 不报错", async () => {
|
||||||
|
await expect(c.get(`${remote}/nope.bin`)).rejects.toMatchObject({ kind: "not_found" });
|
||||||
|
const u = await c.usage(ROOT!);
|
||||||
|
console.log(`[real] 容量:${u ? `${u.free}/${u.total}` : "未知"}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -596,11 +596,17 @@ export class AlistClient {
|
|||||||
async waitExists(path: string, opts: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<AlistObj> {
|
async waitExists(path: string, opts: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<AlistObj> {
|
||||||
const deadline = this.o.now() + (opts.timeoutMs ?? 60_000);
|
const deadline = this.o.now() + (opts.timeoutMs ?? 60_000);
|
||||||
let delay = 500;
|
let delay = 500;
|
||||||
|
const p = normAlistPath(path);
|
||||||
for (;;) {
|
for (;;) {
|
||||||
opts.signal?.throwIfAborted();
|
opts.signal?.throwIfAborted();
|
||||||
const o = await this.stat(path, { refresh: true });
|
const o = await this.stat(p, { refresh: true });
|
||||||
if (o) return o;
|
if (o) return o;
|
||||||
if (this.o.now() >= deadline) throw new AlistError("not_found", `写入后目标仍不存在:${normAlistPath(path)}`);
|
// OpenList 的 get 不认 refresh:刷新父目录列表让缓存失效,再按列表结果判断
|
||||||
|
const hit = (await this.list(posix.dirname(p), { refresh: true }).catch(() => [])).find(
|
||||||
|
(x) => x.name === posix.basename(p),
|
||||||
|
);
|
||||||
|
if (hit) return hit;
|
||||||
|
if (this.o.now() >= deadline) throw new AlistError("not_found", `写入后目标仍不存在:${p}`);
|
||||||
await this.o.sleep(delay);
|
await this.o.sleep(delay);
|
||||||
delay = Math.min(delay * 2, 10_000);
|
delay = Math.min(delay * 2, 10_000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Alist / OpenList 往返验收脚本(docs/runbook/storage-alist.md「真机验收」):对任意地址 + 令牌(或账号密码)
|
||||||
|
// 用本项目的适配器跑一遍:连接 → 建目录 → 上传(秒传头、临时名改名、进度)→ 同名冲突不覆盖 → 同驱动复制(改名)
|
||||||
|
// → 移动 → 可选跨驱动复制 / 移动(Alist 异步任务)→ 下载回本地比对 sha256 → 不存在 ≠ 失败 → 清理。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// deno run -A scripts/alist-e2e.ts --url http://127.0.0.1:5244 --token <令牌> --root /s1 [--root2 /s2] [--size-mb 8]
|
||||||
|
// deno run -A scripts/alist-e2e.ts --url ... --user admin --password <密码> --root /s1
|
||||||
|
// 也可用环境变量 UMT_ALIST_REAL_URL / _TOKEN / _USER / _PASSWORD / _ROOT / _ROOT2。退出码 0 = 全部通过。
|
||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import { type AlistAuth, AlistClient, AlistError, redactAlistText } from "../packages/adapters/src/alist/index.ts";
|
||||||
|
|
||||||
|
const { values: a } = parseArgs({
|
||||||
|
args: Deno.args,
|
||||||
|
options: {
|
||||||
|
url: { type: "string" },
|
||||||
|
token: { type: "string" },
|
||||||
|
user: { type: "string" },
|
||||||
|
password: { type: "string" },
|
||||||
|
root: { type: "string" },
|
||||||
|
root2: { type: "string" },
|
||||||
|
"size-mb": { type: "string" },
|
||||||
|
keep: { type: "boolean" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const env = (k: string) => Deno.env.get(`UMT_ALIST_REAL_${k}`);
|
||||||
|
const url = a.url ?? env("URL");
|
||||||
|
const token = a.token ?? env("TOKEN");
|
||||||
|
const user = a.user ?? env("USER");
|
||||||
|
const password = a.password ?? env("PASSWORD");
|
||||||
|
const root = (a.root ?? env("ROOT") ?? "").replace(/\/+$/, "");
|
||||||
|
const root2 = (a.root2 ?? env("ROOT2") ?? "").replace(/\/+$/, "");
|
||||||
|
if (!url || !root || (!token && !user)) {
|
||||||
|
console.error(
|
||||||
|
"用法:--url <地址> (--token <令牌> | --user <用户> --password <密码>) --root <可写目录> [--root2 <另一驱动目录>]",
|
||||||
|
);
|
||||||
|
Deno.exit(2);
|
||||||
|
}
|
||||||
|
const auth: AlistAuth = token
|
||||||
|
? { kind: "token", token }
|
||||||
|
: { kind: "password", username: user!, password: password ?? "" };
|
||||||
|
const secrets = [token, password];
|
||||||
|
const c = new AlistClient({ baseUrl: url, auth });
|
||||||
|
const tag = `umt-e2e-${randomBytes(3).toString("hex")}`;
|
||||||
|
const base = `${root}/${tag}`;
|
||||||
|
const base2 = root2 ? `${root2}/${tag}` : null;
|
||||||
|
const size = Math.max(1, Number(a["size-mb"] ?? 8)) * 1024 * 1024 + 7;
|
||||||
|
const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
async function step(name: string, fn: () => Promise<string | undefined | void>) {
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const note = await fn();
|
||||||
|
console.log(`✓ ${name}(${Math.round(performance.now() - t0)} ms)${note ? `:${note}` : ""}`);
|
||||||
|
} catch (e) {
|
||||||
|
failed++;
|
||||||
|
const msg = e instanceof AlistError ? `${e.kind}:${e.message}` : String((e as Error)?.message ?? e);
|
||||||
|
console.log(`✗ ${name}(${Math.round(performance.now() - t0)} ms):${redactAlistText(msg, secrets)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expectKind = async (p: Promise<unknown>, kind: string) => {
|
||||||
|
try {
|
||||||
|
await p;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AlistError && e.kind === kind) return;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw new Error(`应当失败(${kind}),却成功了`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "umt-alist-e2e-"));
|
||||||
|
const data = randomBytes(size);
|
||||||
|
await writeFile(join(dir, "source.bin"), data);
|
||||||
|
console.log(
|
||||||
|
`Alist 往返验收:${url} 测试目录 ${base}${base2 ? `、${base2}` : ""} 文件 ${(size / 1048576).toFixed(1)} MiB`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await step("连接(版本、用户、根目录)", async () => {
|
||||||
|
const v = await c.version();
|
||||||
|
const me = await c.me();
|
||||||
|
if (!(await c.get(root)).isDir) throw new Error(`${root} 不是目录`);
|
||||||
|
return `${v ?? "版本未知"},用户 ${me.username}`;
|
||||||
|
});
|
||||||
|
await step("建目录", async () => {
|
||||||
|
await c.mkdir(`${base}/电影 A`);
|
||||||
|
});
|
||||||
|
await step("上传(秒传头、临时名 → 改名、进度回调)", async () => {
|
||||||
|
let last = 0;
|
||||||
|
const r = await c.upload(join(dir, "source.bin"), `${base}/电影 A/source.bin`, { onProgress: (d) => (last = d) });
|
||||||
|
if (last !== size) throw new Error(`进度只到 ${last}/${size}`);
|
||||||
|
return `${r.bytes} 字节,sha256 ${r.hashes.sha256.slice(0, 12)}…`;
|
||||||
|
});
|
||||||
|
await step("同名不覆盖(上传 / 复制都报冲突)", async () => {
|
||||||
|
await expectKind(c.upload(join(dir, "source.bin"), `${base}/电影 A/source.bin`), "conflict");
|
||||||
|
await c.mkdir(`${base}/dup`);
|
||||||
|
await c.copyFile(`${base}/电影 A/source.bin`, `${base}/dup/source.bin`);
|
||||||
|
await expectKind(c.copyFile(`${base}/电影 A/source.bin`, `${base}/dup/source.bin`), "conflict");
|
||||||
|
});
|
||||||
|
await step("同驱动复制并改名(暂存目录)", async () => {
|
||||||
|
const o = await c.copyFile(`${base}/电影 A/source.bin`, `${base}/library/Movie (2020).bin`);
|
||||||
|
const left = (await c.list(`${base}/library`, { refresh: true })).filter((x) => x.name.startsWith(".umt-stage-"));
|
||||||
|
if (left.length) throw new Error("暂存目录没清理");
|
||||||
|
return `${o.size} 字节`;
|
||||||
|
});
|
||||||
|
await step("同驱动移动", async () => {
|
||||||
|
await c.moveFile(`${base}/library/Movie (2020).bin`, `${base}/moved/Moved.bin`);
|
||||||
|
if (await c.stat(`${base}/library/Movie (2020).bin`, { refresh: true })) throw new Error("源还在");
|
||||||
|
});
|
||||||
|
if (base2) {
|
||||||
|
await step("跨驱动复制 / 移动(等 Alist 任务完成)", async () => {
|
||||||
|
await c.copyFile(`${base}/moved/Moved.bin`, `${base2}/cross/Copied.bin`);
|
||||||
|
await c.moveFile(`${base2}/cross/Copied.bin`, `${base}/back/Back.bin`);
|
||||||
|
return "完成";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await step("下载回本地并比对", async () => {
|
||||||
|
const out = join(dir, "back.bin");
|
||||||
|
const r = await c.download(`${base}/moved/Moved.bin`, out);
|
||||||
|
const ok = sha(new Uint8Array(await readFile(out))) === sha(data);
|
||||||
|
if (!ok) throw new Error("内容与源不一致");
|
||||||
|
return `${r.bytes} 字节,sha256 一致`;
|
||||||
|
});
|
||||||
|
await step("不存在 ≠ 失败;容量拿不到不报错", async () => {
|
||||||
|
await expectKind(c.get(`${base}/nope.bin`), "not_found");
|
||||||
|
const u = await c.usage(root);
|
||||||
|
return u ? `剩余 ${(u.free / 1073741824).toFixed(1)} GiB / ${(u.total / 1073741824).toFixed(1)} GiB` : "容量未知";
|
||||||
|
});
|
||||||
|
if (!a.keep) {
|
||||||
|
await step("清理测试目录", async () => {
|
||||||
|
await c.removePath(base);
|
||||||
|
if (base2) await c.removePath(base2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
console.log(failed ? `\n${failed} 步失败` : "\n全部通过");
|
||||||
|
Deno.exit(failed ? 1 : 0);
|
||||||
Reference in new issue
Block a user