This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/process/__tests__/start-guard.test.ts
T
ChaiHaoandClaude Opus 5 f9b2c07731 三面拆分⑬:触发确认链路 + 运营中心「工作流」区 —— 设计只管设计,运营管细节
**触发确认链路**(用户口述五步的第 4/5 步):
  接收邮件 → 分类识别 → 命中 RFQ 事件 → ④(可选,运营勾)系统消息到指定人
  工作台(带邮件内容)→ ⑤ 工作台点「确认起单」,流程才起。
  · 命中不等于必起:勾了「需人工确认」,装配好的入参存成待确认单
    (sys.trigger_pending,migration 0019),ConfirmDesk 端口把带来件内容的
    系统消息送指定人(eventRef 挂票号,消息列表就地贴确认状态与那枚钮)。
  · 先认领后起单:consumed_at 空才认得到 —— 两人同时点、一人点两次,都只起一条流;
    起单失败退回认领,不留死票。确认人记进 source.by,留痕可查。
  · 登记触发不走确认(登记那一下本身就是人在确认);勾了没指定人 = 配置错误入死信。
  · 4 条单测 + 全链实测:投信 → triggersHit=1 且零直起 → AI 工作台收到确认消息
    → 确认起单 → 二次确认幂等回同一流号 → source.by = AI。

**运营中心重排**(区名即运行时:工作流是流程包的运行时,任务是活动的运行时):
  · 新「工作流」区:在途看板(从任务区迁入)+ 运行配置 —— 每包的触发器
    起单开关 / 确认打勾 / 确认人,和节点负责人(sys.node_owner)。
  · 负责人三层:显式指派 > 运营配置 > 轮值 —— 合并点在 process.start,
    复用既有派发链一行不改。
  · 任务区瘦身成活动运行时(任务/审批/需处置);知识库区改名「数据」
    (与设计中心的知识库解开同名);系统区只剩通道实例。
  · 设计中心触发器详情页摘掉启停按钮 —— 设计只管设计,启停/确认全在运营。
  · 旧深链有改嫁:tasks/board → runs/board,tasks/trg → runs/conf。

reset-runs 补 sys.trigger_pending(待确认单是运行痕 —— 和上回漏 trace_span 同类洞)。
邮件分类 Agent(第 ② 步)现由抽键正则 + S-FEEL 代位,升级路径记进 debt.md。
产品设计.md 运营章、trigger/process 两份 CONTRACT.md 同步。

五闸绿;契约快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 13:09:16 +08:00

126 lines
5.0 KiB
TypeScript

/**
* 起单守卫:不合调用接口契约的输入,**一条工作流都不留**。
*
* 用例层测试 —— 端口给的是**真实实现**(内存版),不 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: 'active',
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<typeof createProcessService>;
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,
updatePackageDefinition: async () => undefined,
countPinnedInFlight: async () => 0,
setPackageState: async () => undefined,
getPackage: async () => PKG,
listPackages: async () => [PKG],
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,
nodeOwners: async () => ({}),
setNodeOwner: async () => undefined,
},
activities: { faces: async () => new Map() },
org: { positions: async () => new Set<string>(), 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' });
});
});