Files
UHD_Media_Tool/apps/server/test/acquisition-organize/harness.ts
T
rootandClaude Opus 5.5 c9e8120fc6 test: server 测试共用建库与假引擎,sse-pg 归入串行组
- 新增 apps/server/test/support/{pg,engine}.ts:测试库地址、可用性探测、schema 咨询锁、
  建 schema + 加锁迁移 + 删除、可选去重的假引擎;6 份 harness 改用它(acquisition-organize 与
  file-manager 原先迁移不加锁,并行下有冲突隐患,一并修正)
- sse-pg 起子进程,并行下子进程启动超时,归入 serial
- 修正 b61335e 挪动 / 改名测试后遗留的 import 排序与格式(当时 lint 检查经管道被吞掉退出码)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 17:40:43 +08:00

305 lines
10 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 } from "@umt/db";
import { type Kysely, sql } from "kysely";
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 { type AoOverrides, createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import type { AoDeps } from "../../src/modules/acquisition-organize/services/common.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 { fakeEngine } from "../support/engine.ts";
import { createTestSchema, PG_URL as DB_URL, uniqueSchema } from "../support/pg.ts";
// 真实 PG(独立 schema)+ mock qB + 临时目录 的测试装配;引擎用记录入队的假实现,测试直接驱动服务函数。
export type { Enqueued } from "../support/engine.ts";
export { fakeEngine } from "../support/engine.ts";
export { pgAvailable } from "../support/pg.ts";
export { DB_URL };
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"] },
];
/** 可调的磁盘用量: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 ts = await createTestSchema(uniqueSchema(`ao_test_${name}`));
const { db } = ts;
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 ts.drop();
},
};
}
// ── 种子数据 ──
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;
}