@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +863 -0
  2. package/dist/index.d.mts +2236 -2688
  3. package/dist/index.d.ts +2236 -2688
  4. package/dist/index.js +591 -128
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +590 -127
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +17 -10
  9. package/.turbo/turbo-build.log +0 -22
  10. package/scripts/i18n-extract.config.ts +0 -38
  11. package/src/action-link-pages.ts +0 -102
  12. package/src/approval-actor-impersonation.test.ts +0 -330
  13. package/src/approval-node.test.ts +0 -356
  14. package/src/approval-node.ts +0 -196
  15. package/src/approval-revise.test.ts +0 -418
  16. package/src/approval-service.test.ts +0 -2858
  17. package/src/approval-service.ts +0 -3617
  18. package/src/approvals-plugin.ts +0 -294
  19. package/src/approver-cross-org.integration.test.ts +0 -206
  20. package/src/approver-org-scope.test.ts +0 -201
  21. package/src/approver-org-scope.ts +0 -261
  22. package/src/index.ts +0 -42
  23. package/src/lifecycle-hooks.ts +0 -201
  24. package/src/nav-contribution.test.ts +0 -50
  25. package/src/record-lock-schedule-run.integration.test.ts +0 -206
  26. package/src/status-mirror-cascade.integration.test.ts +0 -224
  27. package/src/sys-approval-action.object.ts +0 -149
  28. package/src/sys-approval-approver.object.ts +0 -85
  29. package/src/sys-approval-delegation.object.test.ts +0 -42
  30. package/src/sys-approval-delegation.object.ts +0 -142
  31. package/src/sys-approval-request.object.test.ts +0 -116
  32. package/src/sys-approval-request.object.ts +0 -413
  33. package/src/sys-approval-token.object.ts +0 -101
  34. package/src/translations/bundle-ownership.test.ts +0 -48
  35. package/src/translations/en.objects.generated.ts +0 -311
  36. package/src/translations/es-ES.objects.generated.ts +0 -311
  37. package/src/translations/index.ts +0 -23
  38. package/src/translations/ja-JP.objects.generated.ts +0 -311
  39. package/src/translations/zh-CN.objects.generated.ts +0 -311
  40. 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
- });