@objectstack/plugin-approvals 9.2.0 → 9.3.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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +27 -0
- package/dist/index.d.mts +2343 -177
- package/dist/index.d.ts +2343 -177
- package/dist/index.js +1293 -34
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1292 -34
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/action-link-pages.ts +102 -0
- package/src/approval-revise.test.ts +411 -0
- package/src/approval-service.test.ts +452 -4
- package/src/approval-service.ts +1128 -34
- package/src/approvals-plugin.ts +124 -3
- package/src/index.ts +1 -0
- package/src/nav-contribution.test.ts +3 -1
- package/src/sys-approval-action.object.ts +5 -1
- package/src/sys-approval-approver.object.ts +78 -0
- package/src/sys-approval-request.object.ts +8 -4
- package/src/sys-approval-token.object.ts +94 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-approvals",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.3.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,17 +13,17 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "9.
|
|
17
|
-
"@objectstack/formula": "9.
|
|
18
|
-
"@objectstack/metadata-core": "9.
|
|
19
|
-
"@objectstack/platform-objects": "9.
|
|
20
|
-
"@objectstack/spec": "9.
|
|
16
|
+
"@objectstack/core": "9.3.0",
|
|
17
|
+
"@objectstack/formula": "9.3.0",
|
|
18
|
+
"@objectstack/metadata-core": "9.3.0",
|
|
19
|
+
"@objectstack/platform-objects": "9.3.0",
|
|
20
|
+
"@objectstack/spec": "9.3.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^25.9.2",
|
|
24
24
|
"typescript": "^6.0.3",
|
|
25
25
|
"vitest": "^4.1.8",
|
|
26
|
-
"@objectstack/service-automation": "9.
|
|
26
|
+
"@objectstack/service-automation": "9.3.0"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"objectstack",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session-less HTML for the actionable-link confirm/result pages (ADR-0043).
|
|
5
|
+
*
|
|
6
|
+
* Deliberately tiny and dependency-free: these pages are reached from an
|
|
7
|
+
* email or IM message by a bearer with no session, so they must not assume
|
|
8
|
+
* the Console bundle, auth state, or client-side i18n. Static bilingual
|
|
9
|
+
* (EN / 中文) copy keeps them readable for the demo audience without a
|
|
10
|
+
* locale negotiation step.
|
|
11
|
+
*
|
|
12
|
+
* The GET page NEVER mutates — the decision happens only on the POST form
|
|
13
|
+
* submit (mail-gateway link prefetchers must not approve requests).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ApprovalRequestRow, ApprovalActionKind } from '@objectstack/spec/contracts';
|
|
17
|
+
|
|
18
|
+
function esc(s: unknown): string {
|
|
19
|
+
return String(s ?? '')
|
|
20
|
+
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
21
|
+
.replaceAll('"', '"').replaceAll("'", ''');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function shell(title: string, body: string): string {
|
|
25
|
+
return `<!doctype html>
|
|
26
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
28
|
+
<meta name="robots" content="noindex">
|
|
29
|
+
<title>${esc(title)}</title>
|
|
30
|
+
<style>
|
|
31
|
+
body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
|
|
32
|
+
background:#f6f7f9;color:#1a202c;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
|
33
|
+
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;max-width:440px;width:calc(100% - 32px);
|
|
34
|
+
padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
|
35
|
+
h1{font-size:18px;margin:0 0 4px}
|
|
36
|
+
.sub{color:#64748b;font-size:13px;margin:0 0 20px}
|
|
37
|
+
.row{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid #f1f5f9;font-size:14px}
|
|
38
|
+
.row b{font-weight:600;text-align:right}
|
|
39
|
+
.k{color:#64748b}
|
|
40
|
+
.actions{margin-top:22px;display:flex;gap:10px}
|
|
41
|
+
button{flex:1;padding:10px 16px;border-radius:8px;border:1px solid transparent;font-size:15px;font-weight:600;cursor:pointer}
|
|
42
|
+
.approve{background:#059669;color:#fff}
|
|
43
|
+
.reject{background:#fff;color:#dc2626;border-color:#fca5a5}
|
|
44
|
+
.badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;margin-bottom:14px}
|
|
45
|
+
.ok{background:#ecfdf5;color:#047857}.warn{background:#fffbeb;color:#b45309}.err{background:#fef2f2;color:#b91c1c}
|
|
46
|
+
a{color:#2563eb;text-decoration:none}
|
|
47
|
+
.foot{margin-top:18px;font-size:12px;color:#94a3b8}
|
|
48
|
+
</style></head><body><div class="card">${body}</div></body></html>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function summaryRows(req: ApprovalRequestRow): string {
|
|
52
|
+
const rows: Array<[string, string]> = [
|
|
53
|
+
['Process · 流程', req.process_label || req.process_name],
|
|
54
|
+
['Step · 步骤', req.step_label || req.current_step || '—'],
|
|
55
|
+
['Record · 记录', req.record_title || req.record_id],
|
|
56
|
+
['Object · 对象', req.object_label || req.object_name],
|
|
57
|
+
['Requester · 申请人', req.submitter_name || req.submitter_id || '—'],
|
|
58
|
+
];
|
|
59
|
+
return rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><b>${esc(v)}</b></div>`).join('');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** GET page: summary + a POST form. Rendering only — no mutation. */
|
|
63
|
+
export function renderConfirmPage(input: {
|
|
64
|
+
request: ApprovalRequestRow;
|
|
65
|
+
action: Extract<ApprovalActionKind, 'approve' | 'reject'>;
|
|
66
|
+
approverId: string;
|
|
67
|
+
token: string;
|
|
68
|
+
actPath: string;
|
|
69
|
+
}): string {
|
|
70
|
+
const approving = input.action === 'approve';
|
|
71
|
+
const verb = approving ? 'Approve · 通过' : 'Reject · 拒绝';
|
|
72
|
+
return shell(`${verb} — Approval`, `
|
|
73
|
+
<h1>${approving ? '✅ Approve this request?' : '⛔ Reject this request?'}</h1>
|
|
74
|
+
<p class="sub">${approving ? '确认通过该审批请求?' : '确认拒绝该审批请求?'}
|
|
75
|
+
Acting as · 操作身份:<b>${esc(input.approverId)}</b></p>
|
|
76
|
+
${summaryRows(input.request)}
|
|
77
|
+
<form method="post" action="${esc(input.actPath)}" class="actions">
|
|
78
|
+
<input type="hidden" name="token" value="${esc(input.token)}">
|
|
79
|
+
<button type="submit" class="${approving ? 'approve' : 'reject'}">${verb}</button>
|
|
80
|
+
</form>
|
|
81
|
+
<p class="foot">This link is single-use and expires automatically. · 此链接一次有效,过期自动失效。</p>`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const RESULT_COPY: Record<string, { cls: string; title: string; body: string }> = {
|
|
85
|
+
approved: { cls: 'ok', title: '✅ Approved · 已通过', body: 'The decision was recorded. · 审批结果已记录。' },
|
|
86
|
+
rejected: { cls: 'ok', title: '⛔ Rejected · 已拒绝', body: 'The decision was recorded. · 审批结果已记录。' },
|
|
87
|
+
invalid: { cls: 'err', title: 'Invalid link · 链接无效', body: 'This link is not recognized. · 无法识别该链接。' },
|
|
88
|
+
expired: { cls: 'warn', title: 'Link expired · 链接已过期', body: 'Ask the requester to send a new reminder. · 请让申请人重新发送催办。' },
|
|
89
|
+
consumed: { cls: 'warn', title: 'Already used · 链接已使用', body: 'This link was already used once. · 该链接已被使用过。' },
|
|
90
|
+
not_pending: { cls: 'warn', title: 'Already decided · 请求已处理', body: 'This request is no longer pending. · 该请求已不在待审批状态。' },
|
|
91
|
+
not_approver: { cls: 'warn', title: 'No longer your approval · 已不在你名下', body: 'This approval was handed to someone else. · 该审批已转由他人处理。' },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Terminal page for every redemption outcome (and stale GETs). */
|
|
95
|
+
export function renderResultPage(kind: keyof typeof RESULT_COPY, request?: ApprovalRequestRow): string {
|
|
96
|
+
const copy = RESULT_COPY[kind] ?? RESULT_COPY.invalid;
|
|
97
|
+
return shell(copy.title, `
|
|
98
|
+
<span class="badge ${copy.cls}">${esc(copy.title)}</span>
|
|
99
|
+
${request ? summaryRows(request) : ''}
|
|
100
|
+
<p>${esc(copy.body)}</p>
|
|
101
|
+
<p class="foot"><a href="/system/approvals">Open the Approvals Inbox · 打开审批中心</a></p>`);
|
|
102
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
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, roles: [], permissions: [] } as any;
|
|
23
|
+
const USER_CTX = { isSystem: false, roles: [], permissions: [] } as any;
|
|
24
|
+
|
|
25
|
+
const noopLogger = { info() {}, warn() {}, error() {}, debug() {} };
|
|
26
|
+
|
|
27
|
+
/** In-memory ObjectQL stand-in: equality/$in where, orderBy, limit. */
|
|
28
|
+
function makeFakeEngine() {
|
|
29
|
+
const tables = new Map<string, any[]>();
|
|
30
|
+
const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!));
|
|
31
|
+
const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => {
|
|
32
|
+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
|
|
33
|
+
if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne;
|
|
34
|
+
return row[k] === v;
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
tables,
|
|
38
|
+
async find(object: string, opts: any = {}) {
|
|
39
|
+
const where = opts.where ?? opts.filter ?? {};
|
|
40
|
+
let out = rows(object).filter(r => matches(r, where));
|
|
41
|
+
for (const ord of [...(opts.orderBy ?? [])].reverse()) {
|
|
42
|
+
// Canonical SortNode key only (spec/data/query.zod.ts): the real
|
|
43
|
+
// engine strips an unknown `direction:` key and defaults to asc, so
|
|
44
|
+
// the mock must too — honoring both keys masks wrong-key sorts.
|
|
45
|
+
const dir = ord.order === 'desc' ? -1 : 1;
|
|
46
|
+
out = [...out].sort((a, b) => (a[ord.field] < b[ord.field] ? -dir : a[ord.field] > b[ord.field] ? dir : 0));
|
|
47
|
+
}
|
|
48
|
+
if (opts.limit) out = out.slice(0, opts.limit);
|
|
49
|
+
return out.map(r => ({ ...r }));
|
|
50
|
+
},
|
|
51
|
+
async insert(object: string, data: any) {
|
|
52
|
+
rows(object).push({ ...data });
|
|
53
|
+
return { ...data };
|
|
54
|
+
},
|
|
55
|
+
async update(object: string, idOrData: any) {
|
|
56
|
+
const row = rows(object).find(r => r.id === idOrData.id);
|
|
57
|
+
if (row) Object.assign(row, idOrData);
|
|
58
|
+
return row ? { ...row } : null;
|
|
59
|
+
},
|
|
60
|
+
async delete(object: string, opts: any = {}) {
|
|
61
|
+
const where = opts.where ?? {};
|
|
62
|
+
const list = rows(object);
|
|
63
|
+
for (let i = list.length - 1; i >= 0; i--) if (matches(list[i], where)) list.splice(i, 1);
|
|
64
|
+
return { affected: 1 };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('Send back for revision (ADR-0044)', () => {
|
|
70
|
+
let automation: AutomationEngine;
|
|
71
|
+
let service: ApprovalService;
|
|
72
|
+
let fake: ReturnType<typeof makeFakeEngine>;
|
|
73
|
+
const marks: string[] = [];
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
marks.length = 0;
|
|
77
|
+
automation = new AutomationEngine(noopLogger as any);
|
|
78
|
+
fake = makeFakeEngine();
|
|
79
|
+
// Ticking clock: every read advances 1s, so rounds never share created_at
|
|
80
|
+
// (assertLatestForRun orders by it).
|
|
81
|
+
let t = Date.parse('2026-06-12T00:00:00Z');
|
|
82
|
+
service = new ApprovalService({
|
|
83
|
+
engine: fake as any,
|
|
84
|
+
logger: noopLogger,
|
|
85
|
+
clock: { now: () => new Date((t += 1000)) },
|
|
86
|
+
});
|
|
87
|
+
service.attachAutomation(automation);
|
|
88
|
+
registerApprovalNode(automation, service, noopLogger);
|
|
89
|
+
automation.registerNodeExecutor({
|
|
90
|
+
type: 'mark',
|
|
91
|
+
async execute(node: any) { marks.push(node.id); return { success: true }; },
|
|
92
|
+
});
|
|
93
|
+
// Signal-flavor wait stand-in: suspends until an external resume — the
|
|
94
|
+
// same contract as the built-in wait node's non-timer path.
|
|
95
|
+
automation.registerNodeExecutor({
|
|
96
|
+
type: 'wait',
|
|
97
|
+
async execute(node: any) { return { success: true, suspend: true, correlation: `wait:${node.id}` }; },
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function registerReviseFlow(opts?: {
|
|
102
|
+
maxRevisions?: number;
|
|
103
|
+
behavior?: 'first_response' | 'unanimous';
|
|
104
|
+
approvers?: Array<{ type: string; value?: string }>;
|
|
105
|
+
}) {
|
|
106
|
+
automation.registerFlow('expense_approval', {
|
|
107
|
+
name: 'expense_approval',
|
|
108
|
+
label: 'Expense Approval',
|
|
109
|
+
type: 'autolaunched',
|
|
110
|
+
nodes: [
|
|
111
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
112
|
+
{
|
|
113
|
+
id: 'review', type: 'approval', label: 'Manager Review',
|
|
114
|
+
config: {
|
|
115
|
+
approvers: opts?.approvers ?? [{ type: 'user', value: 'u1' }],
|
|
116
|
+
behavior: opts?.behavior,
|
|
117
|
+
...(opts?.maxRevisions !== undefined ? { maxRevisions: opts.maxRevisions } : {}),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{ id: 'wait_revision', type: 'wait', label: 'Awaiting Revision' },
|
|
121
|
+
{ id: 'on_approved', type: 'mark', label: 'Approved' },
|
|
122
|
+
{ id: 'on_rejected', type: 'mark', label: 'Rejected' },
|
|
123
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
124
|
+
],
|
|
125
|
+
edges: [
|
|
126
|
+
{ id: 'e1', source: 'start', target: 'review' },
|
|
127
|
+
{ id: 'e2', source: 'review', target: 'on_approved', label: 'approve' },
|
|
128
|
+
{ id: 'e3', source: 'review', target: 'on_rejected', label: 'reject' },
|
|
129
|
+
{ id: 'e4', source: 'review', target: 'wait_revision', label: 'revise' },
|
|
130
|
+
// The cycle-closing back-edge (ADR-0044): resubmit re-enters the approval node.
|
|
131
|
+
{ id: 'e5', source: 'wait_revision', target: 'review', label: 'resubmit', type: 'back' },
|
|
132
|
+
{ id: 'e6', source: 'on_approved', target: 'end' },
|
|
133
|
+
{ id: 'e7', source: 'on_rejected', target: 'end' },
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function startFlow() {
|
|
139
|
+
const paused = await automation.execute('expense_approval', {
|
|
140
|
+
object: 'fin_expense', record: { id: 'x1', amount: 900 }, userId: 'submitter',
|
|
141
|
+
});
|
|
142
|
+
expect(paused.status).toBe('paused');
|
|
143
|
+
const [req] = await fake.find('sys_approval_request', { where: { status: 'pending' } });
|
|
144
|
+
return { runId: paused.runId!, req };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const pendingReq = async () => (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
148
|
+
const actionsOf = async (requestId: string) =>
|
|
149
|
+
(await fake.find('sys_approval_action', { where: { request_id: requestId } })).map((a: any) => a.action);
|
|
150
|
+
|
|
151
|
+
it('registers a revise flow with a declared back-edge (cycle allowed)', () => {
|
|
152
|
+
expect(() => registerReviseFlow()).not.toThrow();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('full round trip: send back → returned + wait → resubmit → round 2 → approve', async () => {
|
|
156
|
+
registerReviseFlow();
|
|
157
|
+
const { runId, req } = await startFlow();
|
|
158
|
+
|
|
159
|
+
const sent = await service.sendBack(req.id, { actorId: 'u1', comment: 'fix the totals' }, USER_CTX);
|
|
160
|
+
expect(sent.resumed).toBe(true);
|
|
161
|
+
expect(sent.autoRejected).toBeUndefined();
|
|
162
|
+
expect(sent.request.status).toBe('returned');
|
|
163
|
+
expect(marks).toHaveLength(0);
|
|
164
|
+
|
|
165
|
+
// The run is paused at the wait point, not terminal.
|
|
166
|
+
expect(automation.listSuspendedRuns()).toMatchObject([{ runId, nodeId: 'wait_revision' }]);
|
|
167
|
+
expect(await actionsOf(req.id)).toEqual(['submit', 'revise']);
|
|
168
|
+
|
|
169
|
+
const re = await service.resubmit(req.id, { actorId: 'submitter', comment: 'totals fixed' }, USER_CTX);
|
|
170
|
+
expect(re.resumed).toBe(true);
|
|
171
|
+
expect(await actionsOf(req.id)).toEqual(['submit', 'revise', 'resubmit']);
|
|
172
|
+
|
|
173
|
+
// Round 2: a NEW pending request on the same (run, node), round stamped.
|
|
174
|
+
const round2 = await pendingReq();
|
|
175
|
+
expect(round2.id).not.toBe(req.id);
|
|
176
|
+
expect(round2).toMatchObject({ flow_run_id: runId, flow_node_id: 'review' });
|
|
177
|
+
expect((await service.getRequest(round2.id, SYSTEM_CTX))?.round).toBe(2);
|
|
178
|
+
expect(automation.listSuspendedRuns()).toMatchObject([{ runId, nodeId: 'review' }]);
|
|
179
|
+
|
|
180
|
+
// Round 2 approval completes the flow down the approve branch.
|
|
181
|
+
const out = await service.decide(round2.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
|
|
182
|
+
expect(out).toMatchObject({ finalized: true, resumed: true });
|
|
183
|
+
expect(marks).toEqual(['on_approved']);
|
|
184
|
+
expect(automation.listSuspendedRuns()).toHaveLength(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('multi-round: two send-backs stamp rounds 2 and 3', async () => {
|
|
188
|
+
registerReviseFlow();
|
|
189
|
+
const { req } = await startFlow();
|
|
190
|
+
expect((await service.getRequest(req.id, SYSTEM_CTX))?.round).toBeUndefined(); // round 1
|
|
191
|
+
|
|
192
|
+
await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
|
|
193
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
|
|
194
|
+
const round2 = await pendingReq();
|
|
195
|
+
expect((await service.getRequest(round2.id, SYSTEM_CTX))?.round).toBe(2);
|
|
196
|
+
|
|
197
|
+
await service.sendBack(round2.id, { actorId: 'u1' }, USER_CTX);
|
|
198
|
+
await service.resubmit(round2.id, { actorId: 'submitter' }, USER_CTX);
|
|
199
|
+
const round3 = await pendingReq();
|
|
200
|
+
expect((await service.getRequest(round3.id, SYSTEM_CTX))?.round).toBe(3);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('maxRevisions overflow auto-rejects instead of returning', async () => {
|
|
204
|
+
registerReviseFlow({ maxRevisions: 1 });
|
|
205
|
+
const { req } = await startFlow();
|
|
206
|
+
|
|
207
|
+
// Send-back #1 fits the budget.
|
|
208
|
+
await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
|
|
209
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
|
|
210
|
+
const round2 = await pendingReq();
|
|
211
|
+
|
|
212
|
+
// Send-back #2 exceeds it → auto-reject, flow takes the reject branch.
|
|
213
|
+
const out = await service.sendBack(round2.id, { actorId: 'u1', comment: 'still wrong' }, USER_CTX);
|
|
214
|
+
expect(out.autoRejected).toBe(true);
|
|
215
|
+
expect(out.resumed).toBe(true);
|
|
216
|
+
expect(out.request.status).toBe('rejected');
|
|
217
|
+
expect(marks).toEqual(['on_rejected']);
|
|
218
|
+
// The trail preserves the approver's actual intent before the auto-reject.
|
|
219
|
+
expect(await actionsOf(round2.id)).toEqual(['submit', 'revise', 'reject']);
|
|
220
|
+
const acts = await fake.find('sys_approval_action', { where: { request_id: round2.id, action: 'reject' } });
|
|
221
|
+
expect(acts[0].comment).toMatch(/revision limit \(1\) exceeded/i);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('maxRevisions 0 disables send-back (immediate auto-reject)', async () => {
|
|
225
|
+
registerReviseFlow({ maxRevisions: 0 });
|
|
226
|
+
const { req } = await startFlow();
|
|
227
|
+
const out = await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
|
|
228
|
+
expect(out.autoRejected).toBe(true);
|
|
229
|
+
expect(marks).toEqual(['on_rejected']);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('rejects send-back when the flow has no revise out-edge', async () => {
|
|
233
|
+
automation.registerFlow('no_revise', {
|
|
234
|
+
name: 'no_revise', label: 'No Revise', type: 'autolaunched',
|
|
235
|
+
nodes: [
|
|
236
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
237
|
+
{ id: 'review', type: 'approval', label: 'Review', config: { approvers: [{ type: 'user', value: 'u1' }] } },
|
|
238
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
239
|
+
],
|
|
240
|
+
edges: [
|
|
241
|
+
{ id: 'e1', source: 'start', target: 'review' },
|
|
242
|
+
{ id: 'e2', source: 'review', target: 'end', label: 'approve' },
|
|
243
|
+
{ id: 'e3', source: 'review', target: 'end', label: 'reject' },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
await automation.execute('no_revise', { object: 'fin_expense', record: { id: 'x2' }, userId: 'submitter' });
|
|
247
|
+
const req = await pendingReq();
|
|
248
|
+
|
|
249
|
+
await expect(service.sendBack(req.id, { actorId: 'u1' }, USER_CTX)).rejects.toThrow(/no 'revise' out-edge/);
|
|
250
|
+
// Nothing moved: still pending, no revise audit row.
|
|
251
|
+
expect((await fake.find('sys_approval_request', { where: { id: req.id } }))[0].status).toBe('pending');
|
|
252
|
+
expect(await actionsOf(req.id)).toEqual(['submit']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('unanimous: one send-back finalizes immediately and round 2 reopens the full slate', async () => {
|
|
256
|
+
registerReviseFlow({
|
|
257
|
+
behavior: 'unanimous',
|
|
258
|
+
approvers: [{ type: 'user', value: 'u1' }, { type: 'user', value: 'u2' }],
|
|
259
|
+
});
|
|
260
|
+
const { req } = await startFlow();
|
|
261
|
+
|
|
262
|
+
// u1 approves — request holds for u2.
|
|
263
|
+
const first = await service.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
|
|
264
|
+
expect(first.finalized).toBe(false);
|
|
265
|
+
|
|
266
|
+
// u2 sends back instead: finalizes despite u1's earlier approval.
|
|
267
|
+
const sent = await service.sendBack(req.id, { actorId: 'u2', comment: 'rework' }, USER_CTX);
|
|
268
|
+
expect(sent.request.status).toBe('returned');
|
|
269
|
+
|
|
270
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
|
|
271
|
+
const round2 = await pendingReq();
|
|
272
|
+
// Fresh slate: BOTH approvers pending again — prior approvals are stale.
|
|
273
|
+
expect((round2.pending_approvers as string).split(',').sort()).toEqual(['u1', 'u2']);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('lock lifecycle: locked while pending, unlocked in the revise window, re-locked on resubmit', async () => {
|
|
277
|
+
registerReviseFlow();
|
|
278
|
+
const { req } = await startFlow();
|
|
279
|
+
|
|
280
|
+
// Bind the real lock hook against a hook-capturing engine facade.
|
|
281
|
+
let hook: ((ctx: any) => Promise<void>) | undefined;
|
|
282
|
+
bindApprovalLockHook({
|
|
283
|
+
registerHook: (_e: string, h: any) => { hook = h; },
|
|
284
|
+
unregisterHooksByPackage: () => 0,
|
|
285
|
+
find: fake.find.bind(fake),
|
|
286
|
+
} as any, noopLogger);
|
|
287
|
+
expect(hook).toBeDefined();
|
|
288
|
+
const editAttempt = () => hook!({
|
|
289
|
+
object: 'fin_expense',
|
|
290
|
+
input: { id: 'x1', data: { amount: 1200 } },
|
|
291
|
+
session: { isSystem: false, roles: [] },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // pending → locked
|
|
295
|
+
await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
|
|
296
|
+
await expect(editAttempt()).resolves.toBeUndefined(); // returned → unlocked
|
|
297
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
|
|
298
|
+
await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // round 2 pending → re-locked
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('recall crossing the revise window cancels the run (returned → recalled)', async () => {
|
|
302
|
+
registerReviseFlow();
|
|
303
|
+
const { runId, req } = await startFlow();
|
|
304
|
+
await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
|
|
305
|
+
|
|
306
|
+
// Only the submitter may abandon the revision.
|
|
307
|
+
await expect(service.recall(req.id, { actorId: 'u1' }, USER_CTX)).rejects.toThrow(/FORBIDDEN/);
|
|
308
|
+
|
|
309
|
+
const out = await service.recall(req.id, { actorId: 'submitter' }, USER_CTX);
|
|
310
|
+
expect(out.request.status).toBe('recalled');
|
|
311
|
+
expect(out.resumed).toBe(false);
|
|
312
|
+
// The run was terminally cancelled, not resumed down any branch.
|
|
313
|
+
expect(automation.listSuspendedRuns()).toHaveLength(0);
|
|
314
|
+
expect(marks).toHaveLength(0);
|
|
315
|
+
const log = (await automation.listRuns('expense_approval'))[0];
|
|
316
|
+
expect(log.status).toBe('cancelled');
|
|
317
|
+
expect(log.id).toBe(runId);
|
|
318
|
+
|
|
319
|
+
// The window is closed: resubmit is no longer possible.
|
|
320
|
+
await expect(service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/INVALID_STATE/);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('refuses resubmit while another pending request collides on the record (run stays resumable)', async () => {
|
|
324
|
+
registerReviseFlow();
|
|
325
|
+
const { runId, req } = await startFlow();
|
|
326
|
+
await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
|
|
327
|
+
|
|
328
|
+
// Simulate a record-change trigger re-firing off an edit made inside the
|
|
329
|
+
// revise window: a second, unrelated run opened its own pending request.
|
|
330
|
+
await fake.insert('sys_approval_request', {
|
|
331
|
+
id: 'areq_collider', object_name: 'fin_expense', record_id: 'x1',
|
|
332
|
+
status: 'pending', flow_run_id: 'run_other', flow_node_id: 'review',
|
|
333
|
+
submitter_id: 'submitter', process_name: 'flow:expense_approval',
|
|
334
|
+
created_at: new Date().toISOString(),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await expect(service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/DUPLICATE_REQUEST/);
|
|
338
|
+
// The refusal happened BEFORE the suspension was consumed — clearing the
|
|
339
|
+
// collision makes the same resubmit succeed.
|
|
340
|
+
expect(automation.listSuspendedRuns().some(r => r.runId === runId)).toBe(true);
|
|
341
|
+
await fake.delete('sys_approval_request', { where: { id: 'areq_collider' } });
|
|
342
|
+
const re = await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
|
|
343
|
+
expect(re.resumed).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('a superseded returned request can neither resubmit again nor be recalled', async () => {
|
|
347
|
+
registerReviseFlow();
|
|
348
|
+
const { req } = await startFlow();
|
|
349
|
+
await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
|
|
350
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
|
|
351
|
+
|
|
352
|
+
// Round 2 is the live frontier; the round-1 row is history.
|
|
353
|
+
await expect(service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/supersedes/);
|
|
354
|
+
await expect(service.recall(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/supersedes/);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('enforces the actor matrix: only pending approvers send back, only the submitter resubmits', async () => {
|
|
358
|
+
registerReviseFlow();
|
|
359
|
+
const { req } = await startFlow();
|
|
360
|
+
|
|
361
|
+
await expect(service.sendBack(req.id, { actorId: 'intruder' }, USER_CTX)).rejects.toThrow(/FORBIDDEN/);
|
|
362
|
+
await expect(service.sendBack(req.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/FORBIDDEN/);
|
|
363
|
+
|
|
364
|
+
await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
|
|
365
|
+
await expect(service.resubmit(req.id, { actorId: 'u1' }, USER_CTX)).rejects.toThrow(/FORBIDDEN/);
|
|
366
|
+
// Resubmit only applies to returned requests — a pending one rejects.
|
|
367
|
+
const fresh = await startFlowSecondRecord();
|
|
368
|
+
await expect(service.resubmit(fresh.id, { actorId: 'submitter' }, USER_CTX)).rejects.toThrow(/INVALID_STATE/);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
/** A second record's pending request, to probe resubmit-on-pending. */
|
|
372
|
+
async function startFlowSecondRecord() {
|
|
373
|
+
await automation.execute('expense_approval', {
|
|
374
|
+
object: 'fin_expense', record: { id: 'x9', amount: 50 }, userId: 'submitter',
|
|
375
|
+
});
|
|
376
|
+
const rows = await fake.find('sys_approval_request', { where: { status: 'pending', record_id: 'x9' } });
|
|
377
|
+
return rows[0];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
it('status mirror follows the rounds when approvalStatusField is configured', async () => {
|
|
381
|
+
automation.registerFlow('mirrored_flow', {
|
|
382
|
+
name: 'mirrored_flow', label: 'Mirrored Flow', type: 'autolaunched',
|
|
383
|
+
nodes: [
|
|
384
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
385
|
+
{
|
|
386
|
+
id: 'review', type: 'approval', label: 'Review',
|
|
387
|
+
config: { approvers: [{ type: 'user', value: 'u1' }], approvalStatusField: 'approval_status' },
|
|
388
|
+
},
|
|
389
|
+
{ id: 'wait_revision', type: 'wait', label: 'Awaiting Revision' },
|
|
390
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
391
|
+
],
|
|
392
|
+
edges: [
|
|
393
|
+
{ id: 'e1', source: 'start', target: 'review' },
|
|
394
|
+
{ id: 'e2', source: 'review', target: 'end', label: 'approve' },
|
|
395
|
+
{ id: 'e3', source: 'review', target: 'end', label: 'reject' },
|
|
396
|
+
{ id: 'e4', source: 'review', target: 'wait_revision', label: 'revise' },
|
|
397
|
+
{ id: 'e5', source: 'wait_revision', target: 'review', label: 'resubmit', type: 'back' },
|
|
398
|
+
],
|
|
399
|
+
});
|
|
400
|
+
await fake.insert('fin_expense', { id: 'm1', approval_status: null });
|
|
401
|
+
await automation.execute('mirrored_flow', { object: 'fin_expense', record: { id: 'm1' }, userId: 'submitter' });
|
|
402
|
+
const req = await pendingReq();
|
|
403
|
+
const mirror = async () => (await fake.find('fin_expense', { where: { id: 'm1' } }))[0].approval_status;
|
|
404
|
+
|
|
405
|
+
expect(await mirror()).toBe('pending');
|
|
406
|
+
await service.sendBack(req.id, { actorId: 'u1' }, USER_CTX);
|
|
407
|
+
expect(await mirror()).toBe('returned');
|
|
408
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, USER_CTX);
|
|
409
|
+
expect(await mirror()).toBe('pending'); // round 2 re-mirrors
|
|
410
|
+
});
|
|
411
|
+
});
|