@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.1
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/CHANGELOG.md +1462 -0
- package/dist/index.d.mts +2853 -3925
- package/dist/index.d.ts +2853 -3925
- package/dist/index.js +1819 -240
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1817 -230
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -7
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -33
- package/src/action-link-pages.ts +0 -102
- package/src/approval-node.test.ts +0 -196
- package/src/approval-node.ts +0 -139
- package/src/approval-revise.test.ts +0 -411
- package/src/approval-service.test.ts +0 -1490
- package/src/approval-service.ts +0 -2360
- package/src/approvals-plugin.ts +0 -263
- package/src/index.ts +0 -39
- package/src/lifecycle-hooks.ts +0 -179
- package/src/nav-contribution.test.ts +0 -50
- package/src/sys-approval-action.object.ts +0 -140
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -103
- package/src/sys-approval-request.object.ts +0 -401
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/en.objects.generated.ts +0 -205
- package/src/translations/es-ES.objects.generated.ts +0 -205
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -205
- package/src/translations/zh-CN.objects.generated.ts +0 -205
- package/tsconfig.json +0 -10
|
@@ -1,1490 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
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
|
-
|
|
12
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
13
|
-
import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js';
|
|
14
|
-
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
|
|
15
|
-
|
|
16
|
-
interface FakeRow { [k: string]: any }
|
|
17
|
-
|
|
18
|
-
function makeFakeEngine() {
|
|
19
|
-
const tables: Record<string, FakeRow[]> = {};
|
|
20
|
-
const ensure = (n: string) => (tables[n] ??= []);
|
|
21
|
-
const hooks: Record<string, Array<{ handler: (ctx: any) => any | Promise<any>; object?: string | string[]; packageId?: string }>> = {};
|
|
22
|
-
|
|
23
|
-
function matches(row: FakeRow, filter: any): boolean {
|
|
24
|
-
if (!filter || typeof filter !== 'object') return true;
|
|
25
|
-
for (const [k, v] of Object.entries(filter)) {
|
|
26
|
-
if (k === '$or') {
|
|
27
|
-
if (!(v as any[]).some(sub => matches(row, sub))) return false;
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
const rv = row[k];
|
|
31
|
-
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
|
|
32
|
-
if (!(v as any).$in.includes(rv)) return false;
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
|
|
36
|
-
if (rv === (v as any).$ne) return false;
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
if (v != null && typeof v === 'object' && '$contains' in (v as any)) {
|
|
40
|
-
if (!String(rv ?? '').includes(String((v as any).$contains))) return false;
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
if (rv !== v) return false;
|
|
44
|
-
}
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
_tables: tables,
|
|
50
|
-
_hooks: hooks,
|
|
51
|
-
async find(object: string, options?: any) {
|
|
52
|
-
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
|
|
53
|
-
if (options?.orderBy?.[0]) {
|
|
54
|
-
// Canonical SortNode key only (spec/data/query.zod.ts): a sloppy
|
|
55
|
-
// `direction:` key must fall through to the schema default (asc),
|
|
56
|
-
// exactly like the real engine — that's how the remind() cool-down
|
|
57
|
-
// regression stayed invisible when this mock honored both keys.
|
|
58
|
-
const { field, order } = options.orderBy[0];
|
|
59
|
-
rows.sort((a, b) => {
|
|
60
|
-
const av = a[field]; const bv = b[field];
|
|
61
|
-
if (av === bv) return 0;
|
|
62
|
-
const cmp = av > bv ? 1 : -1;
|
|
63
|
-
return order === 'desc' ? -cmp : cmp;
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
const start = options?.offset ?? 0;
|
|
67
|
-
return rows.slice(start, start + (options?.limit ?? 1000));
|
|
68
|
-
},
|
|
69
|
-
async insert(object: string, data: any) {
|
|
70
|
-
ensure(object).push({ ...data });
|
|
71
|
-
return { ...data };
|
|
72
|
-
},
|
|
73
|
-
async update(object: string, idOrData: any, _opts?: any) {
|
|
74
|
-
const data = typeof idOrData === 'object' ? idOrData : _opts;
|
|
75
|
-
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
|
|
76
|
-
const table = ensure(object);
|
|
77
|
-
const i = table.findIndex(r => r.id === id);
|
|
78
|
-
if (i >= 0) table[i] = { ...table[i], ...data };
|
|
79
|
-
return table[i];
|
|
80
|
-
},
|
|
81
|
-
async delete(object: string, options?: any) {
|
|
82
|
-
const table = ensure(object);
|
|
83
|
-
const id = options?.where?.id ?? options?.id;
|
|
84
|
-
const i = table.findIndex(r => r.id === id);
|
|
85
|
-
if (i >= 0) table.splice(i, 1);
|
|
86
|
-
return { id };
|
|
87
|
-
},
|
|
88
|
-
// ── hook surface (for the record-lock hook) ──
|
|
89
|
-
registerHook(event: string, handler: (ctx: any) => any, options?: any) {
|
|
90
|
-
(hooks[event] ??= []).push({ handler, object: options?.object, packageId: options?.packageId });
|
|
91
|
-
},
|
|
92
|
-
unregisterHooksByPackage(packageId: string): number {
|
|
93
|
-
let n = 0;
|
|
94
|
-
for (const ev of Object.keys(hooks)) {
|
|
95
|
-
const before = hooks[ev].length;
|
|
96
|
-
hooks[ev] = hooks[ev].filter(h => h.packageId !== packageId);
|
|
97
|
-
n += before - hooks[ev].length;
|
|
98
|
-
}
|
|
99
|
-
return n;
|
|
100
|
-
},
|
|
101
|
-
async fire(event: string, ctx: any) {
|
|
102
|
-
for (const h of hooks[event] ?? []) {
|
|
103
|
-
if (h.object) {
|
|
104
|
-
const objs = Array.isArray(h.object) ? h.object : [h.object];
|
|
105
|
-
if (!objs.includes(ctx.object)) continue;
|
|
106
|
-
}
|
|
107
|
-
await h.handler(ctx);
|
|
108
|
-
}
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;
|
|
114
|
-
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
|
|
115
|
-
|
|
116
|
-
function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
|
|
117
|
-
return {
|
|
118
|
-
approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
|
|
119
|
-
behavior: 'first_response' as const,
|
|
120
|
-
lockRecord: true,
|
|
121
|
-
...extra,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function openInput(approvers: string[], extra: Record<string, any> = {}, configExtra: Record<string, any> = {}) {
|
|
126
|
-
return {
|
|
127
|
-
object: 'opportunity',
|
|
128
|
-
recordId: 'opp1',
|
|
129
|
-
runId: 'run_1',
|
|
130
|
-
nodeId: 'approve_step',
|
|
131
|
-
flowName: 'deal_approval',
|
|
132
|
-
config: nodeConfig(approvers, configExtra),
|
|
133
|
-
record: { id: 'opp1', amount: 100 },
|
|
134
|
-
...extra,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
describe('ApprovalService (node era)', () => {
|
|
139
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
140
|
-
let svc: ApprovalService;
|
|
141
|
-
let n = 0;
|
|
142
|
-
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
143
|
-
|
|
144
|
-
beforeEach(() => {
|
|
145
|
-
engine = makeFakeEngine();
|
|
146
|
-
n = 0;
|
|
147
|
-
svc = new ApprovalService({
|
|
148
|
-
engine: engine as any,
|
|
149
|
-
clock: { now: () => new Date(baseTime + (n++) * 1000) },
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
// ── openNodeRequest ─────────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
it('openNodeRequest: creates a pending request + submit action with flow correlation', async () => {
|
|
156
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
157
|
-
expect(req.status).toBe('pending');
|
|
158
|
-
expect(req.process_name).toBe('flow:deal_approval');
|
|
159
|
-
expect(req.flow_run_id).toBe('run_1');
|
|
160
|
-
expect(req.flow_node_id).toBe('approve_step');
|
|
161
|
-
expect(req.pending_approvers).toEqual(['u9']);
|
|
162
|
-
expect(engine._tables['sys_approval_request']).toHaveLength(1);
|
|
163
|
-
expect(engine._tables['sys_approval_action'][0].action).toBe('submit');
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('openNodeRequest: snapshots the node config on the row', async () => {
|
|
167
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
168
|
-
const raw = engine._tables['sys_approval_request'][0];
|
|
169
|
-
expect(JSON.parse(raw.node_config_json)).toMatchObject({ behavior: 'first_response', lockRecord: true });
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('openNodeRequest: deduplicates a pending request per (object, record)', async () => {
|
|
173
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
174
|
-
await expect(svc.openNodeRequest(openInput(['u9'], { runId: 'run_2' }), CTX))
|
|
175
|
-
.rejects.toThrow(/DUPLICATE_REQUEST/);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it('openNodeRequest: requires object, recordId, runId', async () => {
|
|
179
|
-
await expect(svc.openNodeRequest(openInput(['u9'], { object: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/);
|
|
180
|
-
await expect(svc.openNodeRequest(openInput(['u9'], { recordId: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/);
|
|
181
|
-
await expect(svc.openNodeRequest(openInput(['u9'], { runId: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('openNodeRequest: mirrors status onto the business record when configured', async () => {
|
|
185
|
-
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
186
|
-
await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
187
|
-
expect(engine._tables['opportunity'][0].approval_status).toBe('pending');
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// ── approver expansion: position (ADR-0090 D3) ──────────────────
|
|
191
|
-
|
|
192
|
-
const positionInput = (extra: Record<string, any> = {}) => ({
|
|
193
|
-
...openInput([]),
|
|
194
|
-
config: {
|
|
195
|
-
approvers: [{ type: 'position' as const, value: 'sales_manager' }],
|
|
196
|
-
behavior: 'first_response' as const,
|
|
197
|
-
lockRecord: true,
|
|
198
|
-
},
|
|
199
|
-
...extra,
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('position approver: expands via sys_user_position, org-scoped', async () => {
|
|
203
|
-
engine._tables['sys_user_position'] = [
|
|
204
|
-
{ id: 'up1', user_id: 'u5', position: 'sales_manager', organization_id: 't1' },
|
|
205
|
-
{ id: 'up2', user_id: 'u6', position: 'sales_manager', organization_id: 't1' },
|
|
206
|
-
{ id: 'up3', user_id: 'u7', position: 'sales_manager', organization_id: 't2' }, // other tenant
|
|
207
|
-
{ id: 'up4', user_id: 'u8', position: 'cfo', organization_id: 't1' }, // other position
|
|
208
|
-
];
|
|
209
|
-
const req = await svc.openNodeRequest(positionInput(), CTX);
|
|
210
|
-
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('position approver: unions the sys_member.role transition source (ADR-0057 D4)', async () => {
|
|
214
|
-
engine._tables['sys_user_position'] = [
|
|
215
|
-
{ id: 'up1', user_id: 'u5', position: 'sales_manager', organization_id: 't1' },
|
|
216
|
-
];
|
|
217
|
-
engine._tables['sys_member'] = [
|
|
218
|
-
{ id: 'm1', user_id: 'u6', role: 'sales_manager', organization_id: 't1' },
|
|
219
|
-
{ id: 'm2', user_id: 'u5', role: 'sales_manager', organization_id: 't1' }, // deduped
|
|
220
|
-
];
|
|
221
|
-
const req = await svc.openNodeRequest(positionInput(), CTX);
|
|
222
|
-
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it('position approver: falls back to a position: literal when nobody holds it', async () => {
|
|
226
|
-
const req = await svc.openNodeRequest(positionInput(), CTX);
|
|
227
|
-
expect(req.pending_approvers).toEqual(['position:sales_manager']);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
// ── approver expansion: org_membership_level + its deprecated `role` alias
|
|
231
|
-
// (ADR-0090 D3) ────────────────────────────────────────────────────────
|
|
232
|
-
|
|
233
|
-
// `recordId` is parameterised: the service rejects a second pending request
|
|
234
|
-
// on the same record, and the alias test deliberately opens two.
|
|
235
|
-
const tierInput = (type: 'org_membership_level' | 'role', recordId = 'opp1') => ({
|
|
236
|
-
...openInput([]),
|
|
237
|
-
recordId,
|
|
238
|
-
record: { id: recordId, amount: 100 },
|
|
239
|
-
config: {
|
|
240
|
-
approvers: [{ type: type as any, value: 'admin' }],
|
|
241
|
-
behavior: 'first_response' as const,
|
|
242
|
-
lockRecord: true,
|
|
243
|
-
},
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('org_membership_level approver: expands the better-auth tier, org-scoped', async () => {
|
|
247
|
-
engine._tables['sys_member'] = [
|
|
248
|
-
{ id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },
|
|
249
|
-
{ id: 'm2', user_id: 'u2', role: 'admin', organization_id: 't1' },
|
|
250
|
-
{ id: 'm3', user_id: 'u3', role: 'admin', organization_id: 't2' }, // other tenant
|
|
251
|
-
{ id: 'm4', user_id: 'u4', role: 'member', organization_id: 't1' }, // other tier
|
|
252
|
-
];
|
|
253
|
-
const req = await svc.openNodeRequest(tierInput('org_membership_level'), CTX);
|
|
254
|
-
expect(req.pending_approvers.sort()).toEqual(['u1', 'u2']);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('deprecated `role` alias resolves IDENTICALLY to org_membership_level', async () => {
|
|
258
|
-
engine._tables['sys_member'] = [
|
|
259
|
-
{ id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },
|
|
260
|
-
{ id: 'm2', user_id: 'u4', role: 'member', organization_id: 't1' },
|
|
261
|
-
];
|
|
262
|
-
const canonical = await svc.openNodeRequest(tierInput('org_membership_level', 'opp_canon'), CTX);
|
|
263
|
-
const deprecated = await svc.openNodeRequest(tierInput('role', 'opp_depr'), CTX);
|
|
264
|
-
expect(deprecated.pending_approvers).toEqual(canonical.pending_approvers);
|
|
265
|
-
expect(deprecated.pending_approvers).toEqual(['u1']);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
// The fallback literal keeps the AUTHORED spelling: `sys_approval_approver`
|
|
269
|
-
// rows and `pending_approvers` slots written by 15.x carry `role:<v>`, and
|
|
270
|
-
// canonicalising the literal here would orphan every one of them.
|
|
271
|
-
it('deprecated `role` alias keeps its legacy literal on fallback (no orphaned slots)', async () => {
|
|
272
|
-
const req = await svc.openNodeRequest(tierInput('role'), CTX);
|
|
273
|
-
expect(req.pending_approvers).toEqual(['role:admin']);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it('org_membership_level falls back to its own canonical literal', async () => {
|
|
277
|
-
const req = await svc.openNodeRequest(tierInput('org_membership_level'), CTX);
|
|
278
|
-
expect(req.pending_approvers).toEqual(['org_membership_level:admin']);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("department approver: honors the spec enum value 'department' (not just the business_unit dialect)", async () => {
|
|
282
|
-
engine._tables['sys_business_unit'] = [
|
|
283
|
-
{ id: 'bu1', organization_id: 't1', active: true },
|
|
284
|
-
{ id: 'bu2', parent_business_unit_id: 'bu1', organization_id: 't1', active: true },
|
|
285
|
-
];
|
|
286
|
-
engine._tables['sys_business_unit_member'] = [
|
|
287
|
-
{ id: 'bm1', business_unit_id: 'bu1', user_id: 'u5' },
|
|
288
|
-
{ id: 'bm2', business_unit_id: 'bu2', user_id: 'u6' },
|
|
289
|
-
];
|
|
290
|
-
const req = await svc.openNodeRequest(positionInput({
|
|
291
|
-
config: {
|
|
292
|
-
approvers: [{ type: 'department' as const, value: 'bu1' }],
|
|
293
|
-
behavior: 'first_response' as const,
|
|
294
|
-
lockRecord: true,
|
|
295
|
-
},
|
|
296
|
-
}), CTX);
|
|
297
|
-
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
// ── decideNode ──────────────────────────────────────────────────
|
|
301
|
-
|
|
302
|
-
it('decideNode: first_response approve finalizes immediately', async () => {
|
|
303
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
304
|
-
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
305
|
-
expect(out.finalized).toBe(true);
|
|
306
|
-
expect(out.decision).toBe('approve');
|
|
307
|
-
expect(out.runId).toBe('run_1');
|
|
308
|
-
expect(out.nodeId).toBe('approve_step');
|
|
309
|
-
expect(out.request.status).toBe('approved');
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it('decideNode: reject finalizes as rejected', async () => {
|
|
313
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
314
|
-
const out = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u9', comment: 'no' }, SYS);
|
|
315
|
-
expect(out.finalized).toBe(true);
|
|
316
|
-
expect(out.request.status).toBe('rejected');
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it('decideNode: unanimous holds until every approver acts', async () => {
|
|
320
|
-
const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX);
|
|
321
|
-
const first = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
322
|
-
expect(first.finalized).toBe(false);
|
|
323
|
-
expect(first.request.pending_approvers).toEqual(['u2']);
|
|
324
|
-
const second = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
|
|
325
|
-
expect(second.finalized).toBe(true);
|
|
326
|
-
expect(second.request.status).toBe('approved');
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it('decideNode: blocks a non-approver in a non-system context', async () => {
|
|
330
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
331
|
-
await expect(
|
|
332
|
-
svc.decideNode(req.id, { decision: 'approve', actorId: 'mallory' }, { isSystem: false, positions: [], permissions: [] } as any),
|
|
333
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
it('decideNode: rejects a decision on a non-pending request', async () => {
|
|
337
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
338
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
339
|
-
await expect(svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS)).rejects.toThrow(/INVALID_STATE/);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it('decideNode: mirrors the terminal status onto the business record', async () => {
|
|
343
|
-
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
344
|
-
const req = await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
345
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
346
|
-
expect(engine._tables['opportunity'][0].approval_status).toBe('approved');
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
// ── decide(): public contract + resume bridge ───────────────────
|
|
350
|
-
|
|
351
|
-
it('decide: resumes the owning run down the matching branch on finalize', async () => {
|
|
352
|
-
const resumed: any[] = [];
|
|
353
|
-
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
|
|
354
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
355
|
-
const out = await svc.decide(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
356
|
-
expect(out.finalized).toBe(true);
|
|
357
|
-
expect(out.resumed).toBe(true);
|
|
358
|
-
expect(out.runId).toBe('run_1');
|
|
359
|
-
expect(resumed).toHaveLength(1);
|
|
360
|
-
expect(resumed[0]).toMatchObject({ runId: 'run_1', signal: { branchLabel: 'approve' } });
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
it('decide: does not resume while a unanimous request is still pending', async () => {
|
|
364
|
-
const resumed: any[] = [];
|
|
365
|
-
svc.attachAutomation({ async resume(runId) { resumed.push(runId); } });
|
|
366
|
-
const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX);
|
|
367
|
-
const out = await svc.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
368
|
-
expect(out.finalized).toBe(false);
|
|
369
|
-
expect(out.resumed).toBe(false);
|
|
370
|
-
expect(resumed).toHaveLength(0);
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
it('decide: finalizes even when no automation is attached (resumed=false)', async () => {
|
|
374
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
375
|
-
const out = await svc.decide(req.id, { decision: 'reject', actorId: 'u9' }, SYS);
|
|
376
|
-
expect(out.finalized).toBe(true);
|
|
377
|
-
expect(out.resumed).toBe(false);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
// ── read API ────────────────────────────────────────────────────
|
|
381
|
-
|
|
382
|
-
it('listRequests: filters by approver and status', async () => {
|
|
383
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
384
|
-
const pending = await svc.listRequests({ status: 'pending', approverId: 'u9' }, SYS);
|
|
385
|
-
expect(pending).toHaveLength(1);
|
|
386
|
-
const none = await svc.listRequests({ approverId: 'nobody' }, SYS);
|
|
387
|
-
expect(none).toHaveLength(0);
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
it('listRequests: approverId accepts a list and matches ANY identity', async () => {
|
|
391
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
392
|
-
// None of these identities individually except the last is the approver.
|
|
393
|
-
const hit = await svc.listRequests(
|
|
394
|
-
{ status: 'pending', approverId: ['someone-else', 'user@example.com', 'u9'] },
|
|
395
|
-
SYS,
|
|
396
|
-
);
|
|
397
|
-
expect(hit).toHaveLength(1);
|
|
398
|
-
// A list with no matching identity returns nothing.
|
|
399
|
-
const miss = await svc.listRequests({ approverId: ['a', 'b', 'role:viewer'] }, SYS);
|
|
400
|
-
expect(miss).toHaveLength(0);
|
|
401
|
-
// Empty / whitespace-only ids are ignored, not treated as a match-all.
|
|
402
|
-
const ignored = await svc.listRequests({ approverId: ['', ' '] }, SYS);
|
|
403
|
-
expect(ignored).toHaveLength(1);
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
it('listActions: returns the audit trail for a request', async () => {
|
|
407
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
408
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
409
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
410
|
-
expect(actions.map(a => a.action)).toEqual(['submit', 'approve']);
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
it('getRequest: returns null for an unknown id', async () => {
|
|
414
|
-
expect(await svc.getRequest('nope', SYS)).toBeNull();
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
// ── viewer capability (#3310) ───────────────────────────────────
|
|
418
|
-
it('getRequest: viewer.can_act is true for a pending approver, false for the submitter', async () => {
|
|
419
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
|
|
420
|
-
const asApprover = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
|
|
421
|
-
expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false });
|
|
422
|
-
const asSubmitter = await svc.getRequest(req.id, { userId: 'u1', tenantId: 't1' } as any);
|
|
423
|
-
expect(asSubmitter!.viewer).toEqual({ can_act: false, is_submitter: true });
|
|
424
|
-
const asOther = await svc.getRequest(req.id, { userId: 'u_stranger', tenantId: 't1' } as any);
|
|
425
|
-
expect(asOther!.viewer).toEqual({ can_act: false, is_submitter: false });
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it('getRequest: viewer.can_act drops to false once the request is finalized', async () => {
|
|
429
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
430
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); // → approved
|
|
431
|
-
const after = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
|
|
432
|
-
expect(after!.status).toBe('approved');
|
|
433
|
-
expect(after!.viewer!.can_act).toBe(false);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
it('listRequests: attaches viewer to every row from the caller context', async () => {
|
|
437
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
438
|
-
const rows = await svc.listRequests({ status: 'pending' }, { userId: 'u9', tenantId: 't1' } as any);
|
|
439
|
-
expect(rows.length).toBeGreaterThan(0);
|
|
440
|
-
expect(rows.every(r => r.viewer != null)).toBe(true);
|
|
441
|
-
expect(rows[0].viewer!.can_act).toBe(true);
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
// ── recall ──────────────────────────────────────────────────────
|
|
445
|
-
|
|
446
|
-
it('recall: submitter withdraws a pending request', async () => {
|
|
447
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
448
|
-
const out = await svc.recall(req.id, { actorId: 'u1', comment: 'changed my mind' }, CTX);
|
|
449
|
-
expect(out.request.status).toBe('recalled');
|
|
450
|
-
expect(out.request.completed_at).toBeTruthy();
|
|
451
|
-
expect(out.request.pending_approvers).toEqual([]);
|
|
452
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
453
|
-
expect(actions.map(a => a.action)).toEqual(['submit', 'recall']);
|
|
454
|
-
expect(actions[1].comment).toBe('changed my mind');
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
it('recall: blocks a non-submitter in a non-system context', async () => {
|
|
458
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
459
|
-
await expect(svc.recall(req.id, { actorId: 'u9' }, { positions: [], permissions: [] } as any))
|
|
460
|
-
.rejects.toThrow(/FORBIDDEN/);
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
it('recall: rejects a recall on a non-pending request', async () => {
|
|
464
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
465
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
466
|
-
await expect(svc.recall(req.id, { actorId: 'u1' }, SYS)).rejects.toThrow(/INVALID_STATE/);
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
it('recall: resumes the owning run down the reject branch with decision=recall', async () => {
|
|
470
|
-
const resumed: any[] = [];
|
|
471
|
-
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
|
|
472
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
473
|
-
const out = await svc.recall(req.id, { actorId: 'u1' }, CTX);
|
|
474
|
-
expect(out.resumed).toBe(true);
|
|
475
|
-
expect(resumed[0]).toMatchObject({
|
|
476
|
-
runId: 'run_1',
|
|
477
|
-
signal: { branchLabel: 'reject', output: { decision: 'recall' } },
|
|
478
|
-
});
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
it('recall: mirrors `recalled` onto the business record when configured', async () => {
|
|
482
|
-
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
483
|
-
const req = await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
484
|
-
await svc.recall(req.id, { actorId: 'u1' }, CTX);
|
|
485
|
-
expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
// ── inbox display fields ────────────────────────────────────────
|
|
489
|
-
|
|
490
|
-
it('rows expose submitted_at as an alias of created_at', async () => {
|
|
491
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
492
|
-
expect(req.submitted_at).toBeTruthy();
|
|
493
|
-
expect(req.submitted_at).toBe(req.created_at);
|
|
494
|
-
const listed = await svc.listRequests({ status: 'pending' }, SYS);
|
|
495
|
-
expect(listed[0].submitted_at).toBe(listed[0].created_at);
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
it('rows carry authored flow/node labels when provided', async () => {
|
|
499
|
-
const req = await svc.openNodeRequest(
|
|
500
|
-
openInput(['u9'], { flowLabel: 'Deal Approval', nodeLabel: 'Manager Review' }), CTX,
|
|
501
|
-
);
|
|
502
|
-
expect(req.process_label).toBe('Deal Approval');
|
|
503
|
-
expect(req.step_label).toBe('Manager Review');
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
it('rows fall back to prettified machine names when labels are absent', async () => {
|
|
507
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
508
|
-
expect(req.process_label).toBe('Deal Approval'); // from `flow:deal_approval`
|
|
509
|
-
expect(req.step_label).toBe('Approve Step'); // from `approve_step`
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
it('listRequests enriches record_title and submitter_name', async () => {
|
|
513
|
-
engine._tables['opportunity'] = [{ id: 'opp1', name: 'Acme Renewal', amount: 100 }];
|
|
514
|
-
engine._tables['sys_user'] = [{ id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' }];
|
|
515
|
-
await svc.openNodeRequest(openInput(['u9']), CTX); // submitter_id = u1 (CTX.userId)
|
|
516
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
517
|
-
expect(rows[0].record_title).toBe('Acme Renewal');
|
|
518
|
-
expect(rows[0].submitter_name).toBe('Ada Lovelace');
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
it('enrichment falls back to the payload snapshot when the record is gone', async () => {
|
|
522
|
-
await svc.openNodeRequest(
|
|
523
|
-
openInput(['u9'], { record: { id: 'opp1', name: 'Snapshot Title', amount: 1 } }), CTX,
|
|
524
|
-
);
|
|
525
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
526
|
-
expect(rows[0].record_title).toBe('Snapshot Title');
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
it('enrichment resolves lookup foreign keys in the payload to record titles', async () => {
|
|
530
|
-
(engine as any).getSchema = (name: string) =>
|
|
531
|
-
name === 'opportunity'
|
|
532
|
-
? { label: 'Opportunity', fields: { name: {}, account: { type: 'lookup', reference: 'account' } } }
|
|
533
|
-
: name === 'account' ? { label: 'Account', fields: { name: {} } } : undefined;
|
|
534
|
-
engine._tables['opportunity'] = [{ id: 'opp1', name: 'Acme Renewal', account: 'acc1' }];
|
|
535
|
-
engine._tables['account'] = [{ id: 'acc1', name: 'Acme Corp' }];
|
|
536
|
-
await svc.openNodeRequest(openInput(['u9'], { record: { id: 'opp1', name: 'Acme Renewal', account: 'acc1' } }), CTX);
|
|
537
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
538
|
-
expect(rows[0].object_label).toBe('Opportunity');
|
|
539
|
-
expect(rows[0].payload_display).toEqual({ account: 'Acme Corp' });
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
it('enrichment maps user-id approvers to display names', async () => {
|
|
543
|
-
engine._tables['sys_user'] = [{ id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }];
|
|
544
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
545
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
546
|
-
expect(rows[0].pending_approver_names).toEqual({ u9: 'Grace Hopper' });
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
it('listActions resolves actor display names', async () => {
|
|
550
|
-
engine._tables['sys_user'] = [
|
|
551
|
-
{ id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' },
|
|
552
|
-
{ id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' },
|
|
553
|
-
];
|
|
554
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
555
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
556
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
557
|
-
expect(actions.map(a => (a as any).actor_name)).toEqual(['Ada Lovelace', 'Grace Hopper']);
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
// ── thread interactions ─────────────────────────────────────────
|
|
561
|
-
|
|
562
|
-
it('reassign: hands the slot to a new approver and audits the move', async () => {
|
|
563
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
|
|
564
|
-
const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
|
|
565
|
-
expect(out.request.pending_approvers).toEqual(['u7', 'u2']);
|
|
566
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
567
|
-
expect(actions.at(-1)).toMatchObject({ action: 'reassign', actor_id: 'u9', comment: 'u9 → u7' });
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
it('reassign: notifies the new approver via messaging', async () => {
|
|
571
|
-
const emitted: any[] = [];
|
|
572
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
573
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
574
|
-
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
|
|
575
|
-
expect(emitted).toHaveLength(1);
|
|
576
|
-
expect(emitted[0]).toMatchObject({ topic: 'approval.reassigned', audience: ['u7'] });
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
it('reassign: blocks a non-holder and duplicate targets', async () => {
|
|
580
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
|
|
581
|
-
await expect(svc.reassign(req.id, { actorId: 'intruder', to: 'u7' }, CTX)).rejects.toThrow(/FORBIDDEN/);
|
|
582
|
-
await expect(svc.reassign(req.id, { actorId: 'u9', to: 'u2' }, CTX)).rejects.toThrow(/VALIDATION_FAILED/);
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
it('remind: notifies pending approvers, audits, and throttles repeats', async () => {
|
|
586
|
-
const emitted: any[] = [];
|
|
587
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
588
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
|
|
589
|
-
const out = await svc.remind(req.id, { actorId: 'u1' }, CTX); // u1 = submitter (CTX.userId)
|
|
590
|
-
expect(out.notified).toBe(2);
|
|
591
|
-
// ADR-0043: per-approver fan-out so each reminder carries personal links.
|
|
592
|
-
const reminders = emitted.filter(e => e.topic === 'approval.reminder');
|
|
593
|
-
expect(reminders.map(r => r.audience)).toEqual([['u9'], ['u2']]);
|
|
594
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
595
|
-
expect(actions.at(-1)?.action).toBe('remind');
|
|
596
|
-
// The fake clock steps 1s per call — well inside the 4h cool-down.
|
|
597
|
-
await expect(svc.remind(req.id, { actorId: 'u1' }, CTX)).rejects.toThrow(/THROTTLED/);
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
it('remind: cool-down measures from the NEWEST reminder, not the first', async () => {
|
|
601
|
-
// Regression: the throttle query sorted with the non-canonical
|
|
602
|
-
// `direction: 'desc'` key, which SortNode strips — so it sorted asc and
|
|
603
|
-
// compared against the FIRST reminder ever sent. Once 4h passed after
|
|
604
|
-
// reminder #1, every later remind() sailed through unthrottled.
|
|
605
|
-
let nowMs = baseTime;
|
|
606
|
-
const localSvc = new ApprovalService({
|
|
607
|
-
engine: engine as any,
|
|
608
|
-
clock: { now: () => new Date(nowMs += 1000) },
|
|
609
|
-
});
|
|
610
|
-
const req = await localSvc.openNodeRequest(openInput(['u9']), CTX);
|
|
611
|
-
await localSvc.remind(req.id, { actorId: 'u1' }, CTX);
|
|
612
|
-
// Jump past the cool-down: a second reminder is legitimately allowed.
|
|
613
|
-
nowMs += REMIND_COOLDOWN_MS;
|
|
614
|
-
await localSvc.remind(req.id, { actorId: 'u1' }, CTX);
|
|
615
|
-
// Immediately after reminder #2 the throttle must bite again — with the
|
|
616
|
-
// wrong sort key it compared against reminder #1 (now >4h old) and let
|
|
617
|
-
// unlimited reminders through.
|
|
618
|
-
await expect(localSvc.remind(req.id, { actorId: 'u1' }, CTX)).rejects.toThrow(/THROTTLED/);
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
it('remind: only the submitter may nudge', async () => {
|
|
622
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
623
|
-
await expect(svc.remind(req.id, { actorId: 'u9' }, { positions: [], permissions: [] } as any))
|
|
624
|
-
.rejects.toThrow(/FORBIDDEN/);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
it('requestInfo: keeps the request pending and notifies the submitter', async () => {
|
|
628
|
-
const emitted: any[] = [];
|
|
629
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
630
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
631
|
-
const out = await svc.requestInfo(req.id, { actorId: 'u9', comment: 'Need the Q3 numbers' }, CTX);
|
|
632
|
-
expect(out.request.status).toBe('pending');
|
|
633
|
-
expect(out.request.pending_approvers).toEqual(['u9']);
|
|
634
|
-
expect(emitted[0]).toMatchObject({ topic: 'approval.request_info', audience: ['u1'] });
|
|
635
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
636
|
-
expect(actions.at(-1)).toMatchObject({ action: 'request_info', comment: 'Need the Q3 numbers' });
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
it('comment: submitter and approver may reply; outsiders may not', async () => {
|
|
640
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
641
|
-
await svc.comment(req.id, { actorId: 'u1', comment: 'Numbers attached.' }, CTX);
|
|
642
|
-
await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' }, CTX);
|
|
643
|
-
await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, { positions: [], permissions: [] } as any))
|
|
644
|
-
.rejects.toThrow(/FORBIDDEN/);
|
|
645
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
646
|
-
expect(actions.filter(a => a.action === 'comment')).toHaveLength(2);
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
// ── actionable links (ADR-0043) ─────────────────────────────────
|
|
650
|
-
|
|
651
|
-
it('issueActionTokens: stores hashes only and binds approver + action', async () => {
|
|
652
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
653
|
-
const tokens = await svc.issueActionTokens(req.id, 'u9');
|
|
654
|
-
expect(tokens.approve).not.toBe(tokens.reject);
|
|
655
|
-
const rows = engine._tables['sys_approval_token'];
|
|
656
|
-
expect(rows).toHaveLength(2);
|
|
657
|
-
expect(rows.every(r => r.token_hash.length === 64)).toBe(true); // sha256 hex, never the raw token
|
|
658
|
-
expect(rows.every(r => !JSON.stringify(r).includes(tokens.approve))).toBe(true);
|
|
659
|
-
await expect(svc.issueActionTokens(req.id, 'stranger')).rejects.toThrow(/FORBIDDEN/);
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
it('redeem: approves as the bound approver and burns the token (single-use)', async () => {
|
|
663
|
-
const resumed: any[] = [];
|
|
664
|
-
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
|
|
665
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
666
|
-
const { approve } = await svc.issueActionTokens(req.id, 'u9');
|
|
667
|
-
const out = await svc.redeemActionToken(approve);
|
|
668
|
-
expect(out).toMatchObject({ ok: true, action: 'approve', approverId: 'u9' });
|
|
669
|
-
expect((out as any).request.status).toBe('approved');
|
|
670
|
-
expect(resumed[0]?.signal?.branchLabel).toBe('approve');
|
|
671
|
-
const acts = await svc.listActions(req.id, SYS);
|
|
672
|
-
expect(acts.at(-1)).toMatchObject({ action: 'approve', actor_id: 'u9', comment: 'Via action link' });
|
|
673
|
-
// replay
|
|
674
|
-
expect(await svc.redeemActionToken(approve)).toMatchObject({ ok: false, reason: 'consumed' });
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
it('peek: validates without consuming (GET never mutates)', async () => {
|
|
678
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
679
|
-
const { reject } = await svc.issueActionTokens(req.id, 'u9');
|
|
680
|
-
expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true, action: 'reject' });
|
|
681
|
-
expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true }); // still live
|
|
682
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
683
|
-
expect(fresh?.status).toBe('pending');
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
it('redeem: dead tokens — invalid, expired, decided request, reassigned slot', async () => {
|
|
687
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
688
|
-
expect(await svc.redeemActionToken('garbage')).toMatchObject({ ok: false, reason: 'invalid' });
|
|
689
|
-
|
|
690
|
-
const short = await svc.issueActionTokens(req.id, 'u9', { ttlMs: 1 });
|
|
691
|
-
// fake clock advances 1s per call — far beyond a 1ms TTL
|
|
692
|
-
expect(await svc.redeemActionToken(short.approve)).toMatchObject({ ok: false, reason: 'expired' });
|
|
693
|
-
|
|
694
|
-
const live = await svc.issueActionTokens(req.id, 'u9');
|
|
695
|
-
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
|
|
696
|
-
expect(await svc.redeemActionToken(live.approve)).toMatchObject({ ok: false, reason: 'not_approver' });
|
|
697
|
-
|
|
698
|
-
const forU7 = await svc.issueActionTokens(req.id, 'u7');
|
|
699
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u7' }, SYS);
|
|
700
|
-
expect(await svc.redeemActionToken(forU7.reject)).toMatchObject({ ok: false, reason: 'not_pending' });
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
it('remind: each concrete approver gets their own action links', async () => {
|
|
704
|
-
const emitted: any[] = [];
|
|
705
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
706
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'ada@example.com']), CTX);
|
|
707
|
-
await svc.remind(req.id, { actorId: 'u1' }, CTX);
|
|
708
|
-
const reminders = emitted.filter(e => e.topic === 'approval.reminder');
|
|
709
|
-
expect(reminders).toHaveLength(2);
|
|
710
|
-
for (const r of reminders) {
|
|
711
|
-
expect(r.audience).toHaveLength(1);
|
|
712
|
-
expect(r.payload.actions).toHaveLength(2);
|
|
713
|
-
expect(r.payload.actions[0].url).toContain('/api/v1/approvals/act?token=');
|
|
714
|
-
}
|
|
715
|
-
const urls = reminders.flatMap(r => r.payload.actions.map((a: any) => a.url));
|
|
716
|
-
expect(new Set(urls).size).toBe(4); // every link is personal + per-action
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
// ── pagination + search pushdown (#1745) ────────────────────────
|
|
720
|
-
|
|
721
|
-
async function openMany(n: number) {
|
|
722
|
-
for (let i = 0; i < n; i++) {
|
|
723
|
-
await svc.openNodeRequest(openInput(['u9'], {
|
|
724
|
-
recordId: `opp${i}`, record: { id: `opp${i}`, name: `Deal ${i}` },
|
|
725
|
-
}), CTX);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
it('listRequests: windows pushable queries newest-first with limit/offset', async () => {
|
|
730
|
-
await openMany(5);
|
|
731
|
-
const page1 = await svc.listRequests({ limit: 2, offset: 0 }, SYS);
|
|
732
|
-
const page2 = await svc.listRequests({ limit: 2, offset: 2 }, SYS);
|
|
733
|
-
expect(page1.map(r => r.record_id)).toEqual(['opp4', 'opp3']); // created_at desc
|
|
734
|
-
expect(page2.map(r => r.record_id)).toEqual(['opp2', 'opp1']);
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
it('listRequests: q matches the payload snapshot (record titles) via pushdown', async () => {
|
|
738
|
-
await openMany(3);
|
|
739
|
-
const hit = await svc.listRequests({ q: 'Deal 1', limit: 10 }, SYS);
|
|
740
|
-
expect(hit.map(r => r.record_id)).toEqual(['opp1']);
|
|
741
|
-
const miss = await svc.listRequests({ q: 'no-such-thing', limit: 10 }, SYS);
|
|
742
|
-
expect(miss).toHaveLength(0);
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
it('countRequests: returns the unwindowed total for a filter', async () => {
|
|
746
|
-
await openMany(4);
|
|
747
|
-
expect(await svc.countRequests({ status: 'pending' }, SYS)).toBe(4);
|
|
748
|
-
expect(await svc.countRequests({ q: 'Deal 2' }, SYS)).toBe(1);
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
it('listRequests: approver queries resolve via the index and window engine-side', async () => {
|
|
752
|
-
await openMany(4); // approver u9 on all
|
|
753
|
-
await svc.openNodeRequest(openInput(['someone-else'], {
|
|
754
|
-
recordId: 'oppX', record: { id: 'oppX', name: 'Other' },
|
|
755
|
-
}), CTX);
|
|
756
|
-
const page = await svc.listRequests({ approverId: 'u9', limit: 2, offset: 2 }, SYS);
|
|
757
|
-
expect(page).toHaveLength(2);
|
|
758
|
-
expect(page.every(r => r.pending_approvers?.includes('u9'))).toBe(true);
|
|
759
|
-
expect(await svc.countRequests({ approverId: 'u9' }, SYS)).toBe(4);
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
it('listRequests/countRequests: status arrays push down as $in', async () => {
|
|
763
|
-
await openMany(3);
|
|
764
|
-
const all = await svc.listRequests({ status: 'pending' }, SYS);
|
|
765
|
-
await svc.decideNode(all[0].id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
766
|
-
await svc.decideNode(all[1].id, { decision: 'reject', actorId: 'u9' }, SYS);
|
|
767
|
-
const done = await svc.listRequests({ status: ['approved', 'rejected'] }, SYS);
|
|
768
|
-
expect(done.map(r => r.status).sort()).toEqual(['approved', 'rejected']);
|
|
769
|
-
expect(await svc.countRequests({ status: ['approved', 'rejected'] }, SYS)).toBe(2);
|
|
770
|
-
expect(await svc.countRequests({ status: ['recalled'] }, SYS)).toBe(0);
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
// ── pending-approver index (#1745 join table) ───────────────────
|
|
774
|
-
|
|
775
|
-
const indexRows = () => (engine._tables['sys_approval_approver'] ?? [])
|
|
776
|
-
.map(r => ({ request_id: r.request_id, approver: r.approver }));
|
|
777
|
-
|
|
778
|
-
it('openNodeRequest mirrors every approver identity into the index', async () => {
|
|
779
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'ada@example.com', 'role:finance']), CTX);
|
|
780
|
-
expect(indexRows()).toEqual([
|
|
781
|
-
{ request_id: req.id, approver: 'u9' },
|
|
782
|
-
{ request_id: req.id, approver: 'ada@example.com' },
|
|
783
|
-
{ request_id: req.id, approver: 'role:finance' },
|
|
784
|
-
]);
|
|
785
|
-
});
|
|
786
|
-
|
|
787
|
-
it('decide and recall clear the request\'s index rows', async () => {
|
|
788
|
-
const a = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
789
|
-
await svc.decideNode(a.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
790
|
-
expect(indexRows()).toHaveLength(0);
|
|
791
|
-
|
|
792
|
-
const b = await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
|
|
793
|
-
await svc.recall(b.id, { actorId: 'u1' }, CTX);
|
|
794
|
-
expect(indexRows()).toHaveLength(0);
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
it('unanimous partial approval shrinks the index to the still-pending set', async () => {
|
|
798
|
-
const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX);
|
|
799
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
800
|
-
expect(indexRows()).toEqual([{ request_id: req.id, approver: 'u2' }]);
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
it('reassign and SLA-reassign rewrite the index rows', async () => {
|
|
804
|
-
const req = await svc.openNodeRequest(
|
|
805
|
-
openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
|
|
806
|
-
);
|
|
807
|
-
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, SYS);
|
|
808
|
-
expect(indexRows().map(r => r.approver).sort()).toEqual(['u2', 'u7']);
|
|
809
|
-
|
|
810
|
-
makeOverdue(req.id);
|
|
811
|
-
await svc.runEscalations();
|
|
812
|
-
expect(indexRows()).toEqual([{ request_id: req.id, approver: 'boss' }]);
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
it('approver-filtered pages stay correct past the old 500-row scan window', async () => {
|
|
816
|
-
// 30 u9 requests are the OLDEST rows, buried under 510 newer non-matching
|
|
817
|
-
// ones — the pre-#1745 bounded scan (limit 500, newest-first) could never
|
|
818
|
-
// reach them. Seeded directly: 540 openNodeRequest round-trips are noise.
|
|
819
|
-
const reqs = (engine._tables['sys_approval_request'] ??= []);
|
|
820
|
-
const idx = (engine._tables['sys_approval_approver'] ??= []);
|
|
821
|
-
const ts = (i: number) => new Date(baseTime + i * 1000).toISOString();
|
|
822
|
-
for (let i = 0; i < 30; i++) {
|
|
823
|
-
reqs.push({
|
|
824
|
-
id: `match_${i}`, process_name: 'flow:f', object_name: 'o', record_id: `m${i}`,
|
|
825
|
-
status: 'pending', pending_approvers: 'u9', created_at: ts(i), updated_at: ts(i),
|
|
826
|
-
});
|
|
827
|
-
idx.push({ id: `aapr_m${i}`, request_id: `match_${i}`, approver: 'u9', organization_id: null, created_at: ts(i) });
|
|
828
|
-
}
|
|
829
|
-
for (let i = 0; i < 510; i++) {
|
|
830
|
-
reqs.push({
|
|
831
|
-
id: `noise_${i}`, process_name: 'flow:f', object_name: 'o', record_id: `n${i}`,
|
|
832
|
-
status: 'pending', pending_approvers: 'someone-else', created_at: ts(100 + i), updated_at: ts(100 + i),
|
|
833
|
-
});
|
|
834
|
-
idx.push({ id: `aapr_n${i}`, request_id: `noise_${i}`, approver: 'someone-else', organization_id: null, created_at: ts(100 + i) });
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
expect(await svc.countRequests({ approverId: 'u9' }, SYS)).toBe(30);
|
|
838
|
-
const page = await svc.listRequests({ approverId: 'u9', limit: 10, offset: 20 }, SYS);
|
|
839
|
-
// Newest-first within the matches: offset 20 of 30 → match_9 … match_0.
|
|
840
|
-
expect(page.map(r => r.id)).toEqual(Array.from({ length: 10 }, (_, k) => `match_${9 - k}`));
|
|
841
|
-
expect(page.every(r => r.pending_approvers?.includes('u9'))).toBe(true);
|
|
842
|
-
});
|
|
843
|
-
|
|
844
|
-
it('rebuildApproverIndex backfills legacy rows, drops orphans + stale entries, and is idempotent', async () => {
|
|
845
|
-
const reqs = (engine._tables['sys_approval_request'] ??= []);
|
|
846
|
-
const idx = (engine._tables['sys_approval_approver'] ??= []);
|
|
847
|
-
const ts = new Date(baseTime).toISOString();
|
|
848
|
-
// Legacy pending row written before the index existed.
|
|
849
|
-
reqs.push({
|
|
850
|
-
id: 'legacy_1', process_name: 'flow:f', object_name: 'o', record_id: 'r1',
|
|
851
|
-
status: 'pending', pending_approvers: 'u1,u2', created_at: ts, updated_at: ts,
|
|
852
|
-
});
|
|
853
|
-
// Completed row whose index rows were never cleaned (orphan).
|
|
854
|
-
reqs.push({
|
|
855
|
-
id: 'done_1', process_name: 'flow:f', object_name: 'o', record_id: 'r2',
|
|
856
|
-
status: 'approved', pending_approvers: null, created_at: ts, updated_at: ts,
|
|
857
|
-
});
|
|
858
|
-
idx.push({ id: 'aapr_orphan', request_id: 'done_1', approver: 'u3', organization_id: null, created_at: ts });
|
|
859
|
-
// Pending row whose index drifted (holds an approver no longer in the CSV).
|
|
860
|
-
reqs.push({
|
|
861
|
-
id: 'drift_1', process_name: 'flow:f', object_name: 'o', record_id: 'r3',
|
|
862
|
-
status: 'pending', pending_approvers: 'u5', created_at: ts, updated_at: ts,
|
|
863
|
-
});
|
|
864
|
-
idx.push({ id: 'aapr_stale', request_id: 'drift_1', approver: 'u4', organization_id: null, created_at: ts });
|
|
865
|
-
|
|
866
|
-
const out = await svc.rebuildApproverIndex();
|
|
867
|
-
expect(out).toEqual({ requests: 2, inserted: 3, deleted: 2 }); // +u1 +u2 +u5 / -orphan -stale
|
|
868
|
-
expect(indexRows().sort((a, b) => a.approver.localeCompare(b.approver))).toEqual([
|
|
869
|
-
{ request_id: 'legacy_1', approver: 'u1' },
|
|
870
|
-
{ request_id: 'legacy_1', approver: 'u2' },
|
|
871
|
-
{ request_id: 'drift_1', approver: 'u5' },
|
|
872
|
-
]);
|
|
873
|
-
|
|
874
|
-
const again = await svc.rebuildApproverIndex();
|
|
875
|
-
expect(again).toEqual({ requests: 2, inserted: 0, deleted: 0 });
|
|
876
|
-
});
|
|
877
|
-
|
|
878
|
-
// ── SLA escalation (ADR-0042) ───────────────────────────────────
|
|
879
|
-
|
|
880
|
-
function makeOverdue(reqId: string) {
|
|
881
|
-
// Push created_at into the past so a small timeoutHours is breached.
|
|
882
|
-
const row = engine._tables['sys_approval_request'].find(r => r.id === reqId)!;
|
|
883
|
-
row.created_at = new Date(baseTime - 10 * 3600_000).toISOString();
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
it('runEscalations: notify action messages approvers + escalateTo + submitter, once', async () => {
|
|
887
|
-
const emitted: any[] = [];
|
|
888
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
889
|
-
const req = await svc.openNodeRequest(
|
|
890
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 2, action: 'notify', escalateTo: 'boss', notifySubmitter: true } }), CTX,
|
|
891
|
-
);
|
|
892
|
-
makeOverdue(req.id);
|
|
893
|
-
const first = await svc.runEscalations();
|
|
894
|
-
expect(first.escalated).toBe(1);
|
|
895
|
-
expect(emitted.map(e => e.topic)).toEqual(['approval.sla_breached', 'approval.sla_breached']);
|
|
896
|
-
expect(emitted[0].audience).toEqual(['u9', 'boss']);
|
|
897
|
-
expect(emitted[1].audience).toEqual(['u1']); // submitter
|
|
898
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
899
|
-
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla', comment: 'notify → boss' });
|
|
900
|
-
// Single-shot: second sweep is a no-op.
|
|
901
|
-
const second = await svc.runEscalations();
|
|
902
|
-
expect(second.escalated).toBe(0);
|
|
903
|
-
expect(emitted).toHaveLength(2);
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
it('runEscalations: auto_approve decides as system:sla and resumes the flow', async () => {
|
|
907
|
-
const resumed: any[] = [];
|
|
908
|
-
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
|
|
909
|
-
const req = await svc.openNodeRequest(
|
|
910
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 1, action: 'auto_approve', notifySubmitter: false } }), CTX,
|
|
911
|
-
);
|
|
912
|
-
makeOverdue(req.id);
|
|
913
|
-
const out = await svc.runEscalations();
|
|
914
|
-
expect(out.escalated).toBe(1);
|
|
915
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
916
|
-
expect(fresh?.status).toBe('approved');
|
|
917
|
-
expect(resumed[0]).toMatchObject({ runId: 'run_1', signal: { branchLabel: 'approve' } });
|
|
918
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
919
|
-
expect(actions.map(a => a.action)).toEqual(['submit', 'escalate', 'approve']);
|
|
920
|
-
expect(actions.at(-1)?.actor_id).toBe('system:sla');
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
it('runEscalations: auto_reject decides as system:sla', async () => {
|
|
924
|
-
const req = await svc.openNodeRequest(
|
|
925
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 1, action: 'auto_reject', notifySubmitter: false } }), CTX,
|
|
926
|
-
);
|
|
927
|
-
makeOverdue(req.id);
|
|
928
|
-
await svc.runEscalations();
|
|
929
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
930
|
-
expect(fresh?.status).toBe('rejected');
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
it('runEscalations: reassign replaces the approver set with escalateTo', async () => {
|
|
934
|
-
const req = await svc.openNodeRequest(
|
|
935
|
-
openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
|
|
936
|
-
);
|
|
937
|
-
makeOverdue(req.id);
|
|
938
|
-
await svc.runEscalations();
|
|
939
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
940
|
-
expect(fresh?.status).toBe('pending');
|
|
941
|
-
expect(fresh?.pending_approvers).toEqual(['boss']);
|
|
942
|
-
});
|
|
943
|
-
|
|
944
|
-
it('runEscalations: reassign expands a position escalateTo to its holders (ADR-0090 D3)', async () => {
|
|
945
|
-
engine._tables['sys_user_position'] = [
|
|
946
|
-
{ id: 'up1', user_id: 'u5', position: 'approvals_supervisor', organization_id: 't1' },
|
|
947
|
-
{ id: 'up2', user_id: 'u6', position: 'approvals_supervisor', organization_id: 't1' },
|
|
948
|
-
{ id: 'up3', user_id: 'u7', position: 'approvals_supervisor', organization_id: 't2' }, // other tenant
|
|
949
|
-
];
|
|
950
|
-
const req = await svc.openNodeRequest(
|
|
951
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'approvals_supervisor', notifySubmitter: false } }), CTX,
|
|
952
|
-
);
|
|
953
|
-
makeOverdue(req.id);
|
|
954
|
-
await svc.runEscalations();
|
|
955
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
956
|
-
expect(fresh?.status).toBe('pending');
|
|
957
|
-
expect(fresh?.pending_approvers?.slice().sort()).toEqual(['u5', 'u6']);
|
|
958
|
-
// The audit trail keeps the AUTHORED target, not the expansion.
|
|
959
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
960
|
-
expect(actions.find(a => a.action === 'escalate')?.comment).toBe('reassign → approvals_supervisor');
|
|
961
|
-
});
|
|
962
|
-
|
|
963
|
-
it('runEscalations: notify expands a position escalateTo into the audience', async () => {
|
|
964
|
-
engine._tables['sys_user_position'] = [
|
|
965
|
-
{ id: 'up1', user_id: 'u5', position: 'approvals_supervisor', organization_id: 't1' },
|
|
966
|
-
];
|
|
967
|
-
const emitted: any[] = [];
|
|
968
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
969
|
-
const req = await svc.openNodeRequest(
|
|
970
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 2, action: 'notify', escalateTo: 'approvals_supervisor', notifySubmitter: false } }), CTX,
|
|
971
|
-
);
|
|
972
|
-
makeOverdue(req.id);
|
|
973
|
-
await svc.runEscalations();
|
|
974
|
-
expect(emitted).toHaveLength(1);
|
|
975
|
-
expect(emitted[0].audience).toEqual(['u9', 'u5']);
|
|
976
|
-
});
|
|
977
|
-
|
|
978
|
-
it('runEscalations: skips requests that are not yet due or have no SLA', async () => {
|
|
979
|
-
await svc.openNodeRequest(
|
|
980
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 1000, action: 'auto_approve' } }), CTX,
|
|
981
|
-
);
|
|
982
|
-
await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
|
|
983
|
-
const out = await svc.runEscalations();
|
|
984
|
-
expect(out.scanned).toBe(2);
|
|
985
|
-
expect(out.escalated).toBe(0);
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
// ── SLA + flow steps ────────────────────────────────────────────
|
|
989
|
-
|
|
990
|
-
it('rows expose sla_due_at when the node declares escalation.timeoutHours', async () => {
|
|
991
|
-
const req = await svc.openNodeRequest(
|
|
992
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 48, action: 'notify', notifySubmitter: true } }), CTX,
|
|
993
|
-
);
|
|
994
|
-
expect(req.sla_due_at).toBe(new Date(Date.parse(req.created_at!) + 48 * 3600_000).toISOString());
|
|
995
|
-
const noSla = await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
|
|
996
|
-
expect(noSla.sla_due_at).toBeUndefined();
|
|
997
|
-
});
|
|
998
|
-
|
|
999
|
-
it('getRequest attaches flow_steps from the owning flow graph', async () => {
|
|
1000
|
-
svc.attachAutomation({
|
|
1001
|
-
async getFlow(name: string) {
|
|
1002
|
-
if (name !== 'deal_approval') return null;
|
|
1003
|
-
return {
|
|
1004
|
-
name: 'deal_approval',
|
|
1005
|
-
nodes: [
|
|
1006
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1007
|
-
{ id: 'approve_step', type: 'approval', label: 'Manager Approval' },
|
|
1008
|
-
{ id: 'gate', type: 'decision', label: 'Big?' },
|
|
1009
|
-
{ id: 'exec_step', type: 'approval', label: 'Executive Approval' },
|
|
1010
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1011
|
-
],
|
|
1012
|
-
edges: [
|
|
1013
|
-
{ id: 'e1', source: 'start', target: 'approve_step' },
|
|
1014
|
-
{ id: 'e2', source: 'approve_step', target: 'gate', label: 'approve' },
|
|
1015
|
-
{ id: 'e3', source: 'gate', target: 'exec_step', label: 'true' },
|
|
1016
|
-
{ id: 'e4', source: 'exec_step', target: 'end', label: 'approve' },
|
|
1017
|
-
],
|
|
1018
|
-
};
|
|
1019
|
-
},
|
|
1020
|
-
});
|
|
1021
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1022
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
1023
|
-
expect(fresh?.flow_steps).toEqual([
|
|
1024
|
-
{ id: 'approve_step', label: 'Manager Approval', state: 'current' },
|
|
1025
|
-
{ id: 'exec_step', label: 'Executive Approval', state: 'upcoming' },
|
|
1026
|
-
]);
|
|
1027
|
-
});
|
|
1028
|
-
|
|
1029
|
-
it('enrichment resolves an email submitter via sys_user.email', async () => {
|
|
1030
|
-
engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }];
|
|
1031
|
-
await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX);
|
|
1032
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
1033
|
-
expect(rows[0].submitter_name).toBe('Grace Hopper');
|
|
1034
|
-
});
|
|
1035
|
-
});
|
|
1036
|
-
|
|
1037
|
-
describe('record-lock hook (node era)', () => {
|
|
1038
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1039
|
-
let svc: ApprovalService;
|
|
1040
|
-
let n = 0;
|
|
1041
|
-
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
1042
|
-
|
|
1043
|
-
beforeEach(async () => {
|
|
1044
|
-
engine = makeFakeEngine();
|
|
1045
|
-
n = 0;
|
|
1046
|
-
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
|
|
1047
|
-
bindApprovalLockHook(engine as any);
|
|
1048
|
-
await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
1049
|
-
});
|
|
1050
|
-
|
|
1051
|
-
it('blocks a user edit to a record with a pending approval', async () => {
|
|
1052
|
-
await expect(
|
|
1053
|
-
engine.fire('beforeUpdate', {
|
|
1054
|
-
object: 'opportunity',
|
|
1055
|
-
input: { id: 'opp1', data: { amount: 200 } },
|
|
1056
|
-
session: { isSystem: false, positions: [], userId: 'u1' },
|
|
1057
|
-
}),
|
|
1058
|
-
).rejects.toThrow(/RECORD_LOCKED/);
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1061
|
-
it('allows a status-mirror write (only the approvalStatusField changes)', async () => {
|
|
1062
|
-
await expect(
|
|
1063
|
-
engine.fire('beforeUpdate', {
|
|
1064
|
-
object: 'opportunity',
|
|
1065
|
-
input: { id: 'opp1', data: { approval_status: 'approved' } },
|
|
1066
|
-
session: { isSystem: false, positions: [] },
|
|
1067
|
-
}),
|
|
1068
|
-
).resolves.toBeUndefined();
|
|
1069
|
-
});
|
|
1070
|
-
|
|
1071
|
-
it('allows engine self-writes (system session)', async () => {
|
|
1072
|
-
await expect(
|
|
1073
|
-
engine.fire('beforeUpdate', {
|
|
1074
|
-
object: 'opportunity',
|
|
1075
|
-
input: { id: 'opp1', data: { amount: 200 } },
|
|
1076
|
-
session: { isSystem: true, positions: [] },
|
|
1077
|
-
}),
|
|
1078
|
-
).resolves.toBeUndefined();
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
it('allows an admin override', async () => {
|
|
1082
|
-
await expect(
|
|
1083
|
-
engine.fire('beforeUpdate', {
|
|
1084
|
-
object: 'opportunity',
|
|
1085
|
-
input: { id: 'opp1', data: { amount: 200 } },
|
|
1086
|
-
session: { isSystem: false, roles: ['admin'] },
|
|
1087
|
-
}),
|
|
1088
|
-
).resolves.toBeUndefined();
|
|
1089
|
-
});
|
|
1090
|
-
|
|
1091
|
-
it('does not lock records without a pending request', async () => {
|
|
1092
|
-
await expect(
|
|
1093
|
-
engine.fire('beforeUpdate', {
|
|
1094
|
-
object: 'opportunity',
|
|
1095
|
-
input: { id: 'other_record', data: { amount: 200 } },
|
|
1096
|
-
session: { isSystem: false, positions: [] },
|
|
1097
|
-
}),
|
|
1098
|
-
).resolves.toBeUndefined();
|
|
1099
|
-
});
|
|
1100
|
-
|
|
1101
|
-
it('unbindAllHooks removes the lock hook', () => {
|
|
1102
|
-
expect(unbindAllHooks(engine as any)).toBe(1);
|
|
1103
|
-
expect(engine._hooks['beforeUpdate']).toHaveLength(0);
|
|
1104
|
-
});
|
|
1105
|
-
});
|
|
1106
|
-
|
|
1107
|
-
// ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
|
|
1108
|
-
//
|
|
1109
|
-
// When a resolved individual approver has declared an active OOO delegation,
|
|
1110
|
-
// the slot is rerouted to the delegate at resolution time (never a background
|
|
1111
|
-
// job), audited as `ooo_substitute`, and both parties are notified. Group /
|
|
1112
|
-
// graph approvers (position/team/department/tier) are left untouched.
|
|
1113
|
-
describe('ApprovalService — out-of-office delegation (#1322)', () => {
|
|
1114
|
-
// Mid-window instant for the issue's own example (leave 5/26–5/30).
|
|
1115
|
-
const OOO_NOW = new Date('2026-05-27T10:00:00Z').getTime();
|
|
1116
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1117
|
-
let svc: ApprovalService;
|
|
1118
|
-
let emitted: any[];
|
|
1119
|
-
|
|
1120
|
-
function seedDelegation(rows: Array<Record<string, any>>) {
|
|
1121
|
-
engine._tables['sys_approval_delegation'] = rows.map((r, i) => ({
|
|
1122
|
-
id: `del${i}`,
|
|
1123
|
-
organization_id: 't1',
|
|
1124
|
-
valid_from: '2026-05-26T00:00:00Z',
|
|
1125
|
-
valid_until: '2026-05-30T00:00:00Z',
|
|
1126
|
-
reason: 'Annual leave',
|
|
1127
|
-
...r,
|
|
1128
|
-
}));
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
beforeEach(() => {
|
|
1132
|
-
engine = makeFakeEngine();
|
|
1133
|
-
emitted = [];
|
|
1134
|
-
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(OOO_NOW) } });
|
|
1135
|
-
svc.attachMessaging({ emit: async (m: any) => { emitted.push(m); } });
|
|
1136
|
-
});
|
|
1137
|
-
|
|
1138
|
-
it('type:user — reroutes an out-of-office approver to the delegate', async () => {
|
|
1139
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1140
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1141
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
it('records an ooo_substitute audit action with "A → B — reason"', async () => {
|
|
1145
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1146
|
-
await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1147
|
-
const sub = engine._tables['sys_approval_action'].find((a: any) => a.action === 'ooo_substitute');
|
|
1148
|
-
expect(sub).toBeTruthy();
|
|
1149
|
-
expect(sub.comment).toBe('alice → bob — Annual leave');
|
|
1150
|
-
expect(sub.actor_id).toBeNull(); // system-recorded reroute, no human actor
|
|
1151
|
-
});
|
|
1152
|
-
|
|
1153
|
-
it('notifies both the delegate and the skipped approver', async () => {
|
|
1154
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1155
|
-
await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1156
|
-
const to = emitted.find(e => e.topic === 'approval.ooo_substituted');
|
|
1157
|
-
const from = emitted.find(e => e.topic === 'approval.ooo_skipped');
|
|
1158
|
-
expect(to?.audience).toEqual(['bob']);
|
|
1159
|
-
expect(from?.audience).toEqual(['alice']);
|
|
1160
|
-
});
|
|
1161
|
-
|
|
1162
|
-
it('does not reroute before valid_from (window not yet open)', async () => {
|
|
1163
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_from: '2026-05-28T00:00:00Z' }]);
|
|
1164
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1165
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
1166
|
-
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
|
|
1167
|
-
});
|
|
1168
|
-
|
|
1169
|
-
it('does not reroute at/after valid_until (half-open window)', async () => {
|
|
1170
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_until: '2026-05-27T10:00:00Z' }]);
|
|
1171
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1172
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
it('type:field — reroutes the user stored in the record field', async () => {
|
|
1176
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1177
|
-
const input = {
|
|
1178
|
-
...openInput([]),
|
|
1179
|
-
record: { id: 'opp1', reviewer: 'alice' },
|
|
1180
|
-
config: { approvers: [{ type: 'field', value: 'reviewer' }], behavior: 'first_response', lockRecord: true },
|
|
1181
|
-
};
|
|
1182
|
-
const req = await svc.openNodeRequest(input as any, CTX);
|
|
1183
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
1184
|
-
});
|
|
1185
|
-
|
|
1186
|
-
it('type:manager — reroutes when the resolved manager is out of office', async () => {
|
|
1187
|
-
engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
|
|
1188
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1189
|
-
const input = {
|
|
1190
|
-
...openInput([]),
|
|
1191
|
-
record: { id: 'opp1', owner_id: 'carol' },
|
|
1192
|
-
config: { approvers: [{ type: 'manager', value: 'owner_id' }], behavior: 'first_response', lockRecord: true },
|
|
1193
|
-
};
|
|
1194
|
-
const req = await svc.openNodeRequest(input as any, CTX);
|
|
1195
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
1196
|
-
});
|
|
1197
|
-
|
|
1198
|
-
it('follows a delegation chain A → B → C', async () => {
|
|
1199
|
-
seedDelegation([
|
|
1200
|
-
{ delegator_id: 'alice', delegate_id: 'bob' },
|
|
1201
|
-
{ delegator_id: 'bob', delegate_id: 'carol' },
|
|
1202
|
-
]);
|
|
1203
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1204
|
-
expect(req.pending_approvers).toEqual(['carol']);
|
|
1205
|
-
expect(engine._tables['sys_approval_action'].filter((a: any) => a.action === 'ooo_substitute')).toHaveLength(2);
|
|
1206
|
-
});
|
|
1207
|
-
|
|
1208
|
-
it('stops on a cycle A → B → A without looping', async () => {
|
|
1209
|
-
seedDelegation([
|
|
1210
|
-
{ delegator_id: 'alice', delegate_id: 'bob' },
|
|
1211
|
-
{ delegator_id: 'bob', delegate_id: 'alice' },
|
|
1212
|
-
]);
|
|
1213
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1214
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
1215
|
-
});
|
|
1216
|
-
|
|
1217
|
-
it('ignores a self-delegation (A → A)', async () => {
|
|
1218
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'alice' }]);
|
|
1219
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1220
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
1221
|
-
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
|
|
1222
|
-
});
|
|
1223
|
-
|
|
1224
|
-
it('leaves approvers unchanged when there is no active delegation', async () => {
|
|
1225
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1226
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
1227
|
-
});
|
|
1228
|
-
|
|
1229
|
-
it('does not OOO-substitute group-routed (position) approvers', async () => {
|
|
1230
|
-
engine._tables['sys_user_position'] = [{ id: 'up1', user_id: 'alice', position: 'sales_manager', organization_id: 't1' }];
|
|
1231
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1232
|
-
const input = {
|
|
1233
|
-
...openInput([]),
|
|
1234
|
-
config: { approvers: [{ type: 'position', value: 'sales_manager' }], behavior: 'first_response', lockRecord: true },
|
|
1235
|
-
};
|
|
1236
|
-
const req = await svc.openNodeRequest(input as any, CTX);
|
|
1237
|
-
// Position-routed leave is ADR-0091's job, not this path: the holder stays.
|
|
1238
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
1239
|
-
});
|
|
1240
|
-
|
|
1241
|
-
it('respects tenant scope: a rule scoped to another org does not apply', async () => {
|
|
1242
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: 't2' }]);
|
|
1243
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1244
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
1245
|
-
});
|
|
1246
|
-
|
|
1247
|
-
it('applies a cross-tenant (null org) rule regardless of request tenant', async () => {
|
|
1248
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: null }]);
|
|
1249
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1250
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
1251
|
-
});
|
|
1252
|
-
});
|
|
1253
|
-
|
|
1254
|
-
// ── Delegation self-service write guard (#1322 follow-up) ─────────────
|
|
1255
|
-
//
|
|
1256
|
-
// sys_approval_delegation is apiEnabled CRUD; a member must not be able to
|
|
1257
|
-
// forge a delegation for someone else (delegator_id = victim) and reroute the
|
|
1258
|
-
// victim's approvals. The guard forces delegator_id == acting user for normal
|
|
1259
|
-
// writes; system/admin contexts bypass. Row-ownership on update/delete is the
|
|
1260
|
-
// platform's created_by RLS (not exercised here).
|
|
1261
|
-
describe('sys_approval_delegation write guard (#1322)', () => {
|
|
1262
|
-
const DEL = 'sys_approval_delegation';
|
|
1263
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1264
|
-
|
|
1265
|
-
beforeEach(() => {
|
|
1266
|
-
engine = makeFakeEngine();
|
|
1267
|
-
bindDelegationWriteGuard(engine as any);
|
|
1268
|
-
});
|
|
1269
|
-
|
|
1270
|
-
const fireInsert = (data: any, session: any) =>
|
|
1271
|
-
(engine as any).fire('beforeInsert', { object: DEL, input: { data }, session });
|
|
1272
|
-
const fireUpdate = (data: any, session: any) =>
|
|
1273
|
-
(engine as any).fire('beforeUpdate', { object: DEL, input: { id: data?.id ?? 'd1', data }, session });
|
|
1274
|
-
const member = (userId?: string) => ({ isSystem: false, roles: [], ...(userId ? { userId } : {}) });
|
|
1275
|
-
|
|
1276
|
-
it('allows a member to create their own delegation', async () => {
|
|
1277
|
-
await expect(fireInsert({ delegator_id: 'u1', delegate_id: 'u2' }, member('u1'))).resolves.toBeUndefined();
|
|
1278
|
-
});
|
|
1279
|
-
|
|
1280
|
-
it('rejects a member forging a delegation for someone else', async () => {
|
|
1281
|
-
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
1282
|
-
});
|
|
1283
|
-
|
|
1284
|
-
it('stamps the caller as delegator when omitted on insert', async () => {
|
|
1285
|
-
const data: any = { delegate_id: 'u2' };
|
|
1286
|
-
await fireInsert(data, member('u1'));
|
|
1287
|
-
expect(data.delegator_id).toBe('u1');
|
|
1288
|
-
});
|
|
1289
|
-
|
|
1290
|
-
it('rejects an unauthenticated non-system insert', async () => {
|
|
1291
|
-
await expect(fireInsert({ delegate_id: 'u2' }, member())).rejects.toThrow(/FORBIDDEN/);
|
|
1292
|
-
});
|
|
1293
|
-
|
|
1294
|
-
it('bypasses the guard for system context', async () => {
|
|
1295
|
-
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, { isSystem: true })).resolves.toBeUndefined();
|
|
1296
|
-
});
|
|
1297
|
-
|
|
1298
|
-
it('lets an admin set the delegator to anyone', async () => {
|
|
1299
|
-
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u2' }, { isSystem: false, roles: ['admin'], userId: 'admin1' })).resolves.toBeUndefined();
|
|
1300
|
-
});
|
|
1301
|
-
|
|
1302
|
-
it('rejects a member relabelling delegator on update', async () => {
|
|
1303
|
-
await expect(fireUpdate({ id: 'd1', delegator_id: 'victim' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
1304
|
-
});
|
|
1305
|
-
|
|
1306
|
-
it('allows a member update that does not touch delegator_id', async () => {
|
|
1307
|
-
await expect(fireUpdate({ id: 'd1', valid_until: '2026-06-01T00:00:00Z' }, member('u1'))).resolves.toBeUndefined();
|
|
1308
|
-
});
|
|
1309
|
-
|
|
1310
|
-
it('rejects a batch insert if any row names a foreign delegator', async () => {
|
|
1311
|
-
await expect(fireInsert(
|
|
1312
|
-
[{ delegator_id: 'u1', delegate_id: 'u2' }, { delegator_id: 'victim', delegate_id: 'u3' }],
|
|
1313
|
-
member('u1'),
|
|
1314
|
-
)).rejects.toThrow(/FORBIDDEN/);
|
|
1315
|
-
});
|
|
1316
|
-
});
|
|
1317
|
-
|
|
1318
|
-
// ── Quorum & per-group sign-off (#3266) ───────────────────────────────
|
|
1319
|
-
//
|
|
1320
|
-
// quorum = M-of-N collective sign-off; per_group = one (or minApprovals) from
|
|
1321
|
-
// EACH group (会签). A single rejection is always a veto. Group membership is
|
|
1322
|
-
// snapshotted at open, so OOO-substituted approvers count for their group.
|
|
1323
|
-
describe('ApprovalService — quorum & per_group (#3266)', () => {
|
|
1324
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1325
|
-
let svc: ApprovalService;
|
|
1326
|
-
const base = new Date('2026-08-01T10:00:00Z').getTime();
|
|
1327
|
-
|
|
1328
|
-
beforeEach(() => {
|
|
1329
|
-
engine = makeFakeEngine();
|
|
1330
|
-
let n = 0;
|
|
1331
|
-
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
|
|
1332
|
-
});
|
|
1333
|
-
|
|
1334
|
-
// Build an openNodeRequest input with explicit approver specs + behavior.
|
|
1335
|
-
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
|
|
1336
|
-
...openInput([]),
|
|
1337
|
-
config: { approvers, behavior, lockRecord: true, ...extra },
|
|
1338
|
-
});
|
|
1339
|
-
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
|
|
1340
|
-
|
|
1341
|
-
it('quorum: holds until minApprovals reached, then finalizes', async () => {
|
|
1342
|
-
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
1343
|
-
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
1344
|
-
expect(a.finalized).toBe(false);
|
|
1345
|
-
expect(a.request.status).toBe('pending');
|
|
1346
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
|
|
1347
|
-
expect(b.finalized).toBe(true);
|
|
1348
|
-
expect(b.request.status).toBe('approved');
|
|
1349
|
-
});
|
|
1350
|
-
|
|
1351
|
-
it('quorum: minApprovals clamps to the approver count (no deadlock)', async () => {
|
|
1352
|
-
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2')], 'quorum', { minApprovals: 5 }), CTX);
|
|
1353
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
1354
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
|
|
1355
|
-
expect(b.finalized).toBe(true);
|
|
1356
|
-
});
|
|
1357
|
-
|
|
1358
|
-
it('quorum: any reject is a veto', async () => {
|
|
1359
|
-
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
1360
|
-
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u1' }, SYS);
|
|
1361
|
-
expect(r.finalized).toBe(true);
|
|
1362
|
-
expect(r.request.status).toBe('rejected');
|
|
1363
|
-
});
|
|
1364
|
-
|
|
1365
|
-
it('per_group: advances only when EACH group approves', async () => {
|
|
1366
|
-
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1367
|
-
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
1368
|
-
expect(a.finalized).toBe(false); // finance still pending
|
|
1369
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
|
|
1370
|
-
expect(b.finalized).toBe(true);
|
|
1371
|
-
expect(b.request.status).toBe('approved');
|
|
1372
|
-
});
|
|
1373
|
-
|
|
1374
|
-
it('per_group: two approvals in ONE group do not satisfy another group', async () => {
|
|
1375
|
-
const req = await svc.openNodeRequest(
|
|
1376
|
-
cfg([U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1377
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
1378
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l2' }, SYS);
|
|
1379
|
-
expect(b.finalized).toBe(false); // finance still missing
|
|
1380
|
-
});
|
|
1381
|
-
|
|
1382
|
-
it('per_group: minApprovals=2 needs two from each group', async () => {
|
|
1383
|
-
const req = await svc.openNodeRequest(cfg(
|
|
1384
|
-
[U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance'), U('f2', 'finance')],
|
|
1385
|
-
'per_group', { minApprovals: 2 }), CTX);
|
|
1386
|
-
for (const u of ['l1', 'f1', 'l2']) {
|
|
1387
|
-
const r = await svc.decideNode(req.id, { decision: 'approve', actorId: u }, SYS);
|
|
1388
|
-
expect(r.finalized).toBe(false);
|
|
1389
|
-
}
|
|
1390
|
-
const done = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f2' }, SYS);
|
|
1391
|
-
expect(done.finalized).toBe(true);
|
|
1392
|
-
});
|
|
1393
|
-
|
|
1394
|
-
it('per_group: reject is a veto', async () => {
|
|
1395
|
-
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1396
|
-
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'l1' }, SYS);
|
|
1397
|
-
expect(r.request.status).toBe('rejected');
|
|
1398
|
-
});
|
|
1399
|
-
|
|
1400
|
-
it('per_group: an OOO-substituted member still counts for their group', async () => {
|
|
1401
|
-
engine._tables['sys_approval_delegation'] = [
|
|
1402
|
-
{ id: 'd', delegator_id: 'l1', delegate_id: 'lb', organization_id: 't1', valid_from: null, valid_until: null, reason: 'leave' },
|
|
1403
|
-
];
|
|
1404
|
-
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1405
|
-
expect(req.pending_approvers).toContain('lb');
|
|
1406
|
-
expect(req.pending_approvers).not.toContain('l1');
|
|
1407
|
-
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'lb' }, SYS); // delegate covers legal
|
|
1408
|
-
expect(a.finalized).toBe(false);
|
|
1409
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
|
|
1410
|
-
expect(b.finalized).toBe(true);
|
|
1411
|
-
});
|
|
1412
|
-
|
|
1413
|
-
it('records decision attachments on the audit row', async () => {
|
|
1414
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1415
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_1', 'file_2'] }, SYS);
|
|
1416
|
-
const act = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
|
|
1417
|
-
expect(act.attachments).toEqual(['file_1', 'file_2']);
|
|
1418
|
-
});
|
|
1419
|
-
});
|
|
1420
|
-
|
|
1421
|
-
// ── Decision progress + notification deep links (#2678 P1.5) ──────────
|
|
1422
|
-
describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () => {
|
|
1423
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1424
|
-
let svc: ApprovalService;
|
|
1425
|
-
|
|
1426
|
-
beforeEach(() => {
|
|
1427
|
-
engine = makeFakeEngine();
|
|
1428
|
-
let n = 0;
|
|
1429
|
-
const base = new Date('2026-09-01T09:00:00Z').getTime();
|
|
1430
|
-
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
|
|
1431
|
-
});
|
|
1432
|
-
|
|
1433
|
-
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
|
|
1434
|
-
...openInput([]),
|
|
1435
|
-
config: { approvers, behavior, lockRecord: true, ...extra },
|
|
1436
|
-
});
|
|
1437
|
-
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
|
|
1438
|
-
|
|
1439
|
-
it('per_group: getRequest exposes per-group progress that updates per approval', async () => {
|
|
1440
|
-
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1441
|
-
let row: any = await svc.getRequest(req.id, SYS);
|
|
1442
|
-
expect(row.decision_progress).toMatchObject({ behavior: 'per_group', got: 0, need: 2 });
|
|
1443
|
-
expect(row.decision_progress.groups).toEqual([
|
|
1444
|
-
{ group: 'finance', got: 0, need: 1, satisfied: false },
|
|
1445
|
-
{ group: 'legal', got: 0, need: 1, satisfied: false },
|
|
1446
|
-
]);
|
|
1447
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
1448
|
-
row = await svc.getRequest(req.id, SYS);
|
|
1449
|
-
expect(row.decision_progress.got).toBe(1);
|
|
1450
|
-
expect(row.decision_progress.groups.find((g: any) => g.group === 'legal')).toMatchObject({ got: 1, satisfied: true });
|
|
1451
|
-
expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
|
|
1452
|
-
});
|
|
1453
|
-
|
|
1454
|
-
it('quorum: progress reports approvals against the clamped threshold', async () => {
|
|
1455
|
-
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
1456
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
1457
|
-
const row: any = await svc.getRequest(req.id, SYS);
|
|
1458
|
-
expect(row.decision_progress).toMatchObject({ behavior: 'quorum', got: 1, need: 2 });
|
|
1459
|
-
});
|
|
1460
|
-
|
|
1461
|
-
it('first_response: no decision_progress', async () => {
|
|
1462
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1463
|
-
const row: any = await svc.getRequest(req.id, SYS);
|
|
1464
|
-
expect(row.decision_progress).toBeUndefined();
|
|
1465
|
-
});
|
|
1466
|
-
|
|
1467
|
-
it('notify: inbox actionUrl is rewritten to a request deep link', async () => {
|
|
1468
|
-
const emitted: any[] = [];
|
|
1469
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
1470
|
-
const req = await svc.openNodeRequest(openInput(['u1', 'u2']), CTX);
|
|
1471
|
-
await svc.reassign(req.id, { actorId: 'u1', to: 'u7' }, SYS);
|
|
1472
|
-
const note = emitted.find(e => e.topic === 'approval.reassigned');
|
|
1473
|
-
expect(note.payload.actionUrl).toBe(`/system/approvals?request=${encodeURIComponent(req.id)}`);
|
|
1474
|
-
});
|
|
1475
|
-
});
|
|
1476
|
-
|
|
1477
|
-
// listActions must surface decision attachments through the contract mapping
|
|
1478
|
-
// (#3266 — the column existed but rowFromAction dropped it; caught in browser).
|
|
1479
|
-
describe('ApprovalService — listActions attachments mapping (#3266)', () => {
|
|
1480
|
-
it('returns the attachments recorded on a decision', async () => {
|
|
1481
|
-
const engine = makeFakeEngine();
|
|
1482
|
-
let n = 0;
|
|
1483
|
-
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
|
|
1484
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1485
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
|
|
1486
|
-
const acts = await svc.listActions(req.id, SYS);
|
|
1487
|
-
const approve = acts.find(a => a.action === 'approve');
|
|
1488
|
-
expect(approve?.attachments).toEqual(['file_a']);
|
|
1489
|
-
});
|
|
1490
|
-
});
|