/** * 起单守卫:不合调用接口契约的输入,**一条工作流都不留**。 * * 用例层测试 —— 端口给的是**真实实现**(内存版),不 mock 内部机制; * 类型一律走 schema 解析而来的真值,不用断言绕过(§12.5#4)。 */ import { describe, expect, it } from 'vitest'; import { processPackageSchema } from '@contracts'; import type { ProcessPackage } from '@contracts'; import { CallInterfaceViolationError } from '../contract.ts'; import { createProcessService } from '../internal/usecase/process-service.ts'; import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts'; import type { WorkflowRecord } from '../port.ts'; import type { NodeInstance } from '../internal/domain/owner-resolution.ts'; /** 最小可发布包:一个开始、一个结束、一条边 —— 起单守卫不需要更复杂的图 */ const PKG: ProcessPackage = processPackageSchema.parse({ id: 'p1', name: '测试包', version: 1, state: 'published', callInterface: { input: { type: 'object', required: ['customerCode'], properties: { customerCode: { type: 'string' } } }, output: { type: 'object' }, }, nodes: [ { kind: 'start', id: 'S', label: '开始' }, { kind: 'end', id: 'E', label: '结束' }, ], edges: [{ id: 'e1', from: 'S', to: 'E', mapping: [], reopen: false }], variables: [], }); type Harness = { readonly service: ReturnType; readonly inserted: WorkflowRecord[]; readonly published: unknown[]; }; function harness(validationErrors: readonly string[]): Harness { const inserted: WorkflowRecord[] = []; const published: unknown[] = []; const instances: NodeInstance[] = []; const deps: ProcessServiceDeps = { store: { savePackage: async () => undefined, getPackage: async () => PKG, listPackages: async () => [PKG], publishPackage: async () => undefined, insertWorkflow: async (record) => { inserted.push(record); }, getWorkflow: async (id) => inserted.find((w) => w.id === id), listWorkflows: async () => inserted, // 内存版要**真的落笔**:用例读回它自己刚写的状态,假实现敷衍就测不出真行为 updateWorkflow: async (id, patch) => { const at = inserted.findIndex((w) => w.id === id); if (at < 0) throw new Error(`测试:工作流不存在 ${id}`); const next = { ...inserted[at]!, ...patch }; inserted[at] = next; return next; }, listInstances: async () => instances, insertInstance: async (_workflowId, instance) => { instances.push(instance); }, updateInstance: async () => undefined, findInstanceByTask: async () => undefined, }, activities: { faces: async () => new Map() }, org: { positions: async () => new Set(), candidates: async () => [], workGroupBinding: async () => undefined, isActive: async () => true }, tasks: { start: async () => ({ id: 'task-1' }) }, approvals: { raise: async () => ({ id: 'apr-1' }) }, sfeel: { condition: () => true, expression: () => undefined }, schemas: { validate: () => ({ ok: validationErrors.length === 0, errors: [...validationErrors] }), }, events: { publish: async (input) => { published.push(input); }, }, receipts: { notify: async () => undefined }, clock: { now: () => new Date('2026-07-27T00:00:00.000Z') }, ids: { next: () => 'wf-1' }, }; return { service: createProcessService(deps), inserted, published }; } describe('起单:凡单必合契约', () => { const input = { packageId: 'p1', subject: '测试单', source: { kind: 'registration' as const, ref: 'p1', by: 's1' }, data: {}, }; it('入参不合调用接口 → 抛错,且**不建工作流、不发事件**', async () => { const { service, inserted, published } = harness(["must have required property 'customerCode'"]); await expect(service.start(input)).rejects.toBeInstanceOf(CallInterfaceViolationError); // 校验若放在建单之后,这里就会留下一条永远推不动的在途单,污染在途口径 expect(inserted).toHaveLength(0); expect(published).toHaveLength(0); }); it('错误带上缺了哪个字段 —— 登记人据此补齐,而不是猜', async () => { const { service } = harness(["must have required property 'customerCode'"]); await expect(service.start(input)).rejects.toThrow(/customerCode/); }); it('入参合契约 → 正常建单,来源记在单上', async () => { const { service, inserted } = harness([]); const record = await service.start({ ...input, data: { customerCode: 'ACME' } }); expect(record.id).toBe('wf-1'); expect(inserted).toHaveLength(1); // 凡单必有来源:登记起单的来源就是登记人 expect(inserted[0]?.source).toEqual({ kind: 'registration', ref: 'p1', by: 's1' }); }); });