@objectstack/plugin-approvals 7.3.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,24 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
+ /**
4
+ * Node-era approval service tests (ADR-0019).
5
+ *
6
+ * Approval is a flow node — there is no standalone process engine. These tests
7
+ * exercise the service directly: opening a node-driven request, recording
8
+ * decisions (first_response / unanimous), the public `decide()` resume bridge,
9
+ * the read API, and the global record-lock hook.
10
+ */
11
+
3
12
  import { describe, it, expect, beforeEach } from 'vitest';
4
13
  import { ApprovalService } from './approval-service.js';
14
+ import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
5
15
 
6
16
  interface FakeRow { [k: string]: any }
7
17
 
8
18
  function makeFakeEngine() {
9
19
  const tables: Record<string, FakeRow[]> = {};
10
20
  const ensure = (n: string) => (tables[n] ??= []);
21
+ const hooks: Record<string, Array<{ handler: (ctx: any) => any | Promise<any>; object?: string | string[]; packageId?: string }>> = {};
11
22
 
12
23
  function matches(row: FakeRow, filter: any): boolean {
13
24
  if (!filter || typeof filter !== 'object') return true;
@@ -28,6 +39,7 @@ function makeFakeEngine() {
28
39
 
29
40
  return {
30
41
  _tables: tables,
42
+ _hooks: hooks,
31
43
  async find(object: string, options?: any) {
32
44
  const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
33
45
  if (options?.orderBy?.[0]) {
@@ -60,41 +72,57 @@ function makeFakeEngine() {
60
72
  if (i >= 0) table.splice(i, 1);
61
73
  return { id };
62
74
  },
75
+ // ── hook surface (for the record-lock hook) ──
76
+ registerHook(event: string, handler: (ctx: any) => any, options?: any) {
77
+ (hooks[event] ??= []).push({ handler, object: options?.object, packageId: options?.packageId });
78
+ },
79
+ unregisterHooksByPackage(packageId: string): number {
80
+ let n = 0;
81
+ for (const ev of Object.keys(hooks)) {
82
+ const before = hooks[ev].length;
83
+ hooks[ev] = hooks[ev].filter(h => h.packageId !== packageId);
84
+ n += before - hooks[ev].length;
85
+ }
86
+ return n;
87
+ },
88
+ async fire(event: string, ctx: any) {
89
+ for (const h of hooks[event] ?? []) {
90
+ if (h.object) {
91
+ const objs = Array.isArray(h.object) ? h.object : [h.object];
92
+ if (!objs.includes(ctx.object)) continue;
93
+ }
94
+ await h.handler(ctx);
95
+ }
96
+ },
63
97
  };
64
98
  }
65
99
 
66
- const CTX = { userId: 'u1', tenantId: 't1', roles: [], permissions: [] };
67
- const SYS = { isSystem: true, roles: [], permissions: [] };
100
+ const CTX = { userId: 'u1', tenantId: 't1', roles: [], permissions: [] } as any;
101
+ const SYS = { isSystem: true, roles: [], permissions: [] } as any;
68
102
 
69
- function singleStep(approvers: string[], behavior: 'first_response' | 'unanimous' = 'first_response') {
103
+ function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
70
104
  return {
71
- name: 'proc',
72
- label: 'Proc',
73
- object: 'opportunity',
74
- active: true,
75
- steps: [{
76
- name: 'sales_manager',
77
- label: 'Sales Manager',
78
- approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
79
- behavior,
80
- }],
105
+ approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
106
+ behavior: 'first_response' as const,
107
+ lockRecord: true,
108
+ ...extra,
81
109
  };
82
110
  }
83
111
 
84
- function multiStep() {
112
+ function openInput(approvers: string[], extra: Record<string, any> = {}, configExtra: Record<string, any> = {}) {
85
113
  return {
86
- name: 'proc',
87
- label: 'Proc',
88
114
  object: 'opportunity',
89
- active: true,
90
- steps: [
91
- { name: 'step1', label: 'Step 1', approvers: [{ type: 'user' as const, value: 'u1' }], behavior: 'first_response' },
92
- { name: 'step2', label: 'Step 2', approvers: [{ type: 'user' as const, value: 'u2' }], behavior: 'first_response', rejectionBehavior: 'back_to_previous' as const },
93
- ],
115
+ recordId: 'opp1',
116
+ runId: 'run_1',
117
+ nodeId: 'approve_step',
118
+ flowName: 'deal_approval',
119
+ config: nodeConfig(approvers, configExtra),
120
+ record: { id: 'opp1', amount: 100 },
121
+ ...extra,
94
122
  };
95
123
  }
96
124
 
97
- describe('ApprovalService', () => {
125
+ describe('ApprovalService (node era)', () => {
98
126
  let engine: ReturnType<typeof makeFakeEngine>;
99
127
  let svc: ApprovalService;
100
128
  let n = 0;
@@ -105,342 +133,215 @@ describe('ApprovalService', () => {
105
133
  n = 0;
106
134
  svc = new ApprovalService({
107
135
  engine: engine as any,
108
- // Ensure strictly increasing timestamps so created_at sort is deterministic.
109
136
  clock: { now: () => new Date(baseTime + (n++) * 1000) },
110
137
  });
111
138
  });
112
139
 
113
- // ── Process CRUD ───────────────────────────────────────────────
114
-
115
- it('defineProcess: creates with generated id and validates JSON', async () => {
116
- const r = await svc.defineProcess({
117
- name: 'proc', label: 'P', object: 'opportunity',
118
- definition: singleStep(['u9']),
119
- }, CTX);
120
- expect(r.id).toMatch(/^apv_/);
121
- expect(r.active).toBe(true);
122
- expect(engine._tables['sys_approval_process'].length).toBe(1);
123
- expect(engine._tables['sys_approval_process'][0].definition_json).toContain('sales_manager');
124
- });
140
+ // ── openNodeRequest ─────────────────────────────────────────────
125
141
 
126
- it('defineProcess: upserts when name matches', async () => {
127
- const a = await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
128
- const b = await svc.defineProcess({ name: 'proc', label: 'P2', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
129
- expect(b.id).toBe(a.id);
130
- expect(b.label).toBe('P2');
131
- expect(engine._tables['sys_approval_process'].length).toBe(1);
142
+ it('openNodeRequest: creates a pending request + submit action with flow correlation', async () => {
143
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
144
+ expect(req.status).toBe('pending');
145
+ expect(req.process_name).toBe('flow:deal_approval');
146
+ expect(req.flow_run_id).toBe('run_1');
147
+ expect(req.flow_node_id).toBe('approve_step');
148
+ expect(req.pending_approvers).toEqual(['u9']);
149
+ expect(engine._tables['sys_approval_request']).toHaveLength(1);
150
+ expect(engine._tables['sys_approval_action'][0].action).toBe('submit');
132
151
  });
133
152
 
134
- it('defineProcess: rejects invalid definition', async () => {
135
- await expect(svc.defineProcess({
136
- name: 'proc', label: 'P', object: 'opportunity',
137
- definition: { name: 'proc', label: 'P', object: 'opportunity', steps: [] },
138
- }, CTX)).rejects.toThrow(/VALIDATION_FAILED/);
153
+ it('openNodeRequest: snapshots the node config on the row', async () => {
154
+ await svc.openNodeRequest(openInput(['u9']), CTX);
155
+ const raw = engine._tables['sys_approval_request'][0];
156
+ expect(JSON.parse(raw.node_config_json)).toMatchObject({ behavior: 'first_response', lockRecord: true });
139
157
  });
140
158
 
141
- it('listProcesses({activeOnly:true}) filters', async () => {
142
- await svc.defineProcess({ name: 'proc_a', label: 'A', object: 'opportunity', active: true, definition: { ...singleStep(['u1']), name: 'proc_a' } }, CTX);
143
- await svc.defineProcess({ name: 'proc_b', label: 'B', object: 'opportunity', active: false, definition: { ...singleStep(['u1']), name: 'proc_b', active: false } }, CTX);
144
- const active = await svc.listProcesses({ activeOnly: true }, CTX);
145
- expect(active.length).toBe(1);
146
- expect(active[0].name).toBe('proc_a');
159
+ it('openNodeRequest: deduplicates a pending request per (object, record)', async () => {
160
+ await svc.openNodeRequest(openInput(['u9']), CTX);
161
+ await expect(svc.openNodeRequest(openInput(['u9'], { runId: 'run_2' }), CTX))
162
+ .rejects.toThrow(/DUPLICATE_REQUEST/);
147
163
  });
148
164
 
149
- it('getProcess by name then id; deleteProcess removes row', async () => {
150
- const r = await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
151
- expect((await svc.getProcess('proc', CTX))?.id).toBe(r.id);
152
- expect((await svc.getProcess(r.id, CTX))?.name).toBe('proc');
153
- await svc.deleteProcess('proc', CTX);
154
- expect(engine._tables['sys_approval_process'].length).toBe(0);
165
+ it('openNodeRequest: requires object, recordId, runId', async () => {
166
+ await expect(svc.openNodeRequest(openInput(['u9'], { object: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/);
167
+ await expect(svc.openNodeRequest(openInput(['u9'], { recordId: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/);
168
+ await expect(svc.openNodeRequest(openInput(['u9'], { runId: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/);
155
169
  });
156
170
 
157
- // ── Submit ─────────────────────────────────────────────────────
158
-
159
- it('submit: happy path creates request + submit action + pending_approvers', async () => {
160
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
161
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'u1' }, CTX);
162
- expect(req.status).toBe('pending');
163
- expect(req.pending_approvers).toEqual(['u9']);
164
- expect(req.current_step).toBe('sales_manager');
165
- expect(engine._tables['sys_approval_action'].length).toBe(1);
166
- expect(engine._tables['sys_approval_action'][0].action).toBe('submit');
171
+ it('openNodeRequest: mirrors status onto the business record when configured', async () => {
172
+ engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
173
+ await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
174
+ expect(engine._tables['opportunity'][0].approval_status).toBe('pending');
167
175
  });
168
176
 
169
- it('submit: deduplicates pending requests', async () => {
170
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
171
- await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
172
- await expect(svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX))
173
- .rejects.toThrow(/DUPLICATE_REQUEST/);
174
- });
177
+ // ── decideNode ──────────────────────────────────────────────────
175
178
 
176
- it('submit: throws when no active process', async () => {
177
- await expect(svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX))
178
- .rejects.toThrow(/NO_ACTIVE_PROCESS/);
179
+ it('decideNode: first_response approve finalizes immediately', async () => {
180
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
181
+ const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
182
+ expect(out.finalized).toBe(true);
183
+ expect(out.decision).toBe('approve');
184
+ expect(out.runId).toBe('run_1');
185
+ expect(out.nodeId).toBe('approve_step');
186
+ expect(out.request.status).toBe('approved');
179
187
  });
180
188
 
181
- // ── Approve ────────────────────────────────────────────────────
182
-
183
- it('approve single step finalized=true and status approved', async () => {
184
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
185
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
186
- const out = await svc.approve(req.id, { actorId: 'u9' }, CTX);
189
+ it('decideNode: reject finalizes as rejected', async () => {
190
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
191
+ const out = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u9', comment: 'no' }, SYS);
187
192
  expect(out.finalized).toBe(true);
188
- expect(out.request.status).toBe('approved');
189
- expect(out.request.completed_at).toBeTruthy();
193
+ expect(out.request.status).toBe('rejected');
190
194
  });
191
195
 
192
- it('approve multi step advances to next step', async () => {
193
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: multiStep() }, CTX);
194
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
195
- const out1 = await svc.approve(req.id, { actorId: 'u1' }, CTX);
196
- expect(out1.finalized).toBe(false);
197
- expect(out1.request.current_step).toBe('step2');
198
- expect(out1.request.current_step_index).toBe(1);
199
- expect(out1.request.pending_approvers).toEqual(['u2']);
200
- const out2 = await svc.approve(req.id, { actorId: 'u2' }, CTX);
201
- expect(out2.finalized).toBe(true);
202
- expect(out2.request.status).toBe('approved');
196
+ it('decideNode: unanimous holds until every approver acts', async () => {
197
+ const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX);
198
+ const first = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
199
+ expect(first.finalized).toBe(false);
200
+ expect(first.request.pending_approvers).toEqual(['u2']);
201
+ const second = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
202
+ expect(second.finalized).toBe(true);
203
+ expect(second.request.status).toBe('approved');
203
204
  });
204
205
 
205
- it('approve unanimous: first vote not final, second vote finalizes', async () => {
206
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u1', 'u2'], 'unanimous') }, CTX);
207
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
208
- const a = await svc.approve(req.id, { actorId: 'u1' }, CTX);
209
- expect(a.finalized).toBe(false);
210
- expect(a.request.pending_approvers).toEqual(['u2']);
211
- const b = await svc.approve(req.id, { actorId: 'u2' }, CTX);
212
- expect(b.finalized).toBe(true);
213
- expect(b.request.status).toBe('approved');
206
+ it('decideNode: blocks a non-approver in a non-system context', async () => {
207
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
208
+ await expect(
209
+ svc.decideNode(req.id, { decision: 'approve', actorId: 'mallory' }, { isSystem: false, roles: [], permissions: [] } as any),
210
+ ).rejects.toThrow(/FORBIDDEN/);
214
211
  });
215
212
 
216
- it('approve by non-pending approver → FORBIDDEN', async () => {
217
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
218
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
219
- await expect(svc.approve(req.id, { actorId: 'mallory' }, CTX)).rejects.toThrow(/FORBIDDEN/);
213
+ it('decideNode: rejects a decision on a non-pending request', async () => {
214
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
215
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
216
+ await expect(svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS)).rejects.toThrow(/INVALID_STATE/);
220
217
  });
221
218
 
222
- it('approve when not pending INVALID_STATE', async () => {
223
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
224
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
225
- await svc.approve(req.id, { actorId: 'u9' }, CTX);
226
- await expect(svc.approve(req.id, { actorId: 'u9' }, SYS)).rejects.toThrow(/INVALID_STATE/);
219
+ it('decideNode: mirrors the terminal status onto the business record', async () => {
220
+ engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
221
+ const req = await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
222
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
223
+ expect(engine._tables['opportunity'][0].approval_status).toBe('approved');
227
224
  });
228
225
 
229
- // ── Reject ─────────────────────────────────────────────────────
226
+ // ── decide(): public contract + resume bridge ───────────────────
230
227
 
231
- it('reject default finalized rejected', async () => {
232
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
233
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
234
- const out = await svc.reject(req.id, { actorId: 'u9', comment: 'no' }, CTX);
228
+ it('decide: resumes the owning run down the matching branch on finalize', async () => {
229
+ const resumed: any[] = [];
230
+ svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
231
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
232
+ const out = await svc.decide(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
235
233
  expect(out.finalized).toBe(true);
236
- expect(out.request.status).toBe('rejected');
234
+ expect(out.resumed).toBe(true);
235
+ expect(out.runId).toBe('run_1');
236
+ expect(resumed).toHaveLength(1);
237
+ expect(resumed[0]).toMatchObject({ runId: 'run_1', signal: { branchLabel: 'approve' } });
237
238
  });
238
239
 
239
- it('reject back_to_previous: advances back', async () => {
240
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: multiStep() }, CTX);
241
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
242
- await svc.approve(req.id, { actorId: 'u1' }, CTX); // advance to step2
243
- const out = await svc.reject(req.id, { actorId: 'u2' }, CTX);
240
+ it('decide: does not resume while a unanimous request is still pending', async () => {
241
+ const resumed: any[] = [];
242
+ svc.attachAutomation({ async resume(runId) { resumed.push(runId); } });
243
+ const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX);
244
+ const out = await svc.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
244
245
  expect(out.finalized).toBe(false);
245
- expect(out.request.current_step_index).toBe(0);
246
- expect(out.request.current_step).toBe('step1');
247
- expect(out.request.pending_approvers).toEqual(['u1']);
246
+ expect(out.resumed).toBe(false);
247
+ expect(resumed).toHaveLength(0);
248
248
  });
249
249
 
250
- // ── Recall ─────────────────────────────────────────────────────
251
-
252
- it('recall by submitter status recalled', async () => {
253
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
254
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'u1' }, CTX);
255
- const out = await svc.recall(req.id, { actorId: 'u1' }, CTX);
250
+ it('decide: finalizes even when no automation is attached (resumed=false)', async () => {
251
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
252
+ const out = await svc.decide(req.id, { decision: 'reject', actorId: 'u9' }, SYS);
256
253
  expect(out.finalized).toBe(true);
257
- expect(out.request.status).toBe('recalled');
254
+ expect(out.resumed).toBe(false);
258
255
  });
259
256
 
260
- it('recall by non-submitter FORBIDDEN', async () => {
261
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
262
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'u1' }, CTX);
263
- await expect(svc.recall(req.id, { actorId: 'mallory' }, CTX)).rejects.toThrow(/FORBIDDEN/);
264
- });
265
-
266
- // ── Listing ────────────────────────────────────────────────────
257
+ // ── read API ────────────────────────────────────────────────────
267
258
 
268
- it('listRequests: filters by approverId via post-filter', async () => {
269
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
270
- await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
271
- await svc.submit({ object: 'opportunity', recordId: 'opp2' }, CTX);
272
- const mine = await svc.listRequests({ approverId: 'u9' }, CTX);
273
- expect(mine.length).toBe(2);
274
- const empty = await svc.listRequests({ approverId: 'noone' }, CTX);
275
- expect(empty.length).toBe(0);
259
+ it('listRequests: filters by approver and status', async () => {
260
+ await svc.openNodeRequest(openInput(['u9']), CTX);
261
+ const pending = await svc.listRequests({ status: 'pending', approverId: 'u9' }, SYS);
262
+ expect(pending).toHaveLength(1);
263
+ const none = await svc.listRequests({ approverId: 'nobody' }, SYS);
264
+ expect(none).toHaveLength(0);
276
265
  });
277
266
 
278
- it('listActions: returns rows ordered by created_at ASC', async () => {
279
- await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
280
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
281
- await svc.approve(req.id, { actorId: 'u9' }, CTX);
282
- const actions = await svc.listActions(req.id, CTX);
267
+ it('listActions: returns the audit trail for a request', async () => {
268
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
269
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
270
+ const actions = await svc.listActions(req.id, SYS);
283
271
  expect(actions.map(a => a.action)).toEqual(['submit', 'approve']);
284
272
  });
285
273
 
286
- // ── Graph expansion (M10.17.1) ───────────────────────────────────
287
-
288
- it('approver type=team expands flat sys_team_member', async () => {
289
- engine._tables.sys_team = [{ id: 'sales', name: 'sales', organization_id: 't1' }];
290
- engine._tables.sys_team_member = [
291
- { id: 'tm1', team_id: 'sales', user_id: 'alice' },
292
- { id: 'tm2', team_id: 'sales', user_id: 'bob' },
293
- ];
294
- await svc.defineProcess({
295
- name: 'team_proc', label: 'TeamProc', object: 'opportunity',
296
- definition: {
297
- name: 'team_proc', label: 'TeamProc', object: 'opportunity', active: true,
298
- steps: [{ name: 's1', label: 'S1', approvers: [{ type: 'team', value: 'sales' }] }],
299
- },
300
- }, CTX);
301
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
302
- expect(req.pending_approvers?.sort()).toEqual(['alice', 'bob']);
274
+ it('getRequest: returns null for an unknown id', async () => {
275
+ expect(await svc.getRequest('nope', SYS)).toBeNull();
303
276
  });
277
+ });
304
278
 
305
- it('approver type=department walks parent_department_id (BFS)', async () => {
306
- engine._tables.sys_department = [
307
- { id: 'emea', name: 'EMEA', parent_department_id: null, organization_id: 't1', active: true },
308
- { id: 'emea_sales', name: 'EMEA Sales', parent_department_id: 'emea', organization_id: 't1', active: true },
309
- { id: 'emea_sales_uk', name: 'EMEA Sales UK', parent_department_id: 'emea_sales', organization_id: 't1', active: true },
310
- ];
311
- engine._tables.sys_department_member = [
312
- { id: 'dm1', department_id: 'emea', user_id: 'eva' },
313
- { id: 'dm2', department_id: 'emea_sales_uk', user_id: 'alice' },
314
- ];
315
- await svc.defineProcess({
316
- name: 'dept_proc', label: 'DeptProc', object: 'opportunity',
317
- definition: {
318
- name: 'dept_proc', label: 'DeptProc', object: 'opportunity', active: true,
319
- steps: [{ name: 's1', label: 'S1', approvers: [{ type: 'department', value: 'emea' }] }],
320
- },
321
- }, CTX);
322
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
323
- expect(req.pending_approvers?.sort()).toEqual(['alice', 'eva']);
324
- });
279
+ describe('record-lock hook (node era)', () => {
280
+ let engine: ReturnType<typeof makeFakeEngine>;
281
+ let svc: ApprovalService;
282
+ let n = 0;
283
+ const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
325
284
 
326
- it('approver type=department with no rows falls back to prefixed literal', async () => {
327
- await svc.defineProcess({
328
- name: 'dept_proc2', label: 'DeptProc', object: 'opportunity',
329
- definition: {
330
- name: 'dept_proc2', label: 'DeptProc', object: 'opportunity', active: true,
331
- steps: [{ name: 's1', label: 'S1', approvers: [{ type: 'department', value: 'unknown' }] }],
332
- },
333
- }, CTX);
334
- const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
335
- expect(req.pending_approvers).toEqual(['department:unknown']);
285
+ beforeEach(async () => {
286
+ engine = makeFakeEngine();
287
+ n = 0;
288
+ svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
289
+ bindApprovalLockHook(engine as any);
290
+ await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
336
291
  });
337
292
 
338
- // ── ADR-0009 execution pinning ─────────────────────────────────
339
-
340
- describe('execution pinning (ADR-0009)', () => {
341
- // Minimal fake metadata repo: keeps a map of (name → versions[]) where
342
- // each version has a hash. Mirrors MetadataRepository.get/getByHash.
343
- function makeFakeMetadataRepo() {
344
- const versions = new Map<string, { hash: string; body: any }[]>();
345
- return {
346
- store(name: string, body: any) {
347
- const hash = `sha256:${name}_${(versions.get(name)?.length ?? 0) + 1}`;
348
- const list = versions.get(name) ?? [];
349
- list.push({ hash, body });
350
- versions.set(name, list);
351
- },
352
- async get(ref: any) {
353
- const list = versions.get(ref.name);
354
- if (!list?.length) return null;
355
- const head = list[list.length - 1];
356
- return { ref, hash: head.hash, body: head.body, seq: list.length, version: list.length, parentHash: null };
357
- },
358
- async getByHash(ref: any, hash: string) {
359
- const list = versions.get(ref.name);
360
- const found = list?.find(v => v.hash === hash);
361
- return found ? { ref, hash: found.hash, body: found.body, seq: 1, version: 1, parentHash: null } : null;
362
- },
363
- async put() { throw new Error('not implemented'); },
364
- async delete() { throw new Error('not implemented'); },
365
- list() { return (async function* () {})(); },
366
- async *history() {},
367
- watch() { return () => {}; },
368
- async start() {},
369
- async stop() {},
370
- } as any;
371
- }
293
+ it('blocks a user edit to a record with a pending approval', async () => {
294
+ await expect(
295
+ engine.fire('beforeUpdate', {
296
+ object: 'opportunity',
297
+ input: { id: 'opp1', data: { amount: 200 } },
298
+ session: { isSystem: false, roles: [], userId: 'u1' },
299
+ }),
300
+ ).rejects.toThrow(/RECORD_LOCKED/);
301
+ });
372
302
 
373
- it('submit records process_hash when metadataRepo is wired', async () => {
374
- const repo = makeFakeMetadataRepo();
375
- const v1 = multiStep();
376
- repo.store('proc', v1);
303
+ it('allows a status-mirror write (only the approvalStatusField changes)', async () => {
304
+ await expect(
305
+ engine.fire('beforeUpdate', {
306
+ object: 'opportunity',
307
+ input: { id: 'opp1', data: { approval_status: 'approved' } },
308
+ session: { isSystem: false, roles: [] },
309
+ }),
310
+ ).resolves.toBeUndefined();
311
+ });
377
312
 
378
- const pinned = new ApprovalService({
379
- engine: engine as any,
380
- clock: { now: () => new Date(baseTime + (n++) * 1000) },
381
- metadataRepo: repo,
382
- });
383
- await pinned.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: v1 }, CTX);
313
+ it('allows engine self-writes (system session)', async () => {
314
+ await expect(
315
+ engine.fire('beforeUpdate', {
316
+ object: 'opportunity',
317
+ input: { id: 'opp1', data: { amount: 200 } },
318
+ session: { isSystem: true, roles: [] },
319
+ }),
320
+ ).resolves.toBeUndefined();
321
+ });
384
322
 
385
- const req = await pinned.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
386
- expect(req.process_hash).toMatch(/^sha256:proc_1$/);
387
- });
323
+ it('allows an admin override', async () => {
324
+ await expect(
325
+ engine.fire('beforeUpdate', {
326
+ object: 'opportunity',
327
+ input: { id: 'opp1', data: { amount: 200 } },
328
+ session: { isSystem: false, roles: ['admin'] },
329
+ }),
330
+ ).resolves.toBeUndefined();
331
+ });
388
332
 
389
- it('process upgrade after submit does NOT affect an in-flight request', async () => {
390
- const repo = makeFakeMetadataRepo();
391
- const v1 = multiStep(); // 2 steps: u1 → u2
392
- repo.store('proc', v1);
393
-
394
- const pinned = new ApprovalService({
395
- engine: engine as any,
396
- clock: { now: () => new Date(baseTime + (n++) * 1000) },
397
- metadataRepo: repo,
398
- });
399
- await pinned.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: v1 }, CTX);
400
-
401
- // Submit the request — pinned to v1 (2 steps).
402
- const req = await pinned.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
403
- expect(req.pending_approvers).toEqual(['u1']);
404
-
405
- // After submit, the process gets a brand new third step appended.
406
- const v2 = {
407
- name: 'proc', label: 'P', object: 'opportunity', active: true,
408
- steps: [
409
- ...v1.steps,
410
- { name: 'step3', label: 'Step 3', approvers: [{ type: 'user' as const, value: 'u3' }], behavior: 'first_response' as const },
411
- ],
412
- };
413
- repo.store('proc', v2);
414
- // Also refresh the projection — simulates redeploy.
415
- await pinned.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: v2 }, CTX);
416
-
417
- // Step 1 approver acts → request advances to step 2 (pinned v1, NOT v2).
418
- const r1 = await pinned.approve(req.id, { actorId: 'u1' }, CTX);
419
- expect(r1.request.current_step).toBe('step2');
420
- expect(r1.request.pending_approvers).toEqual(['u2']);
421
- expect(r1.finalized).toBe(false);
422
-
423
- // Step 2 approver finalises → pinned process has only 2 steps, so
424
- // the request becomes `approved` instead of advancing to v2's step3.
425
- const r2 = await pinned.approve(req.id, { actorId: 'u2' }, CTX);
426
- expect(r2.finalized).toBe(true);
427
- expect(r2.request.status).toBe('approved');
428
- });
333
+ it('does not lock records without a pending request', async () => {
334
+ await expect(
335
+ engine.fire('beforeUpdate', {
336
+ object: 'opportunity',
337
+ input: { id: 'other_record', data: { amount: 200 } },
338
+ session: { isSystem: false, roles: [] },
339
+ }),
340
+ ).resolves.toBeUndefined();
341
+ });
429
342
 
430
- it('falls back to projection when metadataRepo has no head (e.g. defineProcess-only path)', async () => {
431
- const repo = makeFakeMetadataRepo();
432
- // Note: repo has NO body for 'proc' — only the projection table does.
433
- const pinned = new ApprovalService({
434
- engine: engine as any,
435
- clock: { now: () => new Date(baseTime + (n++) * 1000) },
436
- metadataRepo: repo,
437
- });
438
- await pinned.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: multiStep() }, CTX);
439
- const req = await pinned.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
440
- expect(req.process_hash).toBeUndefined();
441
- // approve still works through the fallback path.
442
- const r = await pinned.approve(req.id, { actorId: 'u1' }, CTX);
443
- expect(r.request.current_step).toBe('step2');
444
- });
343
+ it('unbindAllHooks removes the lock hook', () => {
344
+ expect(unbindAllHooks(engine as any)).toBe(1);
345
+ expect(engine._hooks['beforeUpdate']).toHaveLength(0);
445
346
  });
446
347
  });