/** * 触发确认链路(2026-07-30,用户口述五步的第 4/5 步)—— * 勾了「需人工确认」,**命中不等于起单**: * 命中 → 装配好的入参存成待确认单 + 系统消息到指定人工作台 → 他点头 → 才起单。 * * 这里守四条命门: * ① 勾了确认:不起单,发确认消息,入参存的是**装配后**的(确认时不重新装配); * ② 点头:起单、来源记确认人、幂等 —— 第二次点头回同一条流,绝不起两条; * ③ 并发点头:store 认领只让一个赢,输家拿到的是赢家那条流; * ④ 登记触发不走确认 —— 登记那一下本身就是人在确认;没指定确认人是配置错误,入死信。 */ import { describe, expect, it } from 'vitest'; import type { TriggerConfig } from '@contracts'; import type { PendingFire } from '../contract.ts'; import { createTriggerService } from '../internal/usecase/trigger-service.ts'; import type { TriggerServiceDeps } from '../internal/usecase/trigger-service.ts'; const CONFIG: TriggerConfig = { id: 'trg-1', name: '客户RFQ询价', enabled: true, processPackageId: 'unified-quote', mapping: [ { source: { from: 'expression', sfeel: 'keys.customerCode' }, target: 'customerCode' }, { source: { from: 'expression', sfeel: 'subject' }, target: 'subject' }, ], confirm: { required: true, staffId: 'staff-bd' }, spec: { kind: 'event', eventType: 'data', rule: 'true' }, } as TriggerConfig; type Harness = { service: ReturnType; pendings: Map; started: Array<{ subject: string; by?: string; data: Record }>; desk: Array<{ pendingId: string; staffId: string }>; dead: string[]; }; function harness(config: TriggerConfig, options?: { failFirstStart?: boolean }): Harness { const pendings = new Map(); const started: Harness['started'] = []; const desk: Harness['desk'] = []; const dead: string[] = []; let seq = 0; let failed = false; const deps: TriggerServiceDeps = { store: { list: async () => [config], get: async () => config, upsert: async (c) => c, setEnabled: async () => undefined, setConfirm: async () => undefined, remove: async () => undefined, claim: async () => true, lastFiredAt: async () => undefined, markFired: async () => undefined, createPending: async (p) => { pendings.set(p.id, p); }, getPending: async (id) => pendings.get(id), pendingByIds: async (ids) => ids.flatMap((id) => (pendings.has(id) ? [pendings.get(id)!] : [])), claimPending: async (id, at) => { const p = pendings.get(id); if (p === undefined || p.consumedAt !== undefined) return false; pendings.set(id, { ...p, consumedAt: at.toISOString() }); return true; }, setPendingWorkflow: async (id, workflowId) => { pendings.set(id, { ...pendings.get(id)!, workflowId }); }, releasePending: async (id) => { const { consumedAt: _drop, ...rest } = pendings.get(id)!; pendings.set(id, rest as PendingFire); }, }, process: { start: async (input) => { if (options?.failFirstStart === true && started.length === 0 && failed === false) { failed = true; throw new Error('起单炸了(注入)'); } started.push({ subject: input.subject, ...(input.source.by === undefined ? {} : { by: input.source.by }), data: input.data }); return { id: `wf-${started.length}` }; }, requiredInputs: async () => ['customerCode'], }, sfeel: { condition: () => true, expression: (expr, scope) => expr.split('.').reduce((acc, seg) => (acc as Record)?.[seg], scope), }, confirmDesk: { request: async (input) => { desk.push({ pendingId: input.pendingId, staffId: input.staffId }); }, }, deadLetters: { report: async (input) => { dead.push(input.reason); }, }, ledger: { scan: async () => [] }, clock: { now: () => new Date('2026-07-30T00:00:00.000Z') }, ids: { next: () => `id-${(seq += 1)}` }, }; return { service: createTriggerService(deps), pendings, started, desk, dead }; } const SCOPE = { type: 'data', keys: { customerCode: 'C014' }, payload: { subject: '【询价】C014', body: '正文', from: 'buyer@x.com' }, source: 'channel', occurredAt: '2026-07-30T00:00:00.000Z', } as const; describe('触发确认链路', () => { it('① 勾了确认:命中不起单 —— 存待确认单 + 消息到指定人,入参是装配后的', async () => { const h = harness(CONFIG); const out = await h.service.fireByEvent('trg-1', 'evt-1', SCOPE); expect(out.workflowId, '不该直接起单').toBeUndefined(); expect(out.pendingId).toBeDefined(); expect(h.started).toHaveLength(0); expect(h.desk).toEqual([{ pendingId: out.pendingId, staffId: 'staff-bd' }]); const pending = h.pendings.get(out.pendingId!)!; expect(pending.data['customerCode'], '存的是装配后的入参,确认时不重新装配').toBe('C014'); expect(pending.preview['from'], '消息要带来件内容').toBe('buyer@x.com'); expect(pending.staffId, '确认人快照在票上').toBe('staff-bd'); }); it('② 点头起单:来源记确认人;第二次点头回同一条流,绝不起两条', async () => { const h = harness(CONFIG); const { pendingId } = await h.service.fireByEvent('trg-1', 'evt-1', SCOPE); const first = await h.service.confirmPending(pendingId!, 'staff-bd'); expect(first.alreadyStarted).toBe(false); expect(h.started).toHaveLength(1); expect(h.started[0]!.by, '这条流是谁点头放行的,留痕要看得到').toBe('staff-bd'); const second = await h.service.confirmPending(pendingId!, 'staff-bd'); expect(second.alreadyStarted).toBe(true); expect(second.workflowId).toBe(first.workflowId); expect(h.started, '幂等 —— 只起一条').toHaveLength(1); }); it('③ 起单失败把认领退回去 —— 这一票还能再确认,不变成死票', async () => { const h = harness(CONFIG, { failFirstStart: true }); const { pendingId } = await h.service.fireByEvent('trg-1', 'evt-1', SCOPE); await expect(h.service.confirmPending(pendingId!, 'staff-bd')).rejects.toThrow('起单炸了'); expect(h.pendings.get(pendingId!)!.consumedAt, '认领退回去了').toBeUndefined(); const retry = await h.service.confirmPending(pendingId!, 'staff-bd'); expect(retry.alreadyStarted).toBe(false); expect(h.started).toHaveLength(1); }); it('④ 登记触发不走确认;勾了确认没指定人 = 配置错误入死信', async () => { // 登记规格 + 零映射(登记表单本身就按调用接口成形)—— 与种子里的「人工登记起单」同形 const h = harness({ ...CONFIG, mapping: [], spec: { kind: 'registration' } } as TriggerConfig); // 登记:登记那一下本身就是人在确认 —— 直接起单 const reg = await h.service.fireByRegistration('trg-1', 'staff-op', { customerCode: 'C014', subject: 's' }); expect(reg.workflowId).toBeDefined(); expect(h.desk, '登记不发确认消息').toHaveLength(0); const h2 = harness({ ...CONFIG, confirm: { required: true } } as TriggerConfig); const out = await h2.service.fireByEvent('trg-1', 'evt-2', SCOPE); expect(out.skipped).toBe('no-confirmer'); expect(h2.started).toHaveLength(0); expect(h2.dead.some((r) => r.includes('确认人')), '如实入死信,不静默丢、不擅自直起').toBe(true); }); });