- 新增 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]>
222 lines
7.7 KiB
TypeScript
222 lines
7.7 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, Kysely } from "@umt/db";
|
|
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 { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
|
|
import { fakeEngine as baseFakeEngine } from "../support/engine.ts";
|
|
import { createTestSchema, uniqueSchema } from "../support/pg.ts";
|
|
|
|
export const READ_KEY = "w2b-read-key";
|
|
export const EXEC_KEY = "w2b-exec-key";
|
|
|
|
// W2-B 集成测试夹具:独立 PG schema + 真实迁移 + fake 引擎/凭证 + 模块运行时。
|
|
|
|
export type { Enqueued } from "../support/engine.ts";
|
|
export { PG_URL, pgAvailable } from "../support/pg.ts";
|
|
|
|
/** 内存凭证库(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 const fakeEngine = () => baseFakeEngine({ dedupe: true });
|
|
|
|
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 ts = await createTestSchema(uniqueSchema(`w2b_${name}`), { max: 8 });
|
|
const db = ts.db as unknown as Kysely<DB>;
|
|
|
|
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 ts.drop();
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 登记一个指向 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 },
|
|
});
|