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 { createAgentModule } from "../../src/modules/agent/index.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 => { 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; db: Kysely; ctx: ServerContext; d: AoDeps; engine: ReturnType; app: ReturnType; api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>; setSettings(key: string, value: Record): Promise; outbox(type?: string): Promise<{ type: string; payload: Loose; idempotency_key: string | null }[]>; close(): Promise; } export async function createHarness( name: string, overrides: AoOverrides = {}, /** withSources:同时装配 sources-identity 模块(待处理中心路由等);withAgent:再装配 agent 模块(POST /acquisition-plans 等) */ opts: { withSources?: boolean; withAgent?: boolean } = {}, ): Promise { 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 = {}; 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: [ module, ...(opts.withSources || opts.withAgent ? [createSourcesIdentityModule()] : []), ...(opts.withAgent ? [createAgentModule()] : []), ], 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, 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, 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, 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, s: { entityId: string; name?: string; hard?: Record; upgrade?: Record }, ) { 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; }