Files
UHD_Media_Tool/apps/server/test/sources-identity/harness.ts
T
rootandClaude Opus 5.5 2b8fe667a5 fix(organize,identity,sites,pi): W10-pre backend fixes from v0.2 visual QA
- slot: new manual_verify event (attention -> verifying, with reason); a
  successful organize of a download whose slot is in attention goes back
  through verifying and lands by verify_passed on the actually imported
  assets; the download's organize_blocked decision is resolved with
  resolution {key: manual_organize | organize_retry, organizationRecordId} (U1)
- acquisition: plan purpose is upgrade only when the slot already has a
  current asset; a movie slot gap is a fill (U7)
- identity: entities created from TMDB details get metadata fields projected
  in the same transaction; TMDB aliases deduped by normalized text, site-title
  aliases skipped when the text already exists (U9)
- pi: auth-mode test of a custom Base URL no longer marks the upstream ok, and
  says to use the minimal completion to probe the endpoint (U2)
- sites: PUT /sites/{id}/credentials replaces same-kind secrets in place and
  names new secrets with site id + random suffix (secret.name is unique; was a
  500 on update); M-Team user data uses config/definition apiBaseUrl instead
  of deriving api.<domain> from the site URL (Invalid URL on IP/local bases)
- copy: setup error says 初始化口令; backup job points at umt backup
- tests: schema expected tables add site_user_data; migration round-trip
  timeout 60s; fake secrets honor label as name; vertical/resolve/pi/slot/
  site-center tests for the above
- docs: 09-visual-qa W10-pre status table (U3 root cause: outbox events are
  never mirrored to SSE)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 16:29:29 +08:00

