@objectstack/plugin-approvals 9.2.0 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { describe, it, expect, beforeEach } from 'vitest';
13
- import { ApprovalService } from './approval-service.js';
13
+ import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js';
14
14
  import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
15
15
 
16
16
  interface FakeRow { [k: string]: any }
@@ -23,6 +23,10 @@ function makeFakeEngine() {
23
23
  function matches(row: FakeRow, filter: any): boolean {
24
24
  if (!filter || typeof filter !== 'object') return true;
25
25
  for (const [k, v] of Object.entries(filter)) {
26
+ if (k === '$or') {
27
+ if (!(v as any[]).some(sub => matches(row, sub))) return false;
28
+ continue;
29
+ }
26
30
  const rv = row[k];
27
31
  if (v != null && typeof v === 'object' && '$in' in (v as any)) {
28
32
  if (!(v as any).$in.includes(rv)) return false;
@@ -32,6 +36,10 @@ function makeFakeEngine() {
32
36
  if (rv === (v as any).$ne) return false;
33
37
  continue;
34
38
  }
39
+ if (v != null && typeof v === 'object' && '$contains' in (v as any)) {
40
+ if (!String(rv ?? '').includes(String((v as any).$contains))) return false;
41
+ continue;
42
+ }
35
43
  if (rv !== v) return false;
36
44
  }
37
45
  return true;
@@ -43,15 +51,20 @@ function makeFakeEngine() {
43
51
  async find(object: string, options?: any) {
44
52
  const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
45
53
  if (options?.orderBy?.[0]) {
46
- const { field, direction } = options.orderBy[0];
54
+ // Canonical SortNode key only (spec/data/query.zod.ts): a sloppy
55
+ // `direction:` key must fall through to the schema default (asc),
56
+ // exactly like the real engine — that's how the remind() cool-down
57
+ // regression stayed invisible when this mock honored both keys.
58
+ const { field, order } = options.orderBy[0];
47
59
  rows.sort((a, b) => {
48
60
  const av = a[field]; const bv = b[field];
49
61
  if (av === bv) return 0;
50
62
  const cmp = av > bv ? 1 : -1;
51
- return direction === 'desc' ? -cmp : cmp;
63
+ return order === 'desc' ? -cmp : cmp;
52
64
  });
53
65
  }
54
- return rows.slice(0, options?.limit ?? 1000);
66
+ const start = options?.offset ?? 0;
67
+ return rows.slice(start, start + (options?.limit ?? 1000));
55
68
  },
56
69
  async insert(object: string, data: any) {
57
70
  ensure(object).push({ ...data });
@@ -407,6 +420,441 @@ describe('ApprovalService (node era)', () => {
407
420
  expect(actions.map(a => (a as any).actor_name)).toEqual(['Ada Lovelace', 'Grace Hopper']);
408
421
  });
409
422
 
423
+ // ── thread interactions ─────────────────────────────────────────
424
+
425
+ it('reassign: hands the slot to a new approver and audits the move', async () => {
426
+ const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
427
+ const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
428
+ expect(out.request.pending_approvers).toEqual(['u7', 'u2']);
429
+ const actions = await svc.listActions(req.id, SYS);
430
+ expect(actions.at(-1)).toMatchObject({ action: 'reassign', actor_id: 'u9', comment: 'u9 → u7' });
431
+ });
432
+
433
+ it('reassign: notifies the new approver via messaging', async () => {
434
+ const emitted: any[] = [];
435
+ svc.attachMessaging({ async emit(input) { emitted.push(input); } });
436
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
437
+ await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
438
+ expect(emitted).toHaveLength(1);
439
+ expect(emitted[0]).toMatchObject({ topic: 'approval.reassigned', audience: ['u7'] });
440
+ });
441
+
442
+ it('reassign: blocks a non-holder and duplicate targets', async () => {
443
+ const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
444
+ await expect(svc.reassign(req.id, { actorId: 'intruder', to: 'u7' }, CTX)).rejects.toThrow(/FORBIDDEN/);
445
+ await expect(svc.reassign(req.id, { actorId: 'u9', to: 'u2' }, CTX)).rejects.toThrow(/VALIDATION_FAILED/);
446
+ });
447
+
448
+ it('remind: notifies pending approvers, audits, and throttles repeats', async () => {
449
+ const emitted: any[] = [];
450
+ svc.attachMessaging({ async emit(input) { emitted.push(input); } });
451
+ const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
452
+ const out = await svc.remind(req.id, { actorId: 'u1' }, CTX); // u1 = submitter (CTX.userId)
453
+ expect(out.notified).toBe(2);
454
+ // ADR-0043: per-approver fan-out so each reminder carries personal links.
455
+ const reminders = emitted.filter(e => e.topic === 'approval.reminder');
456
+ expect(reminders.map(r => r.audience)).toEqual([['u9'], ['u2']]);
457
+ const actions = await svc.listActions(req.id, SYS);
458
+ expect(actions.at(-1)?.action).toBe('remind');
459
+ // The fake clock steps 1s per call — well inside the 4h cool-down.
460
+ await expect(svc.remind(req.id, { actorId: 'u1' }, CTX)).rejects.toThrow(/THROTTLED/);
461
+ });
462
+
463
+ it('remind: cool-down measures from the NEWEST reminder, not the first', async () => {
464
+ // Regression: the throttle query sorted with the non-canonical
465
+ // `direction: 'desc'` key, which SortNode strips — so it sorted asc and
466
+ // compared against the FIRST reminder ever sent. Once 4h passed after
467
+ // reminder #1, every later remind() sailed through unthrottled.
468
+ let nowMs = baseTime;
469
+ const localSvc = new ApprovalService({
470
+ engine: engine as any,
471
+ clock: { now: () => new Date(nowMs += 1000) },
472
+ });
473
+ const req = await localSvc.openNodeRequest(openInput(['u9']), CTX);
474
+ await localSvc.remind(req.id, { actorId: 'u1' }, CTX);
475
+ // Jump past the cool-down: a second reminder is legitimately allowed.
476
+ nowMs += REMIND_COOLDOWN_MS;
477
+ await localSvc.remind(req.id, { actorId: 'u1' }, CTX);
478
+ // Immediately after reminder #2 the throttle must bite again — with the
479
+ // wrong sort key it compared against reminder #1 (now >4h old) and let
480
+ // unlimited reminders through.
481
+ await expect(localSvc.remind(req.id, { actorId: 'u1' }, CTX)).rejects.toThrow(/THROTTLED/);
482
+ });
483
+
484
+ it('remind: only the submitter may nudge', async () => {
485
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
486
+ await expect(svc.remind(req.id, { actorId: 'u9' }, { roles: [], permissions: [] } as any))
487
+ .rejects.toThrow(/FORBIDDEN/);
488
+ });
489
+
490
+ it('requestInfo: keeps the request pending and notifies the submitter', async () => {
491
+ const emitted: any[] = [];
492
+ svc.attachMessaging({ async emit(input) { emitted.push(input); } });
493
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
494
+ const out = await svc.requestInfo(req.id, { actorId: 'u9', comment: 'Need the Q3 numbers' }, CTX);
495
+ expect(out.request.status).toBe('pending');
496
+ expect(out.request.pending_approvers).toEqual(['u9']);
497
+ expect(emitted[0]).toMatchObject({ topic: 'approval.request_info', audience: ['u1'] });
498
+ const actions = await svc.listActions(req.id, SYS);
499
+ expect(actions.at(-1)).toMatchObject({ action: 'request_info', comment: 'Need the Q3 numbers' });
500
+ });
501
+
502
+ it('comment: submitter and approver may reply; outsiders may not', async () => {
503
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
504
+ await svc.comment(req.id, { actorId: 'u1', comment: 'Numbers attached.' }, CTX);
505
+ await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' }, CTX);
506
+ await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, { roles: [], permissions: [] } as any))
507
+ .rejects.toThrow(/FORBIDDEN/);
508
+ const actions = await svc.listActions(req.id, SYS);
509
+ expect(actions.filter(a => a.action === 'comment')).toHaveLength(2);
510
+ });
511
+
512
+ // ── actionable links (ADR-0043) ─────────────────────────────────
513
+
514
+ it('issueActionTokens: stores hashes only and binds approver + action', async () => {
515
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
516
+ const tokens = await svc.issueActionTokens(req.id, 'u9');
517
+ expect(tokens.approve).not.toBe(tokens.reject);
518
+ const rows = engine._tables['sys_approval_token'];
519
+ expect(rows).toHaveLength(2);
520
+ expect(rows.every(r => r.token_hash.length === 64)).toBe(true); // sha256 hex, never the raw token
521
+ expect(rows.every(r => !JSON.stringify(r).includes(tokens.approve))).toBe(true);
522
+ await expect(svc.issueActionTokens(req.id, 'stranger')).rejects.toThrow(/FORBIDDEN/);
523
+ });
524
+
525
+ it('redeem: approves as the bound approver and burns the token (single-use)', async () => {
526
+ const resumed: any[] = [];
527
+ svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
528
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
529
+ const { approve } = await svc.issueActionTokens(req.id, 'u9');
530
+ const out = await svc.redeemActionToken(approve);
531
+ expect(out).toMatchObject({ ok: true, action: 'approve', approverId: 'u9' });
532
+ expect((out as any).request.status).toBe('approved');
533
+ expect(resumed[0]?.signal?.branchLabel).toBe('approve');
534
+ const acts = await svc.listActions(req.id, SYS);
535
+ expect(acts.at(-1)).toMatchObject({ action: 'approve', actor_id: 'u9', comment: 'Via action link' });
536
+ // replay
537
+ expect(await svc.redeemActionToken(approve)).toMatchObject({ ok: false, reason: 'consumed' });
538
+ });
539
+
540
+ it('peek: validates without consuming (GET never mutates)', async () => {
541
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
542
+ const { reject } = await svc.issueActionTokens(req.id, 'u9');
543
+ expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true, action: 'reject' });
544
+ expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true }); // still live
545
+ const fresh = await svc.getRequest(req.id, SYS);
546
+ expect(fresh?.status).toBe('pending');
547
+ });
548
+
549
+ it('redeem: dead tokens — invalid, expired, decided request, reassigned slot', async () => {
550
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
551
+ expect(await svc.redeemActionToken('garbage')).toMatchObject({ ok: false, reason: 'invalid' });
552
+
553
+ const short = await svc.issueActionTokens(req.id, 'u9', { ttlMs: 1 });
554
+ // fake clock advances 1s per call — far beyond a 1ms TTL
555
+ expect(await svc.redeemActionToken(short.approve)).toMatchObject({ ok: false, reason: 'expired' });
556
+
557
+ const live = await svc.issueActionTokens(req.id, 'u9');
558
+ await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
559
+ expect(await svc.redeemActionToken(live.approve)).toMatchObject({ ok: false, reason: 'not_approver' });
560
+
561
+ const forU7 = await svc.issueActionTokens(req.id, 'u7');
562
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u7' }, SYS);
563
+ expect(await svc.redeemActionToken(forU7.reject)).toMatchObject({ ok: false, reason: 'not_pending' });
564
+ });
565
+
566
+ it('remind: each concrete approver gets their own action links', async () => {
567
+ const emitted: any[] = [];
568
+ svc.attachMessaging({ async emit(input) { emitted.push(input); } });
569
+ const req = await svc.openNodeRequest(openInput(['u9', 'ada@example.com']), CTX);
570
+ await svc.remind(req.id, { actorId: 'u1' }, CTX);
571
+ const reminders = emitted.filter(e => e.topic === 'approval.reminder');
572
+ expect(reminders).toHaveLength(2);
573
+ for (const r of reminders) {
574
+ expect(r.audience).toHaveLength(1);
575
+ expect(r.payload.actions).toHaveLength(2);
576
+ expect(r.payload.actions[0].url).toContain('/api/v1/approvals/act?token=');
577
+ }
578
+ const urls = reminders.flatMap(r => r.payload.actions.map((a: any) => a.url));
579
+ expect(new Set(urls).size).toBe(4); // every link is personal + per-action
580
+ });
581
+
582
+ // ── pagination + search pushdown (#1745) ────────────────────────
583
+
584
+ async function openMany(n: number) {
585
+ for (let i = 0; i < n; i++) {
586
+ await svc.openNodeRequest(openInput(['u9'], {
587
+ recordId: `opp${i}`, record: { id: `opp${i}`, name: `Deal ${i}` },
588
+ }), CTX);
589
+ }
590
+ }
591
+
592
+ it('listRequests: windows pushable queries newest-first with limit/offset', async () => {
593
+ await openMany(5);
594
+ const page1 = await svc.listRequests({ limit: 2, offset: 0 }, SYS);
595
+ const page2 = await svc.listRequests({ limit: 2, offset: 2 }, SYS);
596
+ expect(page1.map(r => r.record_id)).toEqual(['opp4', 'opp3']); // created_at desc
597
+ expect(page2.map(r => r.record_id)).toEqual(['opp2', 'opp1']);
598
+ });
599
+
600
+ it('listRequests: q matches the payload snapshot (record titles) via pushdown', async () => {
601
+ await openMany(3);
602
+ const hit = await svc.listRequests({ q: 'Deal 1', limit: 10 }, SYS);
603
+ expect(hit.map(r => r.record_id)).toEqual(['opp1']);
604
+ const miss = await svc.listRequests({ q: 'no-such-thing', limit: 10 }, SYS);
605
+ expect(miss).toHaveLength(0);
606
+ });
607
+
608
+ it('countRequests: returns the unwindowed total for a filter', async () => {
609
+ await openMany(4);
610
+ expect(await svc.countRequests({ status: 'pending' }, SYS)).toBe(4);
611
+ expect(await svc.countRequests({ q: 'Deal 2' }, SYS)).toBe(1);
612
+ });
613
+
614
+ it('listRequests: approver queries resolve via the index and window engine-side', async () => {
615
+ await openMany(4); // approver u9 on all
616
+ await svc.openNodeRequest(openInput(['someone-else'], {
617
+ recordId: 'oppX', record: { id: 'oppX', name: 'Other' },
618
+ }), CTX);
619
+ const page = await svc.listRequests({ approverId: 'u9', limit: 2, offset: 2 }, SYS);
620
+ expect(page).toHaveLength(2);
621
+ expect(page.every(r => r.pending_approvers?.includes('u9'))).toBe(true);
622
+ expect(await svc.countRequests({ approverId: 'u9' }, SYS)).toBe(4);
623
+ });
624
+
625
+ it('listRequests/countRequests: status arrays push down as $in', async () => {
626
+ await openMany(3);
627
+ const all = await svc.listRequests({ status: 'pending' }, SYS);
628
+ await svc.decideNode(all[0].id, { decision: 'approve', actorId: 'u9' }, SYS);
629
+ await svc.decideNode(all[1].id, { decision: 'reject', actorId: 'u9' }, SYS);
630
+ const done = await svc.listRequests({ status: ['approved', 'rejected'] }, SYS);
631
+ expect(done.map(r => r.status).sort()).toEqual(['approved', 'rejected']);
632
+ expect(await svc.countRequests({ status: ['approved', 'rejected'] }, SYS)).toBe(2);
633
+ expect(await svc.countRequests({ status: ['recalled'] }, SYS)).toBe(0);
634
+ });
635
+
636
+ // ── pending-approver index (#1745 join table) ───────────────────
637
+
638
+ const indexRows = () => (engine._tables['sys_approval_approver'] ?? [])
639
+ .map(r => ({ request_id: r.request_id, approver: r.approver }));
640
+
641
+ it('openNodeRequest mirrors every approver identity into the index', async () => {
642
+ const req = await svc.openNodeRequest(openInput(['u9', 'ada@example.com', 'role:finance']), CTX);
643
+ expect(indexRows()).toEqual([
644
+ { request_id: req.id, approver: 'u9' },
645
+ { request_id: req.id, approver: 'ada@example.com' },
646
+ { request_id: req.id, approver: 'role:finance' },
647
+ ]);
648
+ });
649
+
650
+ it('decide and recall clear the request\'s index rows', async () => {
651
+ const a = await svc.openNodeRequest(openInput(['u9']), CTX);
652
+ await svc.decideNode(a.id, { decision: 'approve', actorId: 'u9' }, SYS);
653
+ expect(indexRows()).toHaveLength(0);
654
+
655
+ const b = await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
656
+ await svc.recall(b.id, { actorId: 'u1' }, CTX);
657
+ expect(indexRows()).toHaveLength(0);
658
+ });
659
+
660
+ it('unanimous partial approval shrinks the index to the still-pending set', async () => {
661
+ const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX);
662
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
663
+ expect(indexRows()).toEqual([{ request_id: req.id, approver: 'u2' }]);
664
+ });
665
+
666
+ it('reassign and SLA-reassign rewrite the index rows', async () => {
667
+ const req = await svc.openNodeRequest(
668
+ openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
669
+ );
670
+ await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, SYS);
671
+ expect(indexRows().map(r => r.approver).sort()).toEqual(['u2', 'u7']);
672
+
673
+ makeOverdue(req.id);
674
+ await svc.runEscalations();
675
+ expect(indexRows()).toEqual([{ request_id: req.id, approver: 'boss' }]);
676
+ });
677
+
678
+ it('approver-filtered pages stay correct past the old 500-row scan window', async () => {
679
+ // 30 u9 requests are the OLDEST rows, buried under 510 newer non-matching
680
+ // ones — the pre-#1745 bounded scan (limit 500, newest-first) could never
681
+ // reach them. Seeded directly: 540 openNodeRequest round-trips are noise.
682
+ const reqs = (engine._tables['sys_approval_request'] ??= []);
683
+ const idx = (engine._tables['sys_approval_approver'] ??= []);
684
+ const ts = (i: number) => new Date(baseTime + i * 1000).toISOString();
685
+ for (let i = 0; i < 30; i++) {
686
+ reqs.push({
687
+ id: `match_${i}`, process_name: 'flow:f', object_name: 'o', record_id: `m${i}`,
688
+ status: 'pending', pending_approvers: 'u9', created_at: ts(i), updated_at: ts(i),
689
+ });
690
+ idx.push({ id: `aapr_m${i}`, request_id: `match_${i}`, approver: 'u9', organization_id: null, created_at: ts(i) });
691
+ }
692
+ for (let i = 0; i < 510; i++) {
693
+ reqs.push({
694
+ id: `noise_${i}`, process_name: 'flow:f', object_name: 'o', record_id: `n${i}`,
695
+ status: 'pending', pending_approvers: 'someone-else', created_at: ts(100 + i), updated_at: ts(100 + i),
696
+ });
697
+ idx.push({ id: `aapr_n${i}`, request_id: `noise_${i}`, approver: 'someone-else', organization_id: null, created_at: ts(100 + i) });
698
+ }
699
+
700
+ expect(await svc.countRequests({ approverId: 'u9' }, SYS)).toBe(30);
701
+ const page = await svc.listRequests({ approverId: 'u9', limit: 10, offset: 20 }, SYS);
702
+ // Newest-first within the matches: offset 20 of 30 → match_9 … match_0.
703
+ expect(page.map(r => r.id)).toEqual(Array.from({ length: 10 }, (_, k) => `match_${9 - k}`));
704
+ expect(page.every(r => r.pending_approvers?.includes('u9'))).toBe(true);
705
+ });
706
+
707
+ it('rebuildApproverIndex backfills legacy rows, drops orphans + stale entries, and is idempotent', async () => {
708
+ const reqs = (engine._tables['sys_approval_request'] ??= []);
709
+ const idx = (engine._tables['sys_approval_approver'] ??= []);
710
+ const ts = new Date(baseTime).toISOString();
711
+ // Legacy pending row written before the index existed.
712
+ reqs.push({
713
+ id: 'legacy_1', process_name: 'flow:f', object_name: 'o', record_id: 'r1',
714
+ status: 'pending', pending_approvers: 'u1,u2', created_at: ts, updated_at: ts,
715
+ });
716
+ // Completed row whose index rows were never cleaned (orphan).
717
+ reqs.push({
718
+ id: 'done_1', process_name: 'flow:f', object_name: 'o', record_id: 'r2',
719
+ status: 'approved', pending_approvers: null, created_at: ts, updated_at: ts,
720
+ });
721
+ idx.push({ id: 'aapr_orphan', request_id: 'done_1', approver: 'u3', organization_id: null, created_at: ts });
722
+ // Pending row whose index drifted (holds an approver no longer in the CSV).
723
+ reqs.push({
724
+ id: 'drift_1', process_name: 'flow:f', object_name: 'o', record_id: 'r3',
725
+ status: 'pending', pending_approvers: 'u5', created_at: ts, updated_at: ts,
726
+ });
727
+ idx.push({ id: 'aapr_stale', request_id: 'drift_1', approver: 'u4', organization_id: null, created_at: ts });
728
+
729
+ const out = await svc.rebuildApproverIndex();
730
+ expect(out).toEqual({ requests: 2, inserted: 3, deleted: 2 }); // +u1 +u2 +u5 / -orphan -stale
731
+ expect(indexRows().sort((a, b) => a.approver.localeCompare(b.approver))).toEqual([
732
+ { request_id: 'legacy_1', approver: 'u1' },
733
+ { request_id: 'legacy_1', approver: 'u2' },
734
+ { request_id: 'drift_1', approver: 'u5' },
735
+ ]);
736
+
737
+ const again = await svc.rebuildApproverIndex();
738
+ expect(again).toEqual({ requests: 2, inserted: 0, deleted: 0 });
739
+ });
740
+
741
+ // ── SLA escalation (ADR-0042) ───────────────────────────────────
742
+
743
+ function makeOverdue(reqId: string) {
744
+ // Push created_at into the past so a small timeoutHours is breached.
745
+ const row = engine._tables['sys_approval_request'].find(r => r.id === reqId)!;
746
+ row.created_at = new Date(baseTime - 10 * 3600_000).toISOString();
747
+ }
748
+
749
+ it('runEscalations: notify action messages approvers + escalateTo + submitter, once', async () => {
750
+ const emitted: any[] = [];
751
+ svc.attachMessaging({ async emit(input) { emitted.push(input); } });
752
+ const req = await svc.openNodeRequest(
753
+ openInput(['u9'], {}, { escalation: { timeoutHours: 2, action: 'notify', escalateTo: 'boss', notifySubmitter: true } }), CTX,
754
+ );
755
+ makeOverdue(req.id);
756
+ const first = await svc.runEscalations();
757
+ expect(first.escalated).toBe(1);
758
+ expect(emitted.map(e => e.topic)).toEqual(['approval.sla_breached', 'approval.sla_breached']);
759
+ expect(emitted[0].audience).toEqual(['u9', 'boss']);
760
+ expect(emitted[1].audience).toEqual(['u1']); // submitter
761
+ const actions = await svc.listActions(req.id, SYS);
762
+ expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla', comment: 'notify → boss' });
763
+ // Single-shot: second sweep is a no-op.
764
+ const second = await svc.runEscalations();
765
+ expect(second.escalated).toBe(0);
766
+ expect(emitted).toHaveLength(2);
767
+ });
768
+
769
+ it('runEscalations: auto_approve decides as system:sla and resumes the flow', async () => {
770
+ const resumed: any[] = [];
771
+ svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
772
+ const req = await svc.openNodeRequest(
773
+ openInput(['u9'], {}, { escalation: { timeoutHours: 1, action: 'auto_approve', notifySubmitter: false } }), CTX,
774
+ );
775
+ makeOverdue(req.id);
776
+ const out = await svc.runEscalations();
777
+ expect(out.escalated).toBe(1);
778
+ const fresh = await svc.getRequest(req.id, SYS);
779
+ expect(fresh?.status).toBe('approved');
780
+ expect(resumed[0]).toMatchObject({ runId: 'run_1', signal: { branchLabel: 'approve' } });
781
+ const actions = await svc.listActions(req.id, SYS);
782
+ expect(actions.map(a => a.action)).toEqual(['submit', 'escalate', 'approve']);
783
+ expect(actions.at(-1)?.actor_id).toBe('system:sla');
784
+ });
785
+
786
+ it('runEscalations: auto_reject decides as system:sla', async () => {
787
+ const req = await svc.openNodeRequest(
788
+ openInput(['u9'], {}, { escalation: { timeoutHours: 1, action: 'auto_reject', notifySubmitter: false } }), CTX,
789
+ );
790
+ makeOverdue(req.id);
791
+ await svc.runEscalations();
792
+ const fresh = await svc.getRequest(req.id, SYS);
793
+ expect(fresh?.status).toBe('rejected');
794
+ });
795
+
796
+ it('runEscalations: reassign replaces the approver set with escalateTo', async () => {
797
+ const req = await svc.openNodeRequest(
798
+ openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
799
+ );
800
+ makeOverdue(req.id);
801
+ await svc.runEscalations();
802
+ const fresh = await svc.getRequest(req.id, SYS);
803
+ expect(fresh?.status).toBe('pending');
804
+ expect(fresh?.pending_approvers).toEqual(['boss']);
805
+ });
806
+
807
+ it('runEscalations: skips requests that are not yet due or have no SLA', async () => {
808
+ await svc.openNodeRequest(
809
+ openInput(['u9'], {}, { escalation: { timeoutHours: 1000, action: 'auto_approve' } }), CTX,
810
+ );
811
+ await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
812
+ const out = await svc.runEscalations();
813
+ expect(out.scanned).toBe(2);
814
+ expect(out.escalated).toBe(0);
815
+ });
816
+
817
+ // ── SLA + flow steps ────────────────────────────────────────────
818
+
819
+ it('rows expose sla_due_at when the node declares escalation.timeoutHours', async () => {
820
+ const req = await svc.openNodeRequest(
821
+ openInput(['u9'], {}, { escalation: { timeoutHours: 48, action: 'notify', notifySubmitter: true } }), CTX,
822
+ );
823
+ expect(req.sla_due_at).toBe(new Date(Date.parse(req.created_at!) + 48 * 3600_000).toISOString());
824
+ const noSla = await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
825
+ expect(noSla.sla_due_at).toBeUndefined();
826
+ });
827
+
828
+ it('getRequest attaches flow_steps from the owning flow graph', async () => {
829
+ svc.attachAutomation({
830
+ async getFlow(name: string) {
831
+ if (name !== 'deal_approval') return null;
832
+ return {
833
+ name: 'deal_approval',
834
+ nodes: [
835
+ { id: 'start', type: 'start', label: 'Start' },
836
+ { id: 'approve_step', type: 'approval', label: 'Manager Approval' },
837
+ { id: 'gate', type: 'decision', label: 'Big?' },
838
+ { id: 'exec_step', type: 'approval', label: 'Executive Approval' },
839
+ { id: 'end', type: 'end', label: 'End' },
840
+ ],
841
+ edges: [
842
+ { id: 'e1', source: 'start', target: 'approve_step' },
843
+ { id: 'e2', source: 'approve_step', target: 'gate', label: 'approve' },
844
+ { id: 'e3', source: 'gate', target: 'exec_step', label: 'true' },
845
+ { id: 'e4', source: 'exec_step', target: 'end', label: 'approve' },
846
+ ],
847
+ };
848
+ },
849
+ });
850
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
851
+ const fresh = await svc.getRequest(req.id, SYS);
852
+ expect(fresh?.flow_steps).toEqual([
853
+ { id: 'approve_step', label: 'Manager Approval', state: 'current' },
854
+ { id: 'exec_step', label: 'Executive Approval', state: 'upcoming' },
855
+ ]);
856
+ });
857
+
410
858
  it('enrichment resolves an email submitter via sys_user.email', async () => {
411
859
  engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }];
412
860
  await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX);