@objectstack/plugin-approvals 4.0.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.
@@ -0,0 +1,337 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, beforeEach } from 'vitest';
4
+ import { ApprovalService } from './approval-service.js';
5
+
6
+ interface FakeRow { [k: string]: any }
7
+
8
+ function makeFakeEngine() {
9
+ const tables: Record<string, FakeRow[]> = {};
10
+ const ensure = (n: string) => (tables[n] ??= []);
11
+
12
+ function matches(row: FakeRow, filter: any): boolean {
13
+ if (!filter || typeof filter !== 'object') return true;
14
+ for (const [k, v] of Object.entries(filter)) {
15
+ const rv = row[k];
16
+ if (v != null && typeof v === 'object' && '$in' in (v as any)) {
17
+ if (!(v as any).$in.includes(rv)) return false;
18
+ continue;
19
+ }
20
+ if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
21
+ if (rv === (v as any).$ne) return false;
22
+ continue;
23
+ }
24
+ if (rv !== v) return false;
25
+ }
26
+ return true;
27
+ }
28
+
29
+ return {
30
+ _tables: tables,
31
+ async find(object: string, options?: any) {
32
+ const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
33
+ if (options?.orderBy?.[0]) {
34
+ const { field, direction } = options.orderBy[0];
35
+ rows.sort((a, b) => {
36
+ const av = a[field]; const bv = b[field];
37
+ if (av === bv) return 0;
38
+ const cmp = av > bv ? 1 : -1;
39
+ return direction === 'desc' ? -cmp : cmp;
40
+ });
41
+ }
42
+ return rows.slice(0, options?.limit ?? 1000);
43
+ },
44
+ async insert(object: string, data: any) {
45
+ ensure(object).push({ ...data });
46
+ return { ...data };
47
+ },
48
+ async update(object: string, idOrData: any, _opts?: any) {
49
+ const data = typeof idOrData === 'object' ? idOrData : _opts;
50
+ const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
51
+ const table = ensure(object);
52
+ const i = table.findIndex(r => r.id === id);
53
+ if (i >= 0) table[i] = { ...table[i], ...data };
54
+ return table[i];
55
+ },
56
+ async delete(object: string, options?: any) {
57
+ const table = ensure(object);
58
+ const id = options?.where?.id ?? options?.id;
59
+ const i = table.findIndex(r => r.id === id);
60
+ if (i >= 0) table.splice(i, 1);
61
+ return { id };
62
+ },
63
+ };
64
+ }
65
+
66
+ const CTX = { userId: 'u1', tenantId: 't1', roles: [], permissions: [] };
67
+ const SYS = { isSystem: true, roles: [], permissions: [] };
68
+
69
+ function singleStep(approvers: string[], behavior: 'first_response' | 'unanimous' = 'first_response') {
70
+ return {
71
+ name: 'proc',
72
+ label: 'Proc',
73
+ object: 'opportunity',
74
+ active: true,
75
+ steps: [{
76
+ name: 'sales_manager',
77
+ label: 'Sales Manager',
78
+ approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
79
+ behavior,
80
+ }],
81
+ };
82
+ }
83
+
84
+ function multiStep() {
85
+ return {
86
+ name: 'proc',
87
+ label: 'Proc',
88
+ object: 'opportunity',
89
+ active: true,
90
+ steps: [
91
+ { name: 'step1', label: 'Step 1', approvers: [{ type: 'user' as const, value: 'u1' }], behavior: 'first_response' },
92
+ { name: 'step2', label: 'Step 2', approvers: [{ type: 'user' as const, value: 'u2' }], behavior: 'first_response', rejectionBehavior: 'back_to_previous' as const },
93
+ ],
94
+ };
95
+ }
96
+
97
+ describe('ApprovalService', () => {
98
+ let engine: ReturnType<typeof makeFakeEngine>;
99
+ let svc: ApprovalService;
100
+ let n = 0;
101
+ const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
102
+
103
+ beforeEach(() => {
104
+ engine = makeFakeEngine();
105
+ n = 0;
106
+ svc = new ApprovalService({
107
+ engine: engine as any,
108
+ // Ensure strictly increasing timestamps so created_at sort is deterministic.
109
+ clock: { now: () => new Date(baseTime + (n++) * 1000) },
110
+ });
111
+ });
112
+
113
+ // ── Process CRUD ───────────────────────────────────────────────
114
+
115
+ it('defineProcess: creates with generated id and validates JSON', async () => {
116
+ const r = await svc.defineProcess({
117
+ name: 'proc', label: 'P', object: 'opportunity',
118
+ definition: singleStep(['u9']),
119
+ }, CTX);
120
+ expect(r.id).toMatch(/^apv_/);
121
+ expect(r.active).toBe(true);
122
+ expect(engine._tables['sys_approval_process'].length).toBe(1);
123
+ expect(engine._tables['sys_approval_process'][0].definition_json).toContain('sales_manager');
124
+ });
125
+
126
+ it('defineProcess: upserts when name matches', async () => {
127
+ const a = await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
128
+ const b = await svc.defineProcess({ name: 'proc', label: 'P2', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
129
+ expect(b.id).toBe(a.id);
130
+ expect(b.label).toBe('P2');
131
+ expect(engine._tables['sys_approval_process'].length).toBe(1);
132
+ });
133
+
134
+ it('defineProcess: rejects invalid definition', async () => {
135
+ await expect(svc.defineProcess({
136
+ name: 'proc', label: 'P', object: 'opportunity',
137
+ definition: { name: 'proc', label: 'P', object: 'opportunity', steps: [] },
138
+ }, CTX)).rejects.toThrow(/VALIDATION_FAILED/);
139
+ });
140
+
141
+ it('listProcesses({activeOnly:true}) filters', async () => {
142
+ await svc.defineProcess({ name: 'proc_a', label: 'A', object: 'opportunity', active: true, definition: { ...singleStep(['u1']), name: 'proc_a' } }, CTX);
143
+ await svc.defineProcess({ name: 'proc_b', label: 'B', object: 'opportunity', active: false, definition: { ...singleStep(['u1']), name: 'proc_b', active: false } }, CTX);
144
+ const active = await svc.listProcesses({ activeOnly: true }, CTX);
145
+ expect(active.length).toBe(1);
146
+ expect(active[0].name).toBe('proc_a');
147
+ });
148
+
149
+ it('getProcess by name then id; deleteProcess removes row', async () => {
150
+ const r = await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
151
+ expect((await svc.getProcess('proc', CTX))?.id).toBe(r.id);
152
+ expect((await svc.getProcess(r.id, CTX))?.name).toBe('proc');
153
+ await svc.deleteProcess('proc', CTX);
154
+ expect(engine._tables['sys_approval_process'].length).toBe(0);
155
+ });
156
+
157
+ // ── Submit ─────────────────────────────────────────────────────
158
+
159
+ it('submit: happy path → creates request + submit action + pending_approvers', async () => {
160
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
161
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'u1' }, CTX);
162
+ expect(req.status).toBe('pending');
163
+ expect(req.pending_approvers).toEqual(['u9']);
164
+ expect(req.current_step).toBe('sales_manager');
165
+ expect(engine._tables['sys_approval_action'].length).toBe(1);
166
+ expect(engine._tables['sys_approval_action'][0].action).toBe('submit');
167
+ });
168
+
169
+ it('submit: deduplicates pending requests', async () => {
170
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
171
+ await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
172
+ await expect(svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX))
173
+ .rejects.toThrow(/DUPLICATE_REQUEST/);
174
+ });
175
+
176
+ it('submit: throws when no active process', async () => {
177
+ await expect(svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX))
178
+ .rejects.toThrow(/NO_ACTIVE_PROCESS/);
179
+ });
180
+
181
+ // ── Approve ────────────────────────────────────────────────────
182
+
183
+ it('approve single step → finalized=true and status approved', async () => {
184
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
185
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
186
+ const out = await svc.approve(req.id, { actorId: 'u9' }, CTX);
187
+ expect(out.finalized).toBe(true);
188
+ expect(out.request.status).toBe('approved');
189
+ expect(out.request.completed_at).toBeTruthy();
190
+ });
191
+
192
+ it('approve multi step → advances to next step', async () => {
193
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: multiStep() }, CTX);
194
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
195
+ const out1 = await svc.approve(req.id, { actorId: 'u1' }, CTX);
196
+ expect(out1.finalized).toBe(false);
197
+ expect(out1.request.current_step).toBe('step2');
198
+ expect(out1.request.current_step_index).toBe(1);
199
+ expect(out1.request.pending_approvers).toEqual(['u2']);
200
+ const out2 = await svc.approve(req.id, { actorId: 'u2' }, CTX);
201
+ expect(out2.finalized).toBe(true);
202
+ expect(out2.request.status).toBe('approved');
203
+ });
204
+
205
+ it('approve unanimous: first vote not final, second vote finalizes', async () => {
206
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u1', 'u2'], 'unanimous') }, CTX);
207
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
208
+ const a = await svc.approve(req.id, { actorId: 'u1' }, CTX);
209
+ expect(a.finalized).toBe(false);
210
+ expect(a.request.pending_approvers).toEqual(['u2']);
211
+ const b = await svc.approve(req.id, { actorId: 'u2' }, CTX);
212
+ expect(b.finalized).toBe(true);
213
+ expect(b.request.status).toBe('approved');
214
+ });
215
+
216
+ it('approve by non-pending approver → FORBIDDEN', async () => {
217
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
218
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
219
+ await expect(svc.approve(req.id, { actorId: 'mallory' }, CTX)).rejects.toThrow(/FORBIDDEN/);
220
+ });
221
+
222
+ it('approve when not pending → INVALID_STATE', async () => {
223
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
224
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
225
+ await svc.approve(req.id, { actorId: 'u9' }, CTX);
226
+ await expect(svc.approve(req.id, { actorId: 'u9' }, SYS)).rejects.toThrow(/INVALID_STATE/);
227
+ });
228
+
229
+ // ── Reject ─────────────────────────────────────────────────────
230
+
231
+ it('reject default → finalized rejected', async () => {
232
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
233
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
234
+ const out = await svc.reject(req.id, { actorId: 'u9', comment: 'no' }, CTX);
235
+ expect(out.finalized).toBe(true);
236
+ expect(out.request.status).toBe('rejected');
237
+ });
238
+
239
+ it('reject back_to_previous: advances back', async () => {
240
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: multiStep() }, CTX);
241
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
242
+ await svc.approve(req.id, { actorId: 'u1' }, CTX); // advance to step2
243
+ const out = await svc.reject(req.id, { actorId: 'u2' }, CTX);
244
+ expect(out.finalized).toBe(false);
245
+ expect(out.request.current_step_index).toBe(0);
246
+ expect(out.request.current_step).toBe('step1');
247
+ expect(out.request.pending_approvers).toEqual(['u1']);
248
+ });
249
+
250
+ // ── Recall ─────────────────────────────────────────────────────
251
+
252
+ it('recall by submitter → status recalled', async () => {
253
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
254
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'u1' }, CTX);
255
+ const out = await svc.recall(req.id, { actorId: 'u1' }, CTX);
256
+ expect(out.finalized).toBe(true);
257
+ expect(out.request.status).toBe('recalled');
258
+ });
259
+
260
+ it('recall by non-submitter → FORBIDDEN', async () => {
261
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
262
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'u1' }, CTX);
263
+ await expect(svc.recall(req.id, { actorId: 'mallory' }, CTX)).rejects.toThrow(/FORBIDDEN/);
264
+ });
265
+
266
+ // ── Listing ────────────────────────────────────────────────────
267
+
268
+ it('listRequests: filters by approverId via post-filter', async () => {
269
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
270
+ await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
271
+ await svc.submit({ object: 'opportunity', recordId: 'opp2' }, CTX);
272
+ const mine = await svc.listRequests({ approverId: 'u9' }, CTX);
273
+ expect(mine.length).toBe(2);
274
+ const empty = await svc.listRequests({ approverId: 'noone' }, CTX);
275
+ expect(empty.length).toBe(0);
276
+ });
277
+
278
+ it('listActions: returns rows ordered by created_at ASC', async () => {
279
+ await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX);
280
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
281
+ await svc.approve(req.id, { actorId: 'u9' }, CTX);
282
+ const actions = await svc.listActions(req.id, CTX);
283
+ expect(actions.map(a => a.action)).toEqual(['submit', 'approve']);
284
+ });
285
+
286
+ // ── Graph expansion (M10.17.1) ───────────────────────────────────
287
+
288
+ it('approver type=team expands flat sys_team_member', async () => {
289
+ engine._tables.sys_team = [{ id: 'sales', name: 'sales', organization_id: 't1' }];
290
+ engine._tables.sys_team_member = [
291
+ { id: 'tm1', team_id: 'sales', user_id: 'alice' },
292
+ { id: 'tm2', team_id: 'sales', user_id: 'bob' },
293
+ ];
294
+ await svc.defineProcess({
295
+ name: 'team_proc', label: 'TeamProc', object: 'opportunity',
296
+ definition: {
297
+ name: 'team_proc', label: 'TeamProc', object: 'opportunity', active: true,
298
+ steps: [{ name: 's1', label: 'S1', approvers: [{ type: 'team', value: 'sales' }] }],
299
+ },
300
+ }, CTX);
301
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
302
+ expect(req.pending_approvers?.sort()).toEqual(['alice', 'bob']);
303
+ });
304
+
305
+ it('approver type=department walks parent_department_id (BFS)', async () => {
306
+ engine._tables.sys_department = [
307
+ { id: 'emea', name: 'EMEA', parent_department_id: null, organization_id: 't1', active: true },
308
+ { id: 'emea_sales', name: 'EMEA Sales', parent_department_id: 'emea', organization_id: 't1', active: true },
309
+ { id: 'emea_sales_uk', name: 'EMEA Sales UK', parent_department_id: 'emea_sales', organization_id: 't1', active: true },
310
+ ];
311
+ engine._tables.sys_department_member = [
312
+ { id: 'dm1', department_id: 'emea', user_id: 'eva' },
313
+ { id: 'dm2', department_id: 'emea_sales_uk', user_id: 'alice' },
314
+ ];
315
+ await svc.defineProcess({
316
+ name: 'dept_proc', label: 'DeptProc', object: 'opportunity',
317
+ definition: {
318
+ name: 'dept_proc', label: 'DeptProc', object: 'opportunity', active: true,
319
+ steps: [{ name: 's1', label: 'S1', approvers: [{ type: 'department', value: 'emea' }] }],
320
+ },
321
+ }, CTX);
322
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
323
+ expect(req.pending_approvers?.sort()).toEqual(['alice', 'eva']);
324
+ });
325
+
326
+ it('approver type=department with no rows falls back to prefixed literal', async () => {
327
+ await svc.defineProcess({
328
+ name: 'dept_proc2', label: 'DeptProc', object: 'opportunity',
329
+ definition: {
330
+ name: 'dept_proc2', label: 'DeptProc', object: 'opportunity', active: true,
331
+ steps: [{ name: 's1', label: 'S1', approvers: [{ type: 'department', value: 'unknown' }] }],
332
+ },
333
+ }, CTX);
334
+ const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX);
335
+ expect(req.pending_approvers).toEqual(['department:unknown']);
336
+ });
337
+ });