@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.2
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 +863 -0
- package/dist/index.d.mts +2236 -2688
- package/dist/index.d.ts +2236 -2688
- package/dist/index.js +591 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +590 -127
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -10
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -38
- package/src/action-link-pages.ts +0 -102
- package/src/approval-actor-impersonation.test.ts +0 -330
- package/src/approval-node.test.ts +0 -356
- package/src/approval-node.ts +0 -196
- package/src/approval-revise.test.ts +0 -418
- package/src/approval-service.test.ts +0 -2858
- package/src/approval-service.ts +0 -3617
- package/src/approvals-plugin.ts +0 -294
- package/src/approver-cross-org.integration.test.ts +0 -206
- package/src/approver-org-scope.test.ts +0 -201
- package/src/approver-org-scope.ts +0 -261
- package/src/index.ts +0 -42
- package/src/lifecycle-hooks.ts +0 -201
- package/src/nav-contribution.test.ts +0 -50
- package/src/record-lock-schedule-run.integration.test.ts +0 -206
- package/src/status-mirror-cascade.integration.test.ts +0 -224
- package/src/sys-approval-action.object.ts +0 -149
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.test.ts +0 -42
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -116
- package/src/sys-approval-request.object.ts +0 -413
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/bundle-ownership.test.ts +0 -48
- package/src/translations/en.objects.generated.ts +0 -311
- package/src/translations/es-ES.objects.generated.ts +0 -311
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -311
- package/src/translations/zh-CN.objects.generated.ts +0 -311
- package/tsconfig.json +0 -10
|
@@ -1,418 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* ADR-0044 send-back-for-revision matrix:
|
|
5
|
-
*
|
|
6
|
-
* multi-round (1→2→3) × unanimous (send-back clears partial approvals) ×
|
|
7
|
-
* lock states (locked → unlocked → re-locked) × recall crossing the revise
|
|
8
|
-
* window × maxRevisions overflow auto-reject × flows with no revise edge.
|
|
9
|
-
*
|
|
10
|
-
* Drives the REAL automation engine (back-edge re-entry) against the approval
|
|
11
|
-
* service with an in-memory ObjectQL stand-in — the same harness as
|
|
12
|
-
* approval-node.test.ts, extended with orderBy support (assertLatestForRun
|
|
13
|
-
* sorts by created_at) and a ticking clock (rounds must not share timestamps).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
17
|
-
import { AutomationEngine } from '@objectstack/service-automation';
|
|
18
|
-
import { ApprovalService } from './approval-service.js';
|
|
19
|
-
import { registerApprovalNode } from './approval-node.js';
|
|
20
|
-
import { bindApprovalLockHook, APPROVALS_HOOK_PACKAGE } from './lifecycle-hooks.js';
|
|
21
|
-
|
|
22
|
-
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* The signed-in caller. An approval action is recorded against the
|
|
26
|
-
* AUTHENTICATED caller (#3800), so each call below presents the context of the
|
|
27
|
-
* person it names — an identity-less context can no longer act by naming one.
|
|
28
|
-
*/
|
|
29
|
-
const asUser = (userId: string) =>
|
|
30
|
-
({ isSystem: false, userId, positions: [], permissions: [] }) as any;
|
|
31
|
-
|
|
32
|
-
const noopLogger = { info() {}, warn() {}, error() {}, debug() {} };
|
|
33
|
-
|
|
34
|
-
/** In-memory ObjectQL stand-in: equality/$in where, orderBy, limit. */
|
|
35
|
-
function makeFakeEngine() {
|
|
36
|
-
const tables = new Map<string, any[]>();
|
|
37
|
-
const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!));
|
|
38
|
-
const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => {
|
|
39
|
-
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
|
|
40
|
-
if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne;
|
|
41
|
-
return row[k] === v;
|
|
42
|
-
});
|
|
43
|
-
return {
|
|
44
|
-
tables,
|
|
45
|
-
async find(object: string, opts: any = {}) {
|
|
46
|
-
const where = opts.where ?? opts.filter ?? {};
|
|
47
|
-
let out = rows(object).filter(r => matches(r, where));
|
|
48
|
-
for (const ord of [...(opts.orderBy ?? [])].reverse()) {
|
|
49
|
-
// Canonical SortNode key only (spec/data/query.zod.ts): the real
|
|
50
|
-
// engine strips an unknown `direction:` key and defaults to asc, so
|
|
51
|
-
// the mock must too — honoring both keys masks wrong-key sorts.
|
|
52
|
-
const dir = ord.order === 'desc' ? -1 : 1;
|
|
53
|
-
out = [...out].sort((a, b) => (a[ord.field] < b[ord.field] ? -dir : a[ord.field] > b[ord.field] ? dir : 0));
|
|
54
|
-
}
|
|
55
|
-
if (opts.limit) out = out.slice(0, opts.limit);
|
|
56
|
-
return out.map(r => ({ ...r }));
|
|
57
|
-
},
|
|
58
|
-
async insert(object: string, data: any) {
|
|
59
|
-
rows(object).push({ ...data });
|
|
60
|
-
return { ...data };
|
|
61
|
-
},
|
|
62
|
-
async update(object: string, idOrData: any) {
|
|
63
|
-
const row = rows(object).find(r => r.id === idOrData.id);
|
|
64
|
-
if (row) Object.assign(row, idOrData);
|
|
65
|
-
return row ? { ...row } : null;
|
|
66
|
-
},
|
|
67
|
-
async delete(object: string, opts: any = {}) {
|
|
68
|
-
const where = opts.where ?? {};
|
|
69
|
-
const list = rows(object);
|
|
70
|
-
for (let i = list.length - 1; i >= 0; i--) if (matches(list[i], where)) list.splice(i, 1);
|
|
71
|
-
return { affected: 1 };
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
describe('Send back for revision (ADR-0044)', () => {
|
|
77
|
-
let automation: AutomationEngine;
|
|
78
|
-
let service: ApprovalService;
|
|
79
|
-
let fake: ReturnType<typeof makeFakeEngine>;
|
|
80
|
-
const marks: string[] = [];
|
|
81
|
-
|
|
82
|
-
beforeEach(() => {
|
|
83
|
-
marks.length = 0;
|
|
84
|
-
automation = new AutomationEngine(noopLogger as any);
|
|
85
|
-
fake = makeFakeEngine();
|
|
86
|
-
// Ticking clock: every read advances 1s, so rounds never share created_at
|
|
87
|
-
// (assertLatestForRun orders by it).
|
|
88
|
-
let t = Date.parse('2026-06-12T00:00:00Z');
|
|
89
|
-
service = new ApprovalService({
|
|
90
|
-
engine: fake as any,
|
|
91
|
-
logger: noopLogger,
|
|
92
|
-
clock: { now: () => new Date((t += 1000)) },
|
|
93
|
-
});
|
|
94
|
-
service.attachAutomation(automation);
|
|
95
|
-
registerApprovalNode(automation, service, noopLogger);
|
|
96
|
-
automation.registerNodeExecutor({
|
|
97
|
-
type: 'mark',
|
|
98
|
-
async execute(node: any) { marks.push(node.id); return { success: true }; },
|
|
99
|
-
});
|
|
100
|
-
// Signal-flavor wait stand-in: suspends until an external resume — the
|
|
101
|
-
// same contract as the built-in wait node's non-timer path.
|
|
102
|
-
automation.registerNodeExecutor({
|
|
103
|
-
type: 'wait',
|
|
104
|
-
async execute(node: any) { return { success: true, suspend: true, correlation: `wait:${node.id}` }; },
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
function registerReviseFlow(opts?: {
|
|
109
|
-
maxRevisions?: number;
|
|
110
|
-
behavior?: 'first_response' | 'unanimous';
|
|
111
|
-
approvers?: Array<{ type: string; value?: string }>;
|
|
112
|
-
}) {
|
|
113
|
-
automation.registerFlow('expense_approval', {
|
|
114
|
-
name: 'expense_approval',
|
|
115
|
-
label: 'Expense Approval',
|
|
116
|
-
type: 'autolaunched',
|
|
117
|
-
nodes: [
|
|
118
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
119
|
-
{
|
|
120
|
-
id: 'review', type: 'approval', label: 'Manager Review',
|
|
121
|
-
config: {
|
|
122
|
-
approvers: opts?.approvers ?? [{ type: 'user', value: 'u1' }],
|
|
123
|
-
behavior: opts?.behavior,
|
|
124
|
-
...(opts?.maxRevisions !== undefined ? { maxRevisions: opts.maxRevisions } : {}),
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
{ id: 'wait_revision', type: 'wait', label: 'Awaiting Revision' },
|
|
128
|
-
{ id: 'on_approved', type: 'mark', label: 'Approved' },
|
|
129
|
-
{ id: 'on_rejected', type: 'mark', label: 'Rejected' },
|
|
130
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
131
|
-
],
|
|
132
|
-
edges: [
|
|
133
|
-
{ id: 'e1', source: 'start', target: 'review' },
|
|
134
|
-
{ id: 'e2', source: 'review', target: 'on_approved', label: 'approve' },
|
|
135
|
-
{ id: 'e3', source: 'review', target: 'on_rejected', label: 'reject' },
|
|
136
|
-
{ id: 'e4', source: 'review', target: 'wait_revision', label: 'revise' },
|
|
137
|
-
// The cycle-closing back-edge (ADR-0044): resubmit re-enters the approval node.
|
|
138
|
-
{ id: 'e5', source: 'wait_revision', target: 'review', label: 'resubmit', type: 'back' },
|
|
139
|
-
{ id: 'e6', source: 'on_approved', target: 'end' },
|
|
140
|
-
{ id: 'e7', source: 'on_rejected', target: 'end' },
|
|
141
|
-
],
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function startFlow() {
|
|
146
|
-
const paused = await automation.execute('expense_approval', {
|
|
147
|
-
object: 'fin_expense', record: { id: 'x1', amount: 900 }, userId: 'submitter',
|
|
148
|
-
});
|
|
149
|
-
expect(paused.status).toBe('paused');
|
|
150
|
-
const [req] = await fake.find('sys_approval_request', { where: { status: 'pending' } });
|
|
151
|
-
return { runId: paused.runId!, req };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const pendingReq = async () => (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
155
|
-
const actionsOf = async (requestId: string) =>
|
|
156
|
-
(await fake.find('sys_approval_action', { where: { request_id: requestId } })).map((a: any) => a.action);
|
|
157
|
-
|
|
158
|
-
it('registers a revise flow with a declared back-edge (cycle allowed)', () => {
|
|
159
|
-
expect(() => registerReviseFlow()).not.toThrow();
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('full round trip: send back → returned + wait → resubmit → round 2 → approve', async () => {
|
|
163
|
-
registerReviseFlow();
|
|
164
|
-
const { runId, req } = await startFlow();
|
|
165
|
-
|
|
166
|
-
const sent = await service.sendBack(req.id, { actorId: 'u1', comment: 'fix the totals' }, asUser('u1'));
|
|
167
|
-
expect(sent.resumed).toBe(true);
|
|
168
|
-
expect(sent.autoRejected).toBeUndefined();
|
|
169
|
-
expect(sent.request.status).toBe('returned');
|
|
170
|
-
expect(marks).toHaveLength(0);
|
|
171
|
-
|
|
172
|
-
// The run is paused at the wait point, not terminal.
|
|
173
|
-
expect(automation.listSuspendedRuns()).toMatchObject([{ runId, nodeId: 'wait_revision' }]);
|
|
174
|
-
expect(await actionsOf(req.id)).toEqual(['submit', 'revise']);
|
|
175
|
-
|
|
176
|
-
const re = await service.resubmit(req.id, { actorId: 'submitter', comment: 'totals fixed' }, asUser('submitter'));
|
|
177
|
-
expect(re.resumed).toBe(true);
|
|
178
|
-
expect(await actionsOf(req.id)).toEqual(['submit', 'revise', 'resubmit']);
|
|
179
|
-
|
|
180
|
-
// Round 2: a NEW pending request on the same (run, node), round stamped.
|
|
181
|
-
const round2 = await pendingReq();
|
|
182
|
-
expect(round2.id).not.toBe(req.id);
|
|
183
|
-
expect(round2).toMatchObject({ flow_run_id: runId, flow_node_id: 'review' });
|
|
184
|
-
expect((await service.getRequest(round2.id, SYSTEM_CTX))?.round).toBe(2);
|
|
185
|
-
expect(automation.listSuspendedRuns()).toMatchObject([{ runId, nodeId: 'review' }]);
|
|
186
|
-
|
|
187
|
-
// Round 2 approval completes the flow down the approve branch.
|
|
188
|
-
const out = await service.decide(round2.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
|
|
189
|
-
expect(out).toMatchObject({ finalized: true, resumed: true });
|
|
190
|
-
expect(marks).toEqual(['on_approved']);
|
|
191
|
-
expect(automation.listSuspendedRuns()).toHaveLength(0);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('multi-round: two send-backs stamp rounds 2 and 3', async () => {
|
|
195
|
-
registerReviseFlow();
|
|
196
|
-
const { req } = await startFlow();
|
|
197
|
-
expect((await service.getRequest(req.id, SYSTEM_CTX))?.round).toBeUndefined(); // round 1
|
|
198
|
-
|
|
199
|
-
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
200
|
-
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
201
|
-
const round2 = await pendingReq();
|
|
202
|
-
expect((await service.getRequest(round2.id, SYSTEM_CTX))?.round).toBe(2);
|
|
203
|
-
|
|
204
|
-
await service.sendBack(round2.id, { actorId: 'u1' }, asUser('u1'));
|
|
205
|
-
await service.resubmit(round2.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
206
|
-
const round3 = await pendingReq();
|
|
207
|
-
expect((await service.getRequest(round3.id, SYSTEM_CTX))?.round).toBe(3);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('maxRevisions overflow auto-rejects instead of returning', async () => {
|
|
211
|
-
registerReviseFlow({ maxRevisions: 1 });
|
|
212
|
-
const { req } = await startFlow();
|
|
213
|
-
|
|
214
|
-
// Send-back #1 fits the budget.
|
|
215
|
-
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
216
|
-
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
217
|
-
const round2 = await pendingReq();
|
|
218
|
-
|
|
219
|
-
// Send-back #2 exceeds it → auto-reject, flow takes the reject branch.
|
|
220
|
-
const out = await service.sendBack(round2.id, { actorId: 'u1', comment: 'still wrong' }, asUser('u1'));
|
|
221
|
-
expect(out.autoRejected).toBe(true);
|
|
222
|
-
expect(out.resumed).toBe(true);
|
|
223
|
-
expect(out.request.status).toBe('rejected');
|
|
224
|
-
expect(marks).toEqual(['on_rejected']);
|
|
225
|
-
// The trail preserves the approver's actual intent before the auto-reject.
|
|
226
|
-
expect(await actionsOf(round2.id)).toEqual(['submit', 'revise', 'reject']);
|
|
227
|
-
const acts = await fake.find('sys_approval_action', { where: { request_id: round2.id, action: 'reject' } });
|
|
228
|
-
expect(acts[0].comment).toMatch(/revision limit \(1\) exceeded/i);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('maxRevisions 0 disables send-back (immediate auto-reject)', async () => {
|
|
232
|
-
registerReviseFlow({ maxRevisions: 0 });
|
|
233
|
-
const { req } = await startFlow();
|
|
234
|
-
const out = await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
235
|
-
expect(out.autoRejected).toBe(true);
|
|
236
|
-
expect(marks).toEqual(['on_rejected']);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('rejects send-back when the flow has no revise out-edge', async () => {
|
|
240
|
-
automation.registerFlow('no_revise', {
|
|
241
|
-
name: 'no_revise', label: 'No Revise', type: 'autolaunched',
|
|
242
|
-
nodes: [
|
|
243
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
244
|
-
{ id: 'review', type: 'approval', label: 'Review', config: { approvers: [{ type: 'user', value: 'u1' }] } },
|
|
245
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
246
|
-
],
|
|
247
|
-
edges: [
|
|
248
|
-
{ id: 'e1', source: 'start', target: 'review' },
|
|
249
|
-
{ id: 'e2', source: 'review', target: 'end', label: 'approve' },
|
|
250
|
-
{ id: 'e3', source: 'review', target: 'end', label: 'reject' },
|
|
251
|
-
],
|
|
252
|
-
});
|
|
253
|
-
await automation.execute('no_revise', { object: 'fin_expense', record: { id: 'x2' }, userId: 'submitter' });
|
|
254
|
-
const req = await pendingReq();
|
|
255
|
-
|
|
256
|
-
await expect(service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'))).rejects.toThrow(/no 'revise' out-edge/);
|
|
257
|
-
// Nothing moved: still pending, no revise audit row.
|
|
258
|
-
expect((await fake.find('sys_approval_request', { where: { id: req.id } }))[0].status).toBe('pending');
|
|
259
|
-
expect(await actionsOf(req.id)).toEqual(['submit']);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it('unanimous: one send-back finalizes immediately and round 2 reopens the full slate', async () => {
|
|
263
|
-
registerReviseFlow({
|
|
264
|
-
behavior: 'unanimous',
|
|
265
|
-
approvers: [{ type: 'user', value: 'u1' }, { type: 'user', value: 'u2' }],
|
|
266
|
-
});
|
|
267
|
-
const { req } = await startFlow();
|
|
268
|
-
|
|
269
|
-
// u1 approves — request holds for u2.
|
|
270
|
-
const first = await service.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
|
|
271
|
-
expect(first.finalized).toBe(false);
|
|
272
|
-
|
|
273
|
-
// u2 sends back instead: finalizes despite u1's earlier approval.
|
|
274
|
-
const sent = await service.sendBack(req.id, { actorId: 'u2', comment: 'rework' }, asUser('u2'));
|
|
275
|
-
expect(sent.request.status).toBe('returned');
|
|
276
|
-
|
|
277
|
-
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
278
|
-
const round2 = await pendingReq();
|
|
279
|
-
// Fresh slate: BOTH approvers pending again — prior approvals are stale.
|
|
280
|
-
expect((round2.pending_approvers as string).split(',').sort()).toEqual(['u1', 'u2']);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('lock lifecycle: locked while pending, unlocked in the revise window, re-locked on resubmit', async () => {
|
|
284
|
-
registerReviseFlow();
|
|
285
|
-
const { req } = await startFlow();
|
|
286
|
-
|
|
287
|
-
// Bind the real lock hook against a hook-capturing engine facade.
|
|
288
|
-
let hook: ((ctx: any) => Promise<void>) | undefined;
|
|
289
|
-
bindApprovalLockHook({
|
|
290
|
-
registerHook: (_e: string, h: any) => { hook = h; },
|
|
291
|
-
unregisterHooksByPackage: () => 0,
|
|
292
|
-
find: fake.find.bind(fake),
|
|
293
|
-
} as any, noopLogger);
|
|
294
|
-
expect(hook).toBeDefined();
|
|
295
|
-
const editAttempt = () => hook!({
|
|
296
|
-
object: 'fin_expense',
|
|
297
|
-
input: { id: 'x1', data: { amount: 1200 } },
|
|
298
|
-
session: { isSystem: false, positions: [] },
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // pending → locked
|
|
302
|
-
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
303
|
-
await expect(editAttempt()).resolves.toBeUndefined(); // returned → unlocked
|
|
304
|
-
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
305
|
-
await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // round 2 pending → re-locked
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it('recall crossing the revise window cancels the run (returned → recalled)', async () => {
|
|
309
|
-
registerReviseFlow();
|
|
310
|
-
const { runId, req } = await startFlow();
|
|
311
|
-
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
312
|
-
|
|
313
|
-
// Only the submitter may abandon the revision.
|
|
314
|
-
await expect(service.recall(req.id, { actorId: 'u1' }, asUser('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
315
|
-
|
|
316
|
-
const out = await service.recall(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
317
|
-
expect(out.request.status).toBe('recalled');
|
|
318
|
-
expect(out.resumed).toBe(false);
|
|
319
|
-
// The run was terminally cancelled, not resumed down any branch.
|
|
320
|
-
expect(automation.listSuspendedRuns()).toHaveLength(0);
|
|
321
|
-
expect(marks).toHaveLength(0);
|
|
322
|
-
const log = (await automation.listRuns('expense_approval'))[0];
|
|
323
|
-
expect(log.status).toBe('cancelled');
|
|
324
|
-
expect(log.id).toBe(runId);
|
|
325
|
-
|
|
326
|
-
// The window is closed: resubmit is no longer possible.
|
|
327
|
-
await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/INVALID_STATE/);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it('refuses resubmit while another pending request collides on the record (run stays resumable)', async () => {
|
|
331
|
-
registerReviseFlow();
|
|
332
|
-
const { runId, req } = await startFlow();
|
|
333
|
-
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
334
|
-
|
|
335
|
-
// Simulate a record-change trigger re-firing off an edit made inside the
|
|
336
|
-
// revise window: a second, unrelated run opened its own pending request.
|
|
337
|
-
await fake.insert('sys_approval_request', {
|
|
338
|
-
id: 'areq_collider', object_name: 'fin_expense', record_id: 'x1',
|
|
339
|
-
status: 'pending', flow_run_id: 'run_other', flow_node_id: 'review',
|
|
340
|
-
submitter_id: 'submitter', process_name: 'flow:expense_approval',
|
|
341
|
-
created_at: new Date().toISOString(),
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/DUPLICATE_REQUEST/);
|
|
345
|
-
// The refusal happened BEFORE the suspension was consumed — clearing the
|
|
346
|
-
// collision makes the same resubmit succeed.
|
|
347
|
-
expect(automation.listSuspendedRuns().some(r => r.runId === runId)).toBe(true);
|
|
348
|
-
await fake.delete('sys_approval_request', { where: { id: 'areq_collider' } });
|
|
349
|
-
const re = await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
350
|
-
expect(re.resumed).toBe(true);
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it('a superseded returned request can neither resubmit again nor be recalled', async () => {
|
|
354
|
-
registerReviseFlow();
|
|
355
|
-
const { req } = await startFlow();
|
|
356
|
-
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
357
|
-
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
358
|
-
|
|
359
|
-
// Round 2 is the live frontier; the round-1 row is history.
|
|
360
|
-
await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/supersedes/);
|
|
361
|
-
await expect(service.recall(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/supersedes/);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it('enforces the actor matrix: only pending approvers send back, only the submitter resubmits', async () => {
|
|
365
|
-
registerReviseFlow();
|
|
366
|
-
const { req } = await startFlow();
|
|
367
|
-
|
|
368
|
-
await expect(service.sendBack(req.id, { actorId: 'intruder' }, asUser('intruder'))).rejects.toThrow(/FORBIDDEN/);
|
|
369
|
-
await expect(service.sendBack(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/FORBIDDEN/);
|
|
370
|
-
|
|
371
|
-
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
372
|
-
await expect(service.resubmit(req.id, { actorId: 'u1' }, asUser('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
373
|
-
// Resubmit only applies to returned requests — a pending one rejects.
|
|
374
|
-
const fresh = await startFlowSecondRecord();
|
|
375
|
-
await expect(service.resubmit(fresh.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/INVALID_STATE/);
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
/** A second record's pending request, to probe resubmit-on-pending. */
|
|
379
|
-
async function startFlowSecondRecord() {
|
|
380
|
-
await automation.execute('expense_approval', {
|
|
381
|
-
object: 'fin_expense', record: { id: 'x9', amount: 50 }, userId: 'submitter',
|
|
382
|
-
});
|
|
383
|
-
const rows = await fake.find('sys_approval_request', { where: { status: 'pending', record_id: 'x9' } });
|
|
384
|
-
return rows[0];
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
it('status mirror follows the rounds when approvalStatusField is configured', async () => {
|
|
388
|
-
automation.registerFlow('mirrored_flow', {
|
|
389
|
-
name: 'mirrored_flow', label: 'Mirrored Flow', type: 'autolaunched',
|
|
390
|
-
nodes: [
|
|
391
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
392
|
-
{
|
|
393
|
-
id: 'review', type: 'approval', label: 'Review',
|
|
394
|
-
config: { approvers: [{ type: 'user', value: 'u1' }], approvalStatusField: 'approval_status' },
|
|
395
|
-
},
|
|
396
|
-
{ id: 'wait_revision', type: 'wait', label: 'Awaiting Revision' },
|
|
397
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
398
|
-
],
|
|
399
|
-
edges: [
|
|
400
|
-
{ id: 'e1', source: 'start', target: 'review' },
|
|
401
|
-
{ id: 'e2', source: 'review', target: 'end', label: 'approve' },
|
|
402
|
-
{ id: 'e3', source: 'review', target: 'end', label: 'reject' },
|
|
403
|
-
{ id: 'e4', source: 'review', target: 'wait_revision', label: 'revise' },
|
|
404
|
-
{ id: 'e5', source: 'wait_revision', target: 'review', label: 'resubmit', type: 'back' },
|
|
405
|
-
],
|
|
406
|
-
});
|
|
407
|
-
await fake.insert('fin_expense', { id: 'm1', approval_status: null });
|
|
408
|
-
await automation.execute('mirrored_flow', { object: 'fin_expense', record: { id: 'm1' }, userId: 'submitter' });
|
|
409
|
-
const req = await pendingReq();
|
|
410
|
-
const mirror = async () => (await fake.find('fin_expense', { where: { id: 'm1' } }))[0].approval_status;
|
|
411
|
-
|
|
412
|
-
expect(await mirror()).toBe('pending');
|
|
413
|
-
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
414
|
-
expect(await mirror()).toBe('returned');
|
|
415
|
-
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
416
|
-
expect(await mirror()).toBe('pending'); // round 2 re-mirrors
|
|
417
|
-
});
|
|
418
|
-
});
|