281 lines
9.5 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { createTmdbClient } from "@umt/adapters";
import { DEFAULT_SETTINGS, type GlobalSettings, type SettingsKey } from "@umt/contracts";
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
import type { TaskEngine } from "@umt/tasks";
import { Kysely as K, PostgresDialect } from "kysely";
import pg from "pg";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { RuntimeOverrides } from "../../src/modules/sources-identity/runtime.ts";
import type { SecretStore, ServerContext, SettingsReader } from "../../src/modules/types.ts";
import { withSchemaLock } from "../auth-settings/harness.ts";
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
export const READ_KEY = "w2b-read-key";
export const EXEC_KEY = "w2b-exec-key";
// W2-B 集成测试夹具:独立 PG schema + 真实迁移 + fake 引擎/凭证 + 模块运行时。
export const PG_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
export async function pgAvailable(): Promise<boolean> {
const c = new pg.Client({ connectionString: PG_URL, connectionTimeoutMillis: 2000 });
try {
await c.connect();
await c.end();
return true;
} catch {
return false;
}
}
/** 内存凭证库(W2-A 的 SecretStore 实现前);记录明文以便断言"明文不入业务表" */
export function fakeSecrets(db: () => Kysely<DB>): SecretStore & { plain: Map<string, string> } {
const plain = new Map<string, string>();
return {
plain,
async resolve(ref) {
const v = plain.get(ref);
if (v === undefined) throw new Error(`unknown secret ${ref}`);
return v;
},
async create({ plaintext, purpose, label }) {
const id = randomUUID();
plain.set(id, plaintext);
// 业务表 secret_ref 有外键:落一行占位(密文不是真的);name 与真实实现一致取 label(secret.name 唯一)
await db()
.insertInto("secret")
.values({ id, name: label ?? `test-${id}`, purpose, ciphertext: Buffer.from("x"), nonce: Buffer.from("x") })
.execute();
return id;
},
async replace(ref, plaintext) {
plain.set(ref, plaintext);
},
async remove(ref) {
plain.delete(ref);
},
};
}
/** 每次读库的设置读取(不缓存,测试里改 settings 表立即生效) */
export function plainSettings(db: () => Kysely<DB>): SettingsReader {
const all = async (): Promise<GlobalSettings> => {
const rows = await db().selectFrom("settings").select(["key", "value"]).execute();
const out = { ...DEFAULT_SETTINGS } as Record<string, unknown>;
for (const r of rows) {
const def = (DEFAULT_SETTINGS as Record<string, object>)[r.key];
if (def) out[r.key] = { ...def, ...(r.value as object) };
}
return out as GlobalSettings;
};
return { all, get: async <K extends SettingsKey>(k: K) => (await all())[k] } as SettingsReader;
}
export interface Enqueued {
task: string;
input: unknown;
key: string;
}
export function fakeEngine(): TaskEngine & { enqueued: Enqueued[] } {
const enqueued: Enqueued[] = [];
const keys = new Set<string>();
return {
enqueued,
async enqueue(task, input, opts) {
const dedup = keys.has(opts.idempotencyKey);
if (!dedup) {
keys.add(opts.idempotencyKey);
enqueued.push({ task, input, key: opts.idempotencyKey });
}
return { runId: randomUUID(), deduplicated: dedup };
},
async signal() {
return { delivered: 0 };
},
async cancel() {
return false;
},
async get() {
return null;
},
async list() {
return [];
},
async startWorker() {},
async stop() {},
};
}
export interface Harness {
db: Kysely<DB>;
ctx: ServerContext;
engine: ReturnType<typeof fakeEngine>;
secrets: ReturnType<typeof fakeSecrets>;
module: ReturnType<typeof createSourcesIdentityModule>;
rt: ReturnType<ReturnType<typeof createSourcesIdentityModule>["runtime"]>;
app: ReturnType<typeof createApp>;
/** 带 dev admin key 的请求 */
req(path: string, init?: { method?: string; body?: unknown; key?: string }): Promise<Response>;
outbox(type?: string): Promise<{ type: string; payload: Record<string, unknown>; idempotency_key: string | null }[]>;
close(): Promise<void>;
}
export interface HarnessOptions extends RuntimeOverrides {
tmdbUrl?: string;
}
export async function createHarness(name: string, opts: HarnessOptions = {}): Promise<Harness> {
const schema = `w2b_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new K<unknown>({
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }),
});
await sql.raw(`create schema ${schema}`).execute(admin);
// 与 @umt/db createDb 相同的 int8 解析(createDb 导入即注册全局解析器)
await import("@umt/db");
const db = new K<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
}),
}) as unknown as Kysely<DB>;
// 与其他测试进程共用 PG:迁移内省会看到别处正在删除的 schema,用同一把咨询锁排队并重试
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
const { logger } = memoryLogger();
const engine = fakeEngine();
const secrets = fakeSecrets(() => db);
const outboxPort = createOutboxPort();
const ctx = {
role: "all",
db,
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never,
logger,
events: new EventBus(5),
engine,
outbox: outboxPort,
secrets,
settings: plainSettings(() => db),
capabilities: {} as never,
} as ServerContext;
const { tmdbUrl, ...overrides } = opts;
const module = createSourcesIdentityModule({
...overrides,
tmdb:
overrides.tmdb ??
(() => (tmdbUrl ? createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdbUrl}/3`, retries: 0 }) : null)),
});
const rt = module.runtime(ctx);
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus: new EventBus(5),
role: "all",
version: "0.0.0-test",
modules: [module],
ctx,
apiKeyStore: memoryStore({
[READ_KEY]: { id: "reader", scopes: ["read"] },
[EXEC_KEY]: { id: "operator", scopes: ["organize.execute"] },
}),
});
return {
db,
ctx,
engine,
secrets,
module,
rt,
app,
req(path, init = {}) {
const headers: Record<string, string> = { authorization: `Bearer ${init.key ?? DEV_KEY}` };
if (init.body !== undefined) headers["content-type"] = "application/json";
return Promise.resolve(
app.request(`/api/v1${path}`, {
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
headers,
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
}),
);
},
async outbox(type) {
let q = db.selectFrom("outbox_event").select(["type", "payload", "idempotency_key"]).orderBy("at").orderBy("id");
if (type) q = q.where("type", "=", type);
return (await q.execute()) as never;
},
async close() {
await db.destroy();
await withSchemaLock(() => sql.raw(`drop schema if exists ${schema} cascade`).execute(admin));
await admin.destroy();
},
};
}
/** 登记一个指向 mock 的站点 + 可选账号 */
export async function seedSite(
h: Harness,
opts: { key?: string; baseUrl: string; cookie?: string; adapter?: "nexusphp" | "mteam_api" | "generic_rss" },
) {
const site = await h.db
.insertInto("site")
.values({
key: opts.key ?? "audiences",
name: opts.key ?? "观众",
adapter: opts.adapter ?? "nexusphp",
base_url: opts.baseUrl,
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
snapshot_enabled: true,
snapshot_interval_sec: 3600,
})
.returningAll()
.executeTakeFirstOrThrow();
let accountId: string | null = null;
if (opts.cookie) {
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: opts.cookie });
const acc = await h.db
.insertInto("site_account")
.values({ site_id: site.id, label: "main", auth_kind: "cookie", secret_ref: ref })
.returning(["id"])
.executeTakeFirstOrThrow();
accountId = acc.id;
}
return { site, accountId };
}
/** 登记 Feed(URL 存 fake secret;feed_source.url_secret_ref 外键指向 secret 表,这里补一行占位) */
export async function seedFeed(
h: Harness,
opts: {
siteId: string | null;
url: string;
name?: string;
accountId?: string | null;
purpose?: "movie" | "promotion" | "generic";
},
) {
const ref = await h.secrets.create({ purpose: "feed_url", plaintext: opts.url });
return h.db
.insertInto("feed_source")
.values({
site_id: opts.siteId,
site_account_id: opts.accountId ?? null,
name: opts.name ?? "feed",
purpose: opts.purpose ?? "generic",
url_secret_ref: ref,
url_display: opts.url.replace(/passkey=[^&]+/, "passkey=[REDACTED]"),
poll_interval_sec: 300,
})
.returningAll()
.executeTakeFirstOrThrow();
}
// biome-ignore lint/suspicious/noExplicitAny: 测试断言用
type AnyJson = any;
export const json = async (r: Response) => ({
status: r.status,
body: (await r.json()) as { ok: boolean; data: AnyJson; error?: AnyJson },
});