- 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]>
361 lines
12 KiB
TypeScript
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;
|
|
}
|