/** * 「统一报价」端到端集成测试 —— 把整条流**在真库上**测死。 * * 起真容器、跑真迁移、播真种子;推进不靠定时器,靠手动泵 * (`dispatchOnce` + `advanceOnce` 循环到静止)—— 每一拍都是生产同一份代码。 * 系统内部机制零 mock(README);唯一的「假」是占位活动本身,而这正是被测物: * 编排层要在活动只有契约的情况下把流转走对。 * * **库的选择绝不回落到开发库**:E2E 要 seed 与断言全库状态,碰开发库就是清人数据。 * 次序:JCAI_TEST_DATABASE_URL → 在开发实例上**另建**独立库 jcai_it_flow → Testcontainers; * 都不可得则跳过并告警 —— 跳过必须可见,不能变成沉默的通过。 */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import { migrate } from 'drizzle-orm/node-postgres/migrator'; import { existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { TaskId, WorkOrderId } from '@contracts'; import { loadConfig } from '../config.ts'; import { createContainer } from '../container.ts'; import type { Container } from '../container.ts'; import { seedMvp } from '../seed-mvp.ts'; import { advanceOnce } from '../workers/task-advancer.ts'; import { dispatchOnce } from '../workers/dispatcher.ts'; import { createConsumers } from '../workers/consumers.ts'; /* ── 库:独立于开发库 ─────────────────────────────────────────────────── */ const IT_DB = 'jcai_it_flow'; async function provisionDatabase(): Promise<{ url: string; stop(): Promise } | undefined> { const explicit = process.env['JCAI_TEST_DATABASE_URL']; if (explicit !== undefined) return { url: explicit, stop: async () => undefined }; // 在开发实例上另建一个库 —— 同一个 PG,不同 database,动不到开发数据 const base = process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai'; try { const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 }); const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]); if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`); await admin.end(); return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined }; } catch { /* 开发实例连不上,试 Testcontainers */ } try { const { PostgreSqlContainer } = await import('@testcontainers/postgresql'); const started = await new PostgreSqlContainer('postgres:16-alpine').start(); return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) }; } catch (error) { process.stderr.write( `⚠️ unified-quote E2E 跳过:连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`, ); return undefined; } } function migrationsFolder(): string { let dir = dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 8; i += 1) { const candidate = join(dir, 'db', 'migrations'); if (existsSync(candidate)) return candidate; const parent = resolve(dir, '..'); if (parent === dir) break; dir = parent; } throw new Error('找不到 db/migrations'); } // 顶层 await:可用性必须在收集期定下来,否则 it.skip 的判定早于 beforeAll const database = await provisionDatabase(); let pool: Pool | undefined; let container: Container; let chaihao: string; beforeAll(async () => { if (database === undefined) return; pool = new Pool({ connectionString: database.url, max: 4 }); // 每次从零:四 schema 全重建 → 迁移 → 种子。E2E 的前提是「起点确定」。 for (const schema of ['sys', 'ledger', 'mastra', 'queue']) { await pool.query(`drop schema if exists "${schema}" cascade`); await pool.query(`create schema "${schema}"`); } await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); // 配置指向测试库 —— 经 loadConfig 的 env 形参,不污染 process.env container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); await container.org.ensureSeeded(); await seedMvp(container); const { staff } = await container.org.listStaff(); chaihao = staff.find((s) => s.account === 'Chaihao')!.id; }, 180_000); afterAll(async () => { if (database === undefined) return; await container?.close(); await pool?.end(); await database.stop(); }); /* ── 推进与走单的工具 ─────────────────────────────────────────────────── */ const consumers = createConsumers(); /** 手动泵:投递 + 推进循环到静止 —— 测试不靠定时器,每拍都可断言 */ async function pump(): Promise { for (let i = 0; i < 60; i += 1) { const delivered = await dispatchOnce({ container, consumers }); const advanced = await advanceOnce({ container }); if (delivered === 0 && advanced === 0) return; } throw new Error('泵 60 轮未静止 —— 流转在空转'); } async function start(ref: string): Promise { const record = await container.process.start({ packageId: 'unified-quote', subject: ref, source: { kind: 'registration', ref: 'it', by: chaihao }, data: { customerCode: 'C014', rfqRef: ref, subject: ref, body: 'it' }, }); await pump(); return record.id; } /** 该流当前在途任务(恰一个 —— 占位期无并行支) */ async function currentTask(workflowId: string): Promise { const rows = await pool!.query<{ id: string }>( `select id from sys.task where workflow_id = $1 and terminal is null order by started_at desc limit 1`, [workflowId], ); expect(rows.rowCount, `流 ${workflowId} 应有在途任务`).toBe(1); return rows.rows[0]!.id as TaskId; } /** 答掉当前任务的收口验收(占位活动恰一张) */ async function acceptCurrent( workflowId: string, values: Record = {}, selectedPorts: Record = {}, ): Promise { const taskId = await currentTask(workflowId); const open = await container.humanTool.openWorkOrders(taskId); const order = open.find((o) => o.mode === 'acceptance'); expect(order, '应有待答验收单').toBeDefined(); await container.humanTool.replyWorkOrder( { mode: 'acceptance', workOrderId: order!.id as WorkOrderId, verdict: 'pass', values, selectedPorts }, chaihao as never, ); await pump(); } async function decideApproval(workflowId: string, verdict: 'pass' | 'reject'): Promise { await container.process.onApprovalVerdict(workflowId, 'A5', verdict, { by: chaihao }); await pump(); } /** 节点实例摘要:`A1#1=released` —— 断言拓扑走向的通用形状 */ async function seq(workflowId: string): Promise { const instances = await container.process.instancesOf(workflowId); return instances.map((i) => `${i.nodeId}#${i.instance}=${i.terminal ?? '…'}`); } async function flowState(workflowId: string): Promise { return (await container.process.getWorkflow(workflowId)).state; } /** 走完 A1(按旗选路)→ … → 停在 A6 的验收单前 */ async function walkToA6(ref: string): Promise { const wf = await start(ref); await acceptCurrent(wf, { needAlternative: 'false', needSourcing: 'false' }); await acceptCurrent(wf); // A4 await decideApproval(wf, 'pass'); // A5 → G3 通过 → A6 return wf; } /* ── 用例 ─────────────────────────────────────────────────────────────── */ /* * **2026-07-29 起整套跳过 —— 范式统一的直接后果,不是沉默的通过。** * * 这套端到端靠「答活动的验收工单」把流走完;那张工单从前由 flow 引擎的末步发出。 * 活动改为一律案例模型(loop)之后,发不发工单由 Agent 每拍决定 —— * `turn-runner.runTurn` 每一拍都要 `agent.decide`,**环境里没有模型 key 就一步也走不动**。 * * 恢复条件(两件,缺一不可,见 docs/debt.md): * ① 环境有可用模型(JCAI_DEEPSEEK_API_KEY 或等价的实例绑定); * ② 走法改写成 loop 的口径 —— 按 Enabled 集推进,而不是按步序答工单。 * 在此之前**不许把断言改松让它变绿**:绿得不诚实比红着更坏。 */ const SKIP_UNTIL_MODEL = true; // 见上:没有模型,案例活动一步也走不动 describe.skipIf(database === undefined || SKIP_UNTIL_MODEL)('统一报价 · 端到端(真库真容器)', () => { it('主路径:不选型不寻源 → A4 → A5 → G3 → A6 选成交 → end_won,流 completed', async () => { const wf = await walkToA6('E2E-WON'); await acceptCurrent(wf, {}, { 议价结果: 'won' }); const s = await seq(wf); expect(s).toEqual(['A1#1=released', 'A4#1=released', 'A5#1=released', 'A6#1=released', 'end_won#1=released']); expect(await flowState(wf)).toBe('completed'); }, 120_000); it('G1 需选型 → 进 A2;A2 办结(不再寻源)→ 过 G2/G2J 进 A4', async () => { const wf = await start('E2E-ALT'); await acceptCurrent(wf, { needAlternative: 'true', needSourcing: 'false' }); let s = await seq(wf); expect(s).toContain('A2#1=…'); expect(s.some((x) => x.startsWith('A3#'))).toBe(false); await acceptCurrent(wf, { needSourcing: 'false' }); // A2:仍不寻源 s = await seq(wf); expect(s).toContain('A2#1=released'); expect(s).toContain('A4#1=…'); // 经 G2 → G2J(异或汇合不等)→ A4 }, 120_000); it('G2 需寻源 → 进 A3;A3 办结 → 经 G2J 进 A4', async () => { const wf = await start('E2E-SRC'); await acceptCurrent(wf, { needAlternative: 'false', needSourcing: 'true' }); let s = await seq(wf); expect(s).toContain('A3#1=…'); expect(s.some((x) => x.startsWith('A2#'))).toBe(false); await acceptCurrent(wf); // A3 s = await seq(wf); expect(s).toContain('A3#1=released'); expect(s).toContain('A4#1=…'); }, 120_000); it('G3 审批驳回 → 走图上的驳回边,重开 A4 新实例(旧实例留痕不动)', async () => { const wf = await start('E2E-REJ'); await acceptCurrent(wf, { needAlternative: 'false', needSourcing: 'false' }); await acceptCurrent(wf); // A4#1 await decideApproval(wf, 'reject'); const s = await seq(wf); expect(s).toContain('A4#1=released'); // 旧实例不复活、不改写 expect(s).toContain('A4#2=…'); // 重开新实例 }, 120_000); it('G4 有异议 → 回边重开 A4 新实例', async () => { const wf = await walkToA6('E2E-REV'); await acceptCurrent(wf, {}, { 议价结果: 'revise' }); const s = await seq(wf); expect(s).toContain('A6#1=released'); expect(s).toContain('A4#2=…'); expect(await flowState(wf)).toBe('running'); }, 120_000); it('G4 拒绝/超时 → A7 归档 → end_arch,流 completed', async () => { const wf = await walkToA6('E2E-LOST'); await acceptCurrent(wf, {}, { 议价结果: 'lost' }); await acceptCurrent(wf); // A7 const s = await seq(wf); expect(s).toContain('A7#1=released'); expect(s).toContain('end_arch#1=released'); expect(await flowState(wf)).toBe('completed'); }, 120_000); it('恰选一口是收单规矩:A6 放行不选口 → 同步 400 拒收,工单还活着', async () => { const wf = await walkToA6('E2E-PORT'); const taskId = await currentTask(wf); const order = (await container.humanTool.openWorkOrders(taskId)).find((o) => o.mode === 'acceptance')!; await expect( container.humanTool.replyWorkOrder( { mode: 'acceptance', workOrderId: order.id as WorkOrderId, verdict: 'pass', values: {}, selectedPorts: {} }, chaihao as never, ), ).rejects.toThrow(/议价结果/); // 工单未被吃掉 —— 补选就能继续 const stillOpen = await container.humanTool.openWorkOrders(taskId); expect(stillOpen.some((o) => o.id === order.id)).toBe(true); await acceptCurrent(wf, {}, { 议价结果: 'won' }); expect(await flowState(wf)).toBe('completed'); }, 120_000); it('全程零死路:route-dead-end 事件一条都不该有', async () => { const dead = await pool!.query( `select count(*)::int as n from sys.event where correlation->'cause'->>'ref' = 'route-dead-end'`, ); expect(dead.rows[0].n).toBe(0); }); });