@objectstack/plugin-approvals 7.3.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,263 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * Phase B integration tests.
5
- *
6
- * - status mirror (`approvalStatusField` on the business record)
7
- * - process-level `onSubmit / onFinalApprove / onFinalReject / onRecall`
8
- * - step-level `onApprove / onReject`
9
- * - `inbox_notify` action writes `sys_notification` rows
10
- * - `field_update` action writes the business record (token interpolation)
11
- * - lifecycle hooks: afterInsert auto-submit, lock on beforeUpdate, allow
12
- * status-mirror writes through, allow admin override.
13
- */
14
-
15
- import { describe, it, expect, beforeEach } from 'vitest';
16
- import { ApprovalService } from './approval-service.js';
17
- import { bindProcessHooks, unbindAllHooks } from './lifecycle-hooks.js';
18
-
19
- interface FakeRow { [k: string]: any }
20
-
21
- function makeFakeEngine() {
22
- const tables: Record<string, FakeRow[]> = {};
23
- const ensure = (n: string) => (tables[n] ??= []);
24
- const hooks: Record<string, Array<{ handler: (ctx: any) => any | Promise<any>; object?: string | string[]; packageId?: string }>> = {};
25
-
26
- function matches(row: FakeRow, filter: any): boolean {
27
- if (!filter || typeof filter !== 'object') return true;
28
- for (const [k, v] of Object.entries(filter)) {
29
- if (row[k] !== v) return false;
30
- }
31
- return true;
32
- }
33
-
34
- async function fire(event: string, ctx: any) {
35
- const list = hooks[event] ?? [];
36
- for (const h of list) {
37
- if (h.object) {
38
- const objs = Array.isArray(h.object) ? h.object : [h.object];
39
- if (!objs.includes(ctx.object)) continue;
40
- }
41
- await h.handler(ctx);
42
- }
43
- }
44
-
45
- return {
46
- _tables: tables,
47
- _hooks: hooks,
48
- async find(object: string, options?: any, _opts?: any) {
49
- const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
50
- return rows.slice(0, options?.limit ?? 1000);
51
- },
52
- async insert(object: string, data: any, opts?: any) {
53
- const row = { ...data };
54
- ensure(object).push(row);
55
- const ctx = { object, event: 'afterInsert', result: row, input: { data: row }, session: opts?.context ?? {} };
56
- await fire('afterInsert', ctx);
57
- return row;
58
- },
59
- async update(object: string, idOrData: any, opts?: any) {
60
- const data = typeof idOrData === 'object' ? idOrData : opts;
61
- const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
62
- // beforeUpdate (skip status-mirror writes from system context — the
63
- // hook itself decides via the `data.keys ⊆ approvalStatusField` rule).
64
- const beforeCtx = { object, event: 'beforeUpdate', input: { id, data }, session: opts?.context ?? {} };
65
- await fire('beforeUpdate', beforeCtx);
66
- const table = ensure(object);
67
- const i = table.findIndex(r => r.id === id);
68
- if (i >= 0) table[i] = { ...table[i], ...data };
69
- const after = table[i];
70
- const afterCtx = { object, event: 'afterUpdate', input: { id, data }, result: after, session: opts?.context ?? {} };
71
- await fire('afterUpdate', afterCtx);
72
- return after;
73
- },
74
- async delete(object: string, options?: any) {
75
- const table = ensure(object);
76
- const id = options?.where?.id ?? options?.id;
77
- const i = table.findIndex(r => r.id === id);
78
- if (i >= 0) table.splice(i, 1);
79
- return { id };
80
- },
81
- registerHook(event: string, handler: any, options?: any) {
82
- (hooks[event] ??= []).push({ handler, object: options?.object, packageId: options?.packageId });
83
- },
84
- unregisterHooksByPackage(packageId: string) {
85
- let removed = 0;
86
- for (const ev of Object.keys(hooks)) {
87
- const before = hooks[ev].length;
88
- hooks[ev] = hooks[ev].filter(h => h.packageId !== packageId);
89
- removed += before - hooks[ev].length;
90
- }
91
- return removed;
92
- },
93
- };
94
- }
95
-
96
- const SYS = { isSystem: true, roles: [], permissions: [] };
97
- const USR = { userId: 'submitter', roles: [], permissions: [] };
98
-
99
- function processWithMirror() {
100
- return {
101
- name: 'discount_approval',
102
- label: 'Discount Approval',
103
- object: 'opportunity',
104
- active: true,
105
- approvalStatusField: 'approval_status',
106
- lockRecord: true,
107
- entryCriteria: 'record.amount > 50000',
108
- onSubmit: [
109
- {
110
- type: 'inbox_notify' as const,
111
- name: 'notify_pending',
112
- config: {
113
- to: 'pending_approvers',
114
- title: 'Discount needs approval',
115
- body: 'Opportunity {record_id} amount review',
116
- },
117
- },
118
- ],
119
- onFinalApprove: [
120
- { type: 'field_update' as const, name: 'close_won', config: { field: 'stage', value: 'closed_won' } },
121
- ],
122
- onFinalReject: [
123
- { type: 'inbox_notify' as const, name: 'tell_submitter', config: { to: 'submitter', title: 'Rejected', body: 'Sorry' } },
124
- ],
125
- onRecall: [
126
- { type: 'inbox_notify' as const, name: 'recalled', config: { to: 'submitter', title: 'Recalled', body: 'Pulled back' } },
127
- ],
128
- steps: [
129
- {
130
- name: 'sales_manager',
131
- label: 'Sales Manager',
132
- approvers: [{ type: 'user' as const, value: 'manager' }],
133
- behavior: 'first_response' as const,
134
- },
135
- ],
136
- };
137
- }
138
-
139
- describe('Phase B — approval auto-takeover', () => {
140
- let engine: ReturnType<typeof makeFakeEngine>;
141
- let svc: ApprovalService;
142
-
143
- beforeEach(() => {
144
- engine = makeFakeEngine();
145
- svc = new ApprovalService({ engine: engine as any });
146
- });
147
-
148
- describe('status mirror + actions', () => {
149
- beforeEach(async () => {
150
- // Seed a business record so syncStatusField can update it.
151
- engine._tables.opportunity = [{ id: 'opp1', amount: 80000, stage: 'qualification', approval_status: 'not_submitted' }];
152
- await svc.defineProcess({
153
- name: 'discount_approval',
154
- label: 'Discount Approval',
155
- object: 'opportunity',
156
- definition: processWithMirror(),
157
- }, SYS as any);
158
- });
159
-
160
- it('writes onSubmit notifications and mirrors status to the business record', async () => {
161
- await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any);
162
-
163
- // status mirrored.
164
- const opp = engine._tables.opportunity[0];
165
- expect(opp.approval_status).toBe('pending');
166
-
167
- // inbox notification written to pending approvers.
168
- const notes = engine._tables.sys_notification ?? [];
169
- expect(notes.length).toBeGreaterThanOrEqual(1);
170
- expect(notes.some(n => n.recipient_id === 'manager' && /Opportunity opp1/.test(n.body))).toBe(true);
171
- });
172
-
173
- it('runs onFinalApprove field_update on finalize, mirrors status=approved', async () => {
174
- const submitted = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any);
175
- await svc.approve(submitted.id, { actorId: 'manager' }, SYS as any);
176
-
177
- const opp = engine._tables.opportunity[0];
178
- expect(opp.stage).toBe('closed_won');
179
- expect(opp.approval_status).toBe('approved');
180
- });
181
-
182
- it('runs onFinalReject inbox_notify on rejection, mirrors status=rejected', async () => {
183
- const submitted = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any);
184
- await svc.reject(submitted.id, { actorId: 'manager', comment: 'too low' }, SYS as any);
185
-
186
- const opp = engine._tables.opportunity[0];
187
- expect(opp.approval_status).toBe('rejected');
188
- const notes = engine._tables.sys_notification ?? [];
189
- expect(notes.some(n => n.recipient_id === 'submitter' && n.title === 'Rejected')).toBe(true);
190
- });
191
-
192
- it('runs onRecall and mirrors status=recalled', async () => {
193
- const submitted = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any);
194
- await svc.recall(submitted.id, { actorId: 'submitter' }, USR as any);
195
-
196
- const opp = engine._tables.opportunity[0];
197
- expect(opp.approval_status).toBe('recalled');
198
- const notes = engine._tables.sys_notification ?? [];
199
- expect(notes.some(n => n.title === 'Recalled')).toBe(true);
200
- });
201
- });
202
-
203
- describe('lifecycle hooks', () => {
204
- beforeEach(async () => {
205
- await svc.defineProcess({
206
- name: 'discount_approval',
207
- label: 'Discount Approval',
208
- object: 'opportunity',
209
- definition: processWithMirror(),
210
- }, SYS as any);
211
- const procs = await svc.listProcesses({ activeOnly: true }, SYS as any);
212
- bindProcessHooks(engine as any, svc, procs);
213
- });
214
-
215
- it('auto-submits a request when an inserted record matches entryCriteria', async () => {
216
- await engine.insert('opportunity', { id: 'opp_high', amount: 100000, stage: 'qualification' });
217
- // Drain microtasks (insert kicks off the hook).
218
- await new Promise(r => setTimeout(r, 0));
219
- const requests = engine._tables.sys_approval_request ?? [];
220
- expect(requests.length).toBe(1);
221
- expect(requests[0].object_name).toBe('opportunity');
222
- expect(requests[0].record_id).toBe('opp_high');
223
- });
224
-
225
- it('does NOT auto-submit when entryCriteria evaluates to false', async () => {
226
- await engine.insert('opportunity', { id: 'opp_low', amount: 1000, stage: 'qualification' });
227
- await new Promise(r => setTimeout(r, 0));
228
- expect(engine._tables.sys_approval_request ?? []).toHaveLength(0);
229
- });
230
-
231
- it('does NOT double-submit when criteria continues to be true on update', async () => {
232
- await engine.insert('opportunity', { id: 'opp_dup', amount: 100000, stage: 'qualification' });
233
- await new Promise(r => setTimeout(r, 0));
234
- await engine.update('opportunity', { id: 'opp_dup', amount: 110000 }, { context: { ...SYS, roles: ['admin'] } });
235
- await new Promise(r => setTimeout(r, 0));
236
- expect((engine._tables.sys_approval_request ?? []).length).toBe(1);
237
- });
238
-
239
- it('lock hook blocks edits to a locked record', async () => {
240
- await engine.insert('opportunity', { id: 'opp_lock', amount: 100000, stage: 'qualification' });
241
- await new Promise(r => setTimeout(r, 0));
242
- await expect(
243
- engine.update('opportunity', { id: 'opp_lock', stage: 'closed_won' }, { context: { userId: 'u1', roles: [] } }),
244
- ).rejects.toThrow(/RECORD_LOCKED/);
245
- });
246
-
247
- it('lock hook allows admin role override', async () => {
248
- await engine.insert('opportunity', { id: 'opp_admin', amount: 100000, stage: 'qualification' });
249
- await new Promise(r => setTimeout(r, 0));
250
- await expect(
251
- engine.update('opportunity', { id: 'opp_admin', stage: 'closed_won' }, { context: { userId: 'admin', roles: ['admin'] } }),
252
- ).resolves.toBeTruthy();
253
- });
254
-
255
- it('unbindAllHooks removes registered hooks idempotently', async () => {
256
- const removed = unbindAllHooks(engine as any);
257
- expect(removed).toBeGreaterThan(0);
258
- // Re-bind to default state for any later beforeEach.
259
- const procs = await svc.listProcesses({ activeOnly: true }, SYS as any);
260
- bindProcessHooks(engine as any, svc, procs);
261
- });
262
- });
263
- });