@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +439 -0
  2. package/dist/index.d.mts +2304 -3491
  3. package/dist/index.d.ts +2304 -3491
  4. package/dist/index.js +116 -43
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +114 -41
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +16 -9
  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,149 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectSchema, Field } from '@objectstack/spec/data';
4
-
5
- /**
6
- * sys_approval_action — Audit trail row per approval action.
7
- *
8
- * Append-only: every `submit`, `approve`, `reject`, `recall`, or
9
- * `escalate` event lands here. The engine reads back per-step approval
10
- * rows to evaluate `behavior: 'unanimous'` (all approvers must approve
11
- * before advancing) versus `first_response` (any single approval
12
- * advances the step).
13
- *
14
- * @namespace sys
15
- */
16
- export const SysApprovalAction = ObjectSchema.create({
17
- name: 'sys_approval_action',
18
- label: 'Approval Action',
19
- pluralLabel: 'Approval Actions',
20
- icon: 'check-circle',
21
- isSystem: true,
22
- managedBy: 'append-only',
23
- description: 'Append-only audit trail for approval actions',
24
- displayNameField: 'id',
25
- nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
26
- titleFormat: '{action} · {step_name}',
27
- highlightFields: ['request_id', 'step_name', 'action', 'actor_id', 'created_at'],
28
-
29
- // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
30
- // are OWNED by this row — and the storage service would otherwise authorize
31
- // their download by testing whether the caller can READ this row. It cannot:
32
- // this table is deliberately closed to ordinary approver positions, so that
33
- // test denies the very approver the attachment was filed for. The approvals
34
- // service already owns the rule for seeing a decision (visibility of the
35
- // parent request, exactly as `listActions` applies it), so it answers.
36
- fileAccessDelegate: 'approvals',
37
-
38
- listViews: {
39
- recent: {
40
- type: 'grid',
41
- name: 'recent',
42
- label: 'Recent',
43
- data: { provider: 'object', object: 'sys_approval_action' },
44
- columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'comment'],
45
- sort: [{ field: 'created_at', order: 'desc' }],
46
- pagination: { pageSize: 50 },
47
- emptyState: { title: 'No approval actions yet', message: 'Actions are logged automatically when approvals progress.' },
48
- },
49
- by_actor: {
50
- type: 'grid',
51
- name: 'by_actor',
52
- label: 'By Actor',
53
- data: { provider: 'object', object: 'sys_approval_action' },
54
- columns: ['actor_id', 'created_at', 'request_id', 'step_name', 'action'],
55
- sort: [{ field: 'actor_id', order: 'asc' }, { field: 'created_at', order: 'desc' }],
56
- grouping: { fields: [{ field: 'actor_id', order: 'asc', collapsed: false }] },
57
- pagination: { pageSize: 100 },
58
- },
59
- all_actions: {
60
- type: 'grid',
61
- name: 'all_actions',
62
- label: 'All',
63
- data: { provider: 'object', object: 'sys_approval_action' },
64
- columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'comment'],
65
- sort: [{ field: 'created_at', order: 'desc' }],
66
- pagination: { pageSize: 100 },
67
- },
68
- },
69
-
70
- fields: {
71
- id: Field.text({ label: 'Action ID', required: true, readonly: true, group: 'System' }),
72
-
73
- organization_id: Field.lookup('sys_organization', {
74
- label: 'Organization',
75
- required: false,
76
- group: 'System',
77
- description: 'Tenant that owns this action (mirrors the parent request)',
78
- }),
79
-
80
- request_id: Field.lookup('sys_approval_request', {
81
- label: 'Request',
82
- required: true,
83
- group: 'Target',
84
- }),
85
-
86
- step_name: Field.text({
87
- label: 'Step',
88
- required: false,
89
- maxLength: 100,
90
- description: 'Machine name of the step at the time of the action',
91
- group: 'Target',
92
- }),
93
-
94
- step_index: Field.number({
95
- label: 'Step Index',
96
- required: false,
97
- group: 'Target',
98
- }),
99
-
100
- action: Field.select(
101
- // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
102
- // remind / request_info / comment are thread interactions — they never
103
- // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
104
- // revision and the later resubmission. ooo_substitute (#1322 M1) is a
105
- // system-recorded reroute of an out-of-office approver — no flow movement.
106
- ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit', 'ooo_substitute'],
107
- {
108
- label: 'Action',
109
- required: true,
110
- group: 'Action',
111
- },
112
- ),
113
-
114
- actor_id: Field.lookup('sys_user', {
115
- label: 'Actor',
116
- required: false,
117
- group: 'Action',
118
- }),
119
-
120
- comment: Field.textarea({ label: 'Comment', required: false, group: 'Action' }),
121
-
122
- attachments: Field.file({
123
- label: 'Attachments',
124
- required: false,
125
- multiple: true,
126
- group: 'Action',
127
- description: 'Files supporting this action — e.g. a signed contract or evidence (#3266).',
128
- }),
129
-
130
- created_at: Field.datetime({
131
- label: 'Created At',
132
- required: true,
133
- defaultValue: 'NOW()',
134
- readonly: true,
135
- group: 'System',
136
- }),
137
- },
138
-
139
- indexes: [
140
- { fields: ['request_id', 'created_at'] },
141
- { fields: ['request_id', 'step_index', 'action'] },
142
- ],
143
-
144
- enable: {
145
- // [ADR-0103] Engine-owned append-only decision log: appended by the approval
146
- // engine (SYSTEM_CTX). Reads stay open.
147
- apiMethods: ['get', 'list'],
148
- },
149
- });
@@ -1,85 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectSchema, Field } from '@objectstack/spec/data';
4
-
5
- /**
6
- * sys_approval_approver — Pending-approver index (issue #1745).
7
- *
8
- * One row per (request, approver identity) while the request is **pending**.
9
- * `sys_approval_request.pending_approvers` stays the human-readable source of
10
- * truth (a CSV column), but CSV substring matching can neither be indexed nor
11
- * pushed into an engine query — which made "my pending" a post-filter in
12
- * memory and broke pagination beyond the scan window.
13
- *
14
- * This table is that CSV, normalized: the service mirrors every change to
15
- * `pending_approvers` here (open / decide / recall / send-back / reassign /
16
- * escalate), and clears the rows when the request leaves `pending`. So the
17
- * table only ever holds the live work queue — its size tracks the number of
18
- * open approvals, not the append-only request history.
19
- *
20
- * `approver` holds one identity literal exactly as it appears in the CSV:
21
- * a user id, an email, or a `role:<name>` / `team:<name>` style literal.
22
- * Equality (or `$in`) on this column is the indexed replacement for the old
23
- * per-row substring match.
24
- *
25
- * @namespace sys
26
- */
27
- export const SysApprovalApprover = ObjectSchema.create({
28
- name: 'sys_approval_approver',
29
- label: 'Approval Approver',
30
- pluralLabel: 'Approval Approvers',
31
- icon: 'users',
32
- isSystem: true,
33
- managedBy: 'engine-owned',
34
- description: 'Normalized pending-approver rows for indexed inbox queries',
35
- displayNameField: 'id',
36
- nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
37
- titleFormat: '{approver} · {request_id}',
38
- highlightFields: ['request_id', 'approver', 'created_at'],
39
-
40
- fields: {
41
- id: Field.text({ label: 'Row ID', required: true, readonly: true, group: 'System' }),
42
-
43
- organization_id: Field.lookup('sys_organization', {
44
- label: 'Organization',
45
- required: false,
46
- group: 'System',
47
- description: 'Tenant that owns this row (mirrors the parent request)',
48
- }),
49
-
50
- request_id: Field.lookup('sys_approval_request', {
51
- label: 'Request',
52
- required: true,
53
- group: 'Target',
54
- }),
55
-
56
- approver: Field.text({
57
- label: 'Approver',
58
- required: true,
59
- maxLength: 255,
60
- description: 'One pending-approver identity: user id, email, or role:/team: literal',
61
- group: 'Target',
62
- }),
63
-
64
- created_at: Field.datetime({
65
- label: 'Created At',
66
- required: true,
67
- defaultValue: 'NOW()',
68
- readonly: true,
69
- group: 'System',
70
- }),
71
- },
72
-
73
- indexes: [
74
- // "My pending" inbox: equality on the identity literal, scoped by tenant.
75
- { fields: ['approver', 'organization_id'] },
76
- // Sync path: rewrite all rows of one request on each approver-set change.
77
- { fields: ['request_id'] },
78
- ],
79
-
80
- enable: {
81
- // [ADR-0103] Engine-owned: approver rows are rewritten by the approval
82
- // engine (SYSTEM_CTX) on each approver-set change, never via generic CRUD.
83
- apiMethods: ['get', 'list'],
84
- },
85
- });
@@ -1,42 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * #3026 follow-up — `sys_approval_delegation` must expose the BATCH shape of
5
- * the write verbs it already grants.
6
- *
7
- * The object is `managedBy: 'system'` but opens generic writes deliberately
8
- * (`userActions: { create, edit, delete }` — an out-of-office rule is authored
9
- * by its own user through the plain data endpoint), so the ADR-0103 D3
10
- * reconciliation strips nothing and its boilerplate CRUD-five whitelist reaches
11
- * the REST gate as authored. Since the #3391 P1 contract made bulk
12
- * `bulk ∧ derived(child)`, that whitelist — which never named the `bulk`
13
- * primitive — 405s every batch route while the single-record verbs stay open.
14
- */
15
-
16
- import { describe, expect, it } from 'vitest';
17
- import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data';
18
- import { SysApprovalDelegation } from './sys-approval-delegation.object';
19
-
20
- describe('sys_approval_delegation — batch exposure (#3026 / #3391 P1 companion)', () => {
21
- it('grants the bulk primitive alongside its single-record write verbs', () => {
22
- expect(SysApprovalDelegation.enable?.apiMethods).toContain('bulk');
23
- for (const verb of ['get', 'list', 'create', 'update', 'delete'] as const) {
24
- expect(SysApprovalDelegation.enable?.apiMethods, `must keep ${verb}`).toContain(verb);
25
- }
26
- });
27
-
28
- it('admits createMany / updateMany / deleteMany and /batch', () => {
29
- const eff = resolveEffectiveApiMethods(SysApprovalDelegation.enable);
30
- expect(eff.mode).toBe('restricted');
31
- for (const child of ['create', 'update', 'delete'] as const) {
32
- expect(isApiOperationAllowed(eff, 'bulk', { bulkChild: child }), `batch ${child}`).toBe(true);
33
- }
34
- });
35
-
36
- it('keeps the whitelist explicit so the ADR-0103 D3 backstop still runs', () => {
37
- // `reconcileManagedApiMethods` early-returns on a non-array `apiMethods`.
38
- // For a `managedBy` object, deleting the whitelist would silently disable
39
- // managed-write stripping — it is not an equivalent refactor.
40
- expect(Array.isArray(SysApprovalDelegation.enable?.apiMethods)).toBe(true);
41
- });
42
- });
@@ -1,142 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectSchema, Field } from '@objectstack/spec/data';
4
-
5
- /**
6
- * sys_approval_delegation — self-service out-of-office (OOO) delegation (#1322 M1).
7
- *
8
- * A standing, self-declared rule: "while I (the delegator) am out between
9
- * `valid_from` and `valid_until`, route the approver slots that would resolve
10
- * to me onto my delegate instead." The approval service consults active rows
11
- * in `ApprovalService.expandApprovers` when resolving an approval node's
12
- * INDIVIDUALLY-routed approvers (`type: user` / `field` / `manager`) — the
13
- * delegate becomes a real pending approver and acts under their own identity,
14
- * so nothing is impersonated and the audit trail stays honest.
15
- *
16
- * Modelled as its own object (not a scalar on the better-auth-locked
17
- * `sys_user`), mirroring the `sys_user_position` delegation precedent
18
- * (ADR-0091): the validity window is enforced at RESOLUTION time via the
19
- * shared `isGrantActive` predicate — never by a background job (ADR-0049).
20
- * The window is half-open `[valid_from, valid_until)` in UTC.
21
- *
22
- * Scope note: this is the community-core OOO auto-skip only. Long-term proxy
23
- * "act-as" access (viewing/acting on another user's full queue under their
24
- * authority), delegation governance / segregation-of-duties, and org-wide
25
- * administration of others' delegations are enterprise concerns tracked
26
- * separately (objectstack-ai/cloud#855), not here.
27
- *
28
- * @namespace sys
29
- */
30
- export const SysApprovalDelegation = ObjectSchema.create({
31
- name: 'sys_approval_delegation',
32
- label: 'Approval Delegation',
33
- pluralLabel: 'Approval Delegations',
34
- icon: 'user-clock',
35
- isSystem: true,
36
- managedBy: 'system',
37
- // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
38
- // authors their own out-of-office delegation. Affordance only (matches the
39
- // full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
40
- // keeps the system write guard from rejecting the self-service write.
41
- userActions: { create: true, edit: true, delete: true },
42
- description:
43
- 'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).',
44
- titleFormat: '{delegator_id} → {delegate_id}',
45
- highlightFields: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until'],
46
-
47
- listViews: {
48
- active: {
49
- type: 'grid',
50
- name: 'active',
51
- label: 'Active',
52
- data: { provider: 'object', object: 'sys_approval_delegation' },
53
- columns: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason'],
54
- sort: [{ field: 'valid_until', order: 'asc' }],
55
- pagination: { pageSize: 50 },
56
- emptyState: {
57
- title: 'No delegations',
58
- message: 'Declare an out-of-office delegation so approvals route to a backup while you are away.',
59
- },
60
- },
61
- },
62
-
63
- fields: {
64
- id: Field.text({ label: 'Delegation ID', required: true, readonly: true, group: 'System' }),
65
-
66
- delegator_id: Field.lookup('sys_user', {
67
- label: 'Delegator',
68
- required: true,
69
- group: 'Delegation',
70
- description: 'The user going out of office; their individually-routed approver slots are rerouted while active.',
71
- }),
72
-
73
- delegate_id: Field.lookup('sys_user', {
74
- label: 'Delegate',
75
- required: true,
76
- group: 'Delegation',
77
- description: 'The backup who receives the delegator\'s approvals while this rule is active. Acts under their own identity.',
78
- }),
79
-
80
- valid_from: Field.datetime({
81
- label: 'Valid From',
82
- required: false,
83
- group: 'Delegation',
84
- description:
85
- 'Rule is inactive before this instant. Null = active immediately. ' +
86
- 'Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job.',
87
- }),
88
-
89
- valid_until: Field.datetime({
90
- label: 'Valid Until',
91
- required: false,
92
- group: 'Delegation',
93
- description:
94
- 'Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires.',
95
- }),
96
-
97
- reason: Field.text({
98
- label: 'Reason',
99
- required: false,
100
- maxLength: 500,
101
- group: 'Delegation',
102
- description: 'Why the delegation exists (e.g. "Annual leave 5/26–5/30"). Recorded on the substitution audit row.',
103
- }),
104
-
105
- organization_id: Field.lookup('sys_organization', {
106
- label: 'Organization',
107
- required: false,
108
- group: 'System',
109
- description: 'Tenant that owns this rule; null = applies across tenants for this delegator.',
110
- }),
111
-
112
- created_at: Field.datetime({
113
- label: 'Created At',
114
- defaultValue: 'NOW()',
115
- readonly: true,
116
- group: 'System',
117
- }),
118
-
119
- updated_at: Field.datetime({
120
- label: 'Updated At',
121
- defaultValue: 'NOW()',
122
- readonly: true,
123
- group: 'System',
124
- }),
125
- },
126
-
127
- indexes: [
128
- // Resolution-time lookup: "active delegations for this delegator".
129
- { fields: ['delegator_id', 'organization_id'] },
130
- { fields: ['delegate_id'] },
131
- { fields: ['valid_until'] },
132
- ],
133
-
134
- enable: {
135
- trackHistory: true,
136
- searchable: true,
137
- apiEnabled: true,
138
- // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
139
- // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
140
- apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
141
- },
142
- });
@@ -1,116 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * Contract test for the server-declared decision actions on
5
- * `sys_approval_request` (objectui#2678 P2-4 / retire-hardcoded-buttons).
6
- *
7
- * The console's generic action runtime renders + executes these wherever the
8
- * object is surfaced (the approvals inbox included), so the inbox no longer
9
- * hand-writes a button per capability. That only holds if the declared set
10
- * stays faithful to the REST routes it targets and to the who-can-act gating.
11
- * This pins both:
12
- *
13
- * • every `type:'api'` target resolves `{id}` and points at a route that the
14
- * REST server actually registers (approve/reject/reassign/recall/remind/
15
- * request-info/revise/resubmit) — a typo'd verb would 404 silently in the UI;
16
- * • submitter-only levers (remind/recall/resubmit) gate on
17
- * `submitter_id == ctx.user.id` so a non-submitter never sees them.
18
- */
19
-
20
- import { describe, it, expect } from 'vitest';
21
- import { SysApprovalRequest } from './sys-approval-request.object.js';
22
-
23
- const actions = (SysApprovalRequest as any).actions as any[];
24
- const byName = (n: string) => actions.find((a) => a.name === n);
25
- /** `ObjectSchema.create` normalizes `visible` strings into a
26
- * `{ dialect: 'cel', source }` envelope — read the source for substring asserts. */
27
- const vis = (n: string): string => {
28
- const v = byName(n).visible;
29
- return typeof v === 'string' ? v : String(v?.source ?? '');
30
- };
31
-
32
- /** Verbs the REST server registers under `/api/v1/approvals/requests/:id/*`. */
33
- const ROUTE_VERBS = new Set([
34
- 'approve', 'reject', 'reassign', 'recall', 'remind', 'request-info', 'revise', 'resubmit',
35
- ]);
36
-
37
- describe('sys_approval_request declared actions', () => {
38
- it('declares the full decision + continuity set', () => {
39
- expect(actions.map((a) => a.name).sort()).toEqual(
40
- [
41
- 'approval_approve',
42
- 'approval_recall',
43
- 'approval_reassign',
44
- 'approval_reject',
45
- 'approval_remind',
46
- 'approval_request_info',
47
- 'approval_resubmit',
48
- 'approval_send_back',
49
- ].sort(),
50
- );
51
- });
52
-
53
- it('every api target points at a registered approvals route verb and injects {id}', () => {
54
- for (const a of actions) {
55
- expect(a.type).toBe('api');
56
- expect(a.method).toBe('POST');
57
- const m = /^\/api\/v1\/approvals\/requests\/\{id\}\/([a-z-]+)$/.exec(a.target);
58
- expect(m, `${a.name} target ${a.target}`).not.toBeNull();
59
- expect(ROUTE_VERBS.has(m![1]), `${a.name} → ${m![1]}`).toBe(true);
60
- expect(a.refreshAfter).toBe(true);
61
- }
62
- });
63
-
64
- it('gates on the server-computed viewer block (#3310): approver actions on can_act, submitter levers on is_submitter', () => {
65
- for (const name of ['approval_remind', 'approval_recall', 'approval_resubmit']) {
66
- expect(vis(name)).toContain('record.viewer.is_submitter');
67
- expect(vis(name)).not.toContain('can_act');
68
- }
69
- for (const name of ['approval_approve', 'approval_reject', 'approval_send_back', 'approval_request_info', 'approval_reassign']) {
70
- expect(vis(name)).toContain('record.viewer.can_act');
71
- // who-can-act is server-derived, never a client identity guess
72
- expect(vis(name)).not.toContain('ctx.user.id');
73
- expect(vis(name)).not.toContain('is_submitter');
74
- }
75
- });
76
-
77
- it('the core decision levers OR in the admin override (#3424) so a stuck request is recoverable', () => {
78
- // approve / reject / reassign additionally show for a platform/tenant admin
79
- // (`record.viewer.can_override`) so an approval routed to an unstaffed
80
- // position — otherwise undecidable, locking the record forever — can be
81
- // rescued in-product. The secondary approver levers stay slot-only.
82
- for (const name of ['approval_approve', 'approval_reject', 'approval_reassign']) {
83
- expect(vis(name)).toContain('record.viewer.can_override');
84
- }
85
- for (const name of ['approval_send_back', 'approval_request_info']) {
86
- expect(vis(name)).not.toContain('can_override');
87
- }
88
- });
89
-
90
- it('recall stays available while a returned request is still the submitter\'s to abandon', () => {
91
- expect(vis('approval_recall')).toContain('record.status == "returned"');
92
- expect(byName('approval_recall').confirmText).toBeTruthy();
93
- });
94
-
95
- it('reassign collects the new approver via a field-backed sys_user picker keyed as `to`', () => {
96
- const toParam = byName('approval_reassign').params.find((p: any) => p.name === 'to');
97
- expect(toParam).toMatchObject({ field: 'submitter_id', name: 'to', required: true });
98
- });
99
-
100
- it('request-info requires a comment; other params stay optional', () => {
101
- const ri = byName('approval_request_info').params.find((p: any) => p.name === 'comment');
102
- expect(ri.required).toBe(true);
103
- for (const name of ['approval_send_back', 'approval_remind', 'approval_recall', 'approval_resubmit']) {
104
- const c = byName(name).params.find((p: any) => p.name === 'comment');
105
- expect(c.required ?? false).toBe(false);
106
- }
107
- });
108
-
109
- it('approve/reject collect optional multi-file decision attachments', () => {
110
- for (const name of ['approval_approve', 'approval_reject']) {
111
- const att = byName(name).params.find((p: any) => p.name === 'attachments');
112
- expect(att, `${name}.attachments`).toMatchObject({ type: 'file', multiple: true });
113
- expect(att.required ?? false).toBe(false);
114
- }
115
- });
116
- });