/** * 保存 —— **编辑即其身**(产品决策 2026-07-28 第四轮): * 写回当前版本号不变;新版本只出自显式新建(asNew)或新流程首版。 * 两道底线:在途钉版不可变;生效版写回必须闭合(生效的必须始终可运行)。 */ import { describe, expect, it } from 'vitest'; import { processPackageSchema } from '@contracts'; import type { ProcessPackage } from '@contracts'; import { NotClosedError, PinnedVersionError, StaleVersionError } from '../contract.ts'; import { createProcessService } from '../internal/usecase/process-service.ts'; import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts'; const basePkg = (version: number, state: 'active' | 'retired'): ProcessPackage => processPackageSchema.parse({ id: 'p1', name: '包', version, state, callInterface: { input: { type: 'object' }, output: { type: 'object' } }, nodes: [ { id: 'S', kind: 'start', label: '起' }, { id: 'E', kind: 'end', label: '终' }, ], edges: [{ id: 'E1', from: 'S', to: 'E', mapping: [], reopen: false }], variables: [], }); function harness(seed: ProcessPackage[], pinned = 0) { const shelf = [...seed]; const updated: Array<{ id: string; version: number }> = []; const inserted: ProcessPackage[] = []; const deps: ProcessServiceDeps = { store: { savePackage: async (pkg) => { inserted.push(pkg); shelf.push(pkg); }, updatePackageDefinition: async (id, version, pkg) => { const at = shelf.findIndex((p) => p.id === id && p.version === version); if (at < 0) throw new Error('版本不存在'); shelf[at] = pkg; updated.push({ id, version }); }, countPinnedInFlight: async () => pinned, setPackageState: async () => undefined, getPackage: async (id, version) => version === undefined ? [...shelf].sort((a, b) => b.version - a.version).find((p) => p.id === id) : shelf.find((p) => p.id === id && p.version === version), listPackages: async () => shelf, insertWorkflow: async () => undefined, getWorkflow: async () => undefined, listWorkflows: async () => [], updateWorkflow: async () => { throw new Error('测试:不该走到 updateWorkflow'); }, listInstances: async () => [], insertInstance: async () => undefined, updateInstance: async () => undefined, findInstanceByTask: async () => undefined, deletePackage: async () => undefined, renameFlow: async () => undefined, nodeOwners: async () => ({}), setNodeOwner: async () => undefined, }, activities: { faces: async () => new Map() }, org: { positions: async () => new Set(['PM']), candidates: async () => [], workGroupBinding: async () => undefined, isActive: async () => true, }, tasks: { start: async () => ({ id: 't' }) }, approvals: { raise: async () => ({ id: 'a' }) }, sfeel: { condition: () => true, expression: () => undefined }, schemas: { validate: () => ({ ok: true, errors: [] }) }, events: { publish: async () => undefined }, receipts: { notify: async () => undefined }, clock: { now: () => new Date('2026-07-28T00:00:00.000Z') }, ids: { next: () => 'x' }, }; return { service: createProcessService(deps), shelf, updated, inserted }; } describe('savePackage —— 编辑即其身', () => { it('已存在的版本就地写回:号不变、状态不变、不长新版', async () => { const h = harness([basePkg(4, 'retired')]); const edited = { ...basePkg(4, 'retired'), name: '改名了' }; const saved = await h.service.savePackage(edited); expect(saved.version).toBe(4); expect(saved.state).toBe('retired'); expect(h.updated).toEqual([{ id: 'p1', version: 4 }]); expect(h.inserted).toHaveLength(0); expect(h.shelf).toHaveLength(1); }); it('在途钉版不可变 —— 有未完结工作流即拒,一字不落库', async () => { const h = harness([basePkg(4, 'retired')], 2); await expect(h.service.savePackage(basePkg(4, 'retired'))).rejects.toBeInstanceOf(PinnedVersionError); expect(h.updated).toHaveLength(0); expect(h.inserted).toHaveLength(0); }); it('写回生效版须闭合 —— 生效的必须始终可运行', async () => { const h = harness([basePkg(4, 'active')]); // 把唯一一条边砍掉:起点无出边、终点无入边 —— 不闭合 const broken = { ...basePkg(4, 'active'), edges: [] }; await expect(h.service.savePackage(broken)).rejects.toBeInstanceOf(NotClosedError); expect(h.updated).toHaveLength(0); // 闭合的改动照常写回,生效状态保持 const ok = { ...basePkg(4, 'active'), name: '热改' }; const saved = await h.service.savePackage(ok); expect(saved.state).toBe('active'); expect(h.updated).toEqual([{ id: 'p1', version: 4 }]); }); it('asNew 显式新建:铸下一个版本号,不生效 —— 新版本只出自这条路', async () => { const h = harness([basePkg(4, 'active')]); const saved = await h.service.savePackage(basePkg(4, 'active'), { asNew: true }); expect(saved.version).toBe(5); expect(saved.state).toBe('retired'); expect(h.inserted).toHaveLength(1); expect(h.updated).toHaveLength(0); }); it('陈旧编辑拒之门外:流程存在而版本号已不存在 —— 不静默复活旧内容', async () => { const h = harness([basePkg(4, 'active')]); // 停在已清理旧版(v1)上的标签页发来保存 await expect(h.service.savePackage(basePkg(1, 'retired'))).rejects.toBeInstanceOf(StaleVersionError); expect(h.inserted).toHaveLength(0); expect(h.updated).toHaveLength(0); }); it('新流程首版:版本不存在即插入 v1,不生效', async () => { const h = harness([]); const saved = await h.service.savePackage(basePkg(1, 'active')); expect(saved.version).toBe(1); expect(saved.state).toBe('retired'); expect(h.inserted).toHaveLength(1); }); });