Files
UHD_Media_Tool/apps/server/test/acquisition-organize/harness.ts
T
rootandClaude Opus 5.5 b61335eaf6 test: 测试按行为命名、server 测试统一到 test/、PG 测试分组并行
- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀,
  w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名
- vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行);
  pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s
- CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争)
- ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 16:54:04 +08:00

361 lines
12 KiB
TypeScript

import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { DiskUsage } from "@umt/adapters";
import { createQbClient } from "@umt/adapters";
import { DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
import { type DB, migrateToLatest } from "@umt/db";
import type { TaskEngine } from "@umt/tasks";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import { ConfigSchema } from "../../src/config.ts";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { hashApiKey } from "../../src/http/auth.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { type AoOverrides, createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import type { AoDeps } from "../../src/modules/acquisition-organize/services/common.ts";
// 真实 PG(独立 schema)+ mock qB + 临时目录 的测试装配;引擎用记录入队的假实现,测试直接驱动服务函数。
export const DB_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
export const DEV_KEY = "dev-admin-ao-test-key";
/** 只读整理记录 / 可发起整理 的 API Key */
export const READ_KEY = "ao-organize-read-key";
export const EXEC_KEY = "ao-organize-exec-key";
const KEYS = [
{ key: READ_KEY, scopes: ["organize.read"] },
{ key: EXEC_KEY, scopes: ["organize.execute"] },
];
export async function pgAvailable() {
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
const c = new pg.Client({ connectionString: DB_URL, connectionTimeoutMillis: 2000 });
try {
await c.connect();
await c.end();
return true;
} catch {
return false;
}
}
export interface Enqueued {
task: string;
input: unknown;
key: string;
}
/** 记录入队、不执行的引擎(处理器由测试直接调服务函数) */
export function fakeEngine(): TaskEngine & { enqueued: Enqueued[] } {
const enqueued: Enqueued[] = [];
let n = 0;
return {
enqueued,
async enqueue(task, input, opts) {
enqueued.push({ task, input, key: opts.idempotencyKey });
n++;
return { runId: `run-${n}`, deduplicated: false };
},
async signal() {
return { delivered: 0 };
},
async cancel() {
return false;
},
async get() {
return null;
},
async list() {
return [];
},
async startWorker() {},
async stop() {},
};
}
/** 可调的磁盘用量:used = base + 目录下文件总大小(清理删文件后自然下降) */
export function fakeDisk(total: number) {
const state = { total, base: 0, watch: [] as string[], fail: false };
const du = (dir: string): number => {
let s = 0;
try {
for (const n of readdirSync(dir)) {
const p = join(dir, n);
const st = statSync(p);
s += st.isDirectory() ? du(p) : st.size;
}
} catch {
// 不存在
}
return s;
};
const fn = async (path: string): Promise<DiskUsage> => {
if (state.fail) return { status: "unknown", path, code: "EIO", message: "fake" };
const used = state.base + state.watch.reduce((a, d) => a + du(d), 0);
return {
status: "ok",
path,
totalBytes: state.total,
freeBytes: state.total - used,
availableBytes: state.total - used,
usedBytes: used,
usedRatio: used / state.total,
};
};
/** 把使用率设为 pct(按当前目录内容反推 base) */
const setPct = (pct: number) => {
const files = state.watch.reduce((a, d) => a + du(d), 0);
state.base = Math.round((pct / 100) * state.total) - files;
};
return { state, fn, setPct };
}
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应/事件 JSON
type Loose = any;
export interface Harness {
module: ReturnType<typeof createAcquisitionOrganizeModule>;
db: Kysely<DB>;
ctx: ServerContext;
d: AoDeps;
engine: ReturnType<typeof fakeEngine>;
app: ReturnType<typeof createApp>;
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
setSettings(key: string, value: Record<string, unknown>): Promise<void>;
outbox(type?: string): Promise<{ type: string; payload: Loose; idempotency_key: string | null }[]>;
close(): Promise<void>;
}
export async function createHarness(
name: string,
overrides: AoOverrides = {},
/** withSources:同时装配 sources-identity 模块(待处理中心路由等) */
opts: { withSources?: boolean } = {},
): Promise<Harness> {
const schema = `ao_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new pg.Client({ connectionString: DB_URL });
await admin.connect();
await admin.query(`create schema ${schema}`);
await admin.end();
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: DB_URL, max: 6, options: `-c search_path=${schema},public` }),
}),
});
await migrateToLatest(db, { migrationTableSchema: schema });
const logger = createLogger({ level: "silent" });
const engine = fakeEngine();
const config = ConfigSchema.parse({
DATABASE_URL: DB_URL,
SECRET_MASTER_KEY: "test-master-key-0123456789",
DEV_ADMIN_KEY: DEV_KEY,
});
const outbox = createOutboxPort();
const bus = new EventBus(50);
const ctx: ServerContext = {
role: "all",
db,
config,
logger,
events: bus,
engine,
outbox,
secrets: {
resolve: async () => "adminadmin",
create: async () => "x",
replace: async () => {},
remove: async () => {},
},
// 不缓存的设置读取(测试里改 settings 行立即生效)
settings: {
async all() {
const rows = await db.selectFrom("settings").select(["key", "value"]).execute();
const stored = new Map(rows.map((r) => [r.key, r.value]));
const out: Record<string, unknown> = {};
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
const v = stored.get(k);
// 整理最小体积缺省跟随端口注入的 minVideoBytes(测试用小文件)
const base =
k === "organize"
? { ...DEFAULT_SETTINGS.organize, minSizeBytes: overrides.options?.minVideoBytes ?? 100_000 }
: DEFAULT_SETTINGS[k];
out[k] = GlobalSettings.shape[k].parse({ ...base, ...((v as object) ?? {}) });
}
return out as GlobalSettings;
},
async get(k) {
return (await this.all())[k];
},
},
capabilities: createCapabilityRegistry({ logger }),
};
const module = createAcquisitionOrganizeModule({
downloader: async (inst) =>
createQbClient({
instanceId: inst.id,
baseUrl: inst.base_url,
username: "admin",
password: "adminadmin",
lookup: { attempts: 3, intervalMs: 50 },
}),
probe: async () => ({ status: "unknown", reason: "ffprobe_unavailable" }),
...overrides,
options: { backgroundLoops: false, minVideoBytes: 100_000, verifyHash: "sha256", ...(overrides.options ?? {}) },
});
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus,
role: "all",
version: "test",
db,
modules: opts.withSources ? [module, createSourcesIdentityModule()] : [module],
ctx,
apiKeyStore: {
async findByHash(hash) {
const k = KEYS.find((x) => hashApiKey(x.key) === hash);
return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null;
},
async touch() {},
},
});
const d = module.deps();
return {
module,
db,
ctx,
d,
engine,
app,
async api(method, path, body, key = DEV_KEY) {
const res = await app.request(`/api/v1${path}`, {
method,
headers: {
authorization: `Bearer ${key}`,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { status: res.status, body: await res.json() };
},
async setSettings(key, value) {
await sql`insert into settings (key, value) values (${key}, ${JSON.stringify(value)}::jsonb)
on conflict (key) do update set value = excluded.value`.execute(db);
},
async outbox(type) {
const r = await sql<{ type: string; payload: Loose; idempotency_key: string | null }>`
select type, payload, idempotency_key from outbox_event ${type ? sql`where type = ${type}` : sql``} order by at, id`.execute(
db,
);
return r.rows;
},
async close() {
await db.destroy();
const c = new pg.Client({ connectionString: DB_URL });
await c.connect();
await c.query(`drop schema if exists ${schema} cascade`);
await c.end();
},
};
}
// ── 种子数据 ──
export async function seedBase(
db: Kysely<DB>,
opts: { libraryPath: string; cachePath: string; qbUrl: string; mediaKind?: "movie" | "series" },
) {
const root = await db
.insertInto("library_root")
.values({
name: `lib-${Math.random().toString(36).slice(2, 6)}`,
media_kind: "mixed",
local_path: opts.libraryPath,
management_mode: "managed",
})
.returning("id")
.executeTakeFirstOrThrow();
const inst = await db
.insertInto("downloader_instance")
.values({
name: `qb-${Math.random().toString(36).slice(2, 6)}`,
base_url: opts.qbUrl,
username: "admin",
settings: JSON.stringify({ savePath: opts.cachePath }),
})
.returning("id")
.executeTakeFirstOrThrow();
const site = await db
.insertInto("site")
.values({
key: `site${Math.random().toString(36).slice(2, 6)}`,
name: "测试站",
adapter: "nexusphp",
base_url: "https://pt.example.test",
config: JSON.stringify({ hitAndRun: { minSeedingSec: 86_400 } }),
})
.returning("id")
.executeTakeFirstOrThrow();
return { rootId: root.id, instanceId: inst.id, siteId: site.id };
}
export async function seedEntity(db: Kysely<DB>, e: { kind: "movie" | "series"; title: string; year: number }) {
return (
await db
.insertInto("media_entity")
.values({ kind: e.kind, title: e.title, year: e.year, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
export async function seedRelease(
db: Kysely<DB>,
r: { siteId: string; entityId: string; title: string; size: number; hash: string; hr?: boolean | "unknown" },
) {
return (
await db
.insertInto("source_release")
.values({
site_id: r.siteId,
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
title: r.title,
size_bytes: r.size,
infohash_v1: r.hash,
media_entity_id: r.entityId,
resolution_status: "resolved",
raw: JSON.stringify({ _site: { hr: r.hr ?? false } }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
export async function seedSlot(
db: Kysely<DB>,
s: { entityId: string; name?: string; hard?: Record<string, unknown>; upgrade?: Record<string, unknown> },
) {
const sub = await db
.insertInto("subscription")
.values({ media_entity_id: s.entityId, media_kind: "movie", mode: "continuous" })
.returning("id")
.executeTakeFirstOrThrow();
return (
await db
.insertInto("acquisition_slot")
.values({
media_entity_id: s.entityId,
subscription_id: sub.id,
name: s.name ?? "main",
hard_conditions: JSON.stringify(s.hard ?? {}),
upgrade_threshold: JSON.stringify(s.upgrade ?? {}),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}