@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +75 -0
- package/dist/index.d.mts +6431 -107
- package/dist/index.d.ts +6431 -107
- package/dist/index.js +1237 -776
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1244 -779
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -7
- package/scripts/i18n-extract.config.ts +32 -0
- package/src/approval-node.test.ts +182 -0
- package/src/approval-node.ts +131 -0
- package/src/approval-service.test.ts +205 -304
- package/src/approval-service.ts +208 -491
- package/src/approvals-plugin.ts +61 -53
- package/src/index.ts +12 -11
- package/src/lifecycle-hooks.ts +67 -202
- package/src/nav-contribution.test.ts +46 -0
- package/src/sys-approval-action.object.ts +120 -0
- package/src/sys-approval-request.object.ts +227 -0
- package/src/translations/en.objects.generated.ts +156 -0
- package/src/translations/es-ES.objects.generated.ts +156 -0
- package/src/translations/index.ts +23 -0
- package/src/translations/ja-JP.objects.generated.ts +156 -0
- package/src/translations/zh-CN.objects.generated.ts +156 -0
- package/src/action-executor.ts +0 -313
- package/src/phase-b.test.ts +0 -263
|
@@ -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
|
|
103
|
+
function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
|
|
70
104
|
return {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
// ──
|
|
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('
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
expect(
|
|
130
|
-
expect(
|
|
131
|
-
expect(
|
|
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('
|
|
135
|
-
await
|
|
136
|
-
|
|
137
|
-
|
|
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('
|
|
142
|
-
await svc.
|
|
143
|
-
await svc.
|
|
144
|
-
|
|
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('
|
|
150
|
-
|
|
151
|
-
expect(
|
|
152
|
-
expect(
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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('
|
|
177
|
-
await
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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('
|
|
189
|
-
expect(out.request.completed_at).toBeTruthy();
|
|
193
|
+
expect(out.request.status).toBe('rejected');
|
|
190
194
|
});
|
|
191
195
|
|
|
192
|
-
it('
|
|
193
|
-
await svc.
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
expect(
|
|
197
|
-
|
|
198
|
-
expect(
|
|
199
|
-
expect(
|
|
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('
|
|
206
|
-
await svc.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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('
|
|
217
|
-
await svc.
|
|
218
|
-
|
|
219
|
-
await expect(svc.
|
|
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('
|
|
223
|
-
|
|
224
|
-
const req = await svc.
|
|
225
|
-
await svc.
|
|
226
|
-
|
|
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
|
-
// ──
|
|
226
|
+
// ── decide(): public contract + resume bridge ───────────────────
|
|
230
227
|
|
|
231
|
-
it('
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
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.
|
|
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('
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
await svc.
|
|
243
|
-
const out = await svc.
|
|
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.
|
|
246
|
-
expect(
|
|
247
|
-
expect(out.request.pending_approvers).toEqual(['u1']);
|
|
246
|
+
expect(out.resumed).toBe(false);
|
|
247
|
+
expect(resumed).toHaveLength(0);
|
|
248
248
|
});
|
|
249
249
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
254
|
+
expect(out.resumed).toBe(false);
|
|
258
255
|
});
|
|
259
256
|
|
|
260
|
-
|
|
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
|
|
269
|
-
await svc.
|
|
270
|
-
await svc.
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
expect(
|
|
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
|
|
279
|
-
await svc.
|
|
280
|
-
|
|
281
|
-
await svc.
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
});
|