@@ -407,6 +407,113 @@ describe('ApprovalService (node era)', () => {
407407 expect ( actions . map ( a => ( a as any ) . actor_name ) ) . toEqual ( [ 'Ada Lovelace' , 'Grace Hopper' ] ) ;
408408 } ) ;
409409
410+ // ── thread interactions ─────────────────────────────────────────
411+
412+ it ( 'reassign: hands the slot to a new approver and audits the move' , async ( ) => {
413+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' , 'u2' ] ) , CTX ) ;
414+ const out = await svc . reassign ( req . id , { actorId : 'u9' , to : 'u7' } , CTX ) ;
415+ expect ( out . request . pending_approvers ) . toEqual ( [ 'u7' , 'u2' ] ) ;
416+ const actions = await svc . listActions ( req . id , SYS ) ;
417+ expect ( actions . at ( - 1 ) ) . toMatchObject ( { action : 'reassign' , actor_id : 'u9' , comment : 'u9 → u7' } ) ;
418+ } ) ;
419+
420+ it ( 'reassign: notifies the new approver via messaging' , async ( ) => {
421+ const emitted : any [ ] = [ ] ;
422+ svc . attachMessaging ( { async emit ( input ) { emitted . push ( input ) ; } } ) ;
423+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
424+ await svc . reassign ( req . id , { actorId : 'u9' , to : 'u7' } , CTX ) ;
425+ expect ( emitted ) . toHaveLength ( 1 ) ;
426+ expect ( emitted [ 0 ] ) . toMatchObject ( { topic : 'approval.reassigned' , audience : [ 'u7' ] } ) ;
427+ } ) ;
428+
429+ it ( 'reassign: blocks a non-holder and duplicate targets' , async ( ) => {
430+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' , 'u2' ] ) , CTX ) ;
431+ await expect ( svc . reassign ( req . id , { actorId : 'intruder' , to : 'u7' } , CTX ) ) . rejects . toThrow ( / F O R B I D D E N / ) ;
432+ await expect ( svc . reassign ( req . id , { actorId : 'u9' , to : 'u2' } , CTX ) ) . rejects . toThrow ( / V A L I D A T I O N _ F A I L E D / ) ;
433+ } ) ;
434+
435+ it ( 'remind: notifies pending approvers, audits, and throttles repeats' , async ( ) => {
436+ const emitted : any [ ] = [ ] ;
437+ svc . attachMessaging ( { async emit ( input ) { emitted . push ( input ) ; } } ) ;
438+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' , 'u2' ] ) , CTX ) ;
439+ const out = await svc . remind ( req . id , { actorId : 'u1' } , CTX ) ; // u1 = submitter (CTX.userId)
440+ expect ( out . notified ) . toBe ( 2 ) ;
441+ expect ( emitted [ 0 ] ) . toMatchObject ( { topic : 'approval.reminder' , audience : [ 'u9' , 'u2' ] } ) ;
442+ const actions = await svc . listActions ( req . id , SYS ) ;
443+ expect ( actions . at ( - 1 ) ?. action ) . toBe ( 'remind' ) ;
444+ // The fake clock steps 1s per call — well inside the 4h cool-down.
445+ await expect ( svc . remind ( req . id , { actorId : 'u1' } , CTX ) ) . rejects . toThrow ( / T H R O T T L E D / ) ;
446+ } ) ;
447+
448+ it ( 'remind: only the submitter may nudge' , async ( ) => {
449+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
450+ await expect ( svc . remind ( req . id , { actorId : 'u9' } , { roles : [ ] , permissions : [ ] } as any ) )
451+ . rejects . toThrow ( / F O R B I D D E N / ) ;
452+ } ) ;
453+
454+ it ( 'requestInfo: keeps the request pending and notifies the submitter' , async ( ) => {
455+ const emitted : any [ ] = [ ] ;
456+ svc . attachMessaging ( { async emit ( input ) { emitted . push ( input ) ; } } ) ;
457+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
458+ const out = await svc . requestInfo ( req . id , { actorId : 'u9' , comment : 'Need the Q3 numbers' } , CTX ) ;
459+ expect ( out . request . status ) . toBe ( 'pending' ) ;
460+ expect ( out . request . pending_approvers ) . toEqual ( [ 'u9' ] ) ;
461+ expect ( emitted [ 0 ] ) . toMatchObject ( { topic : 'approval.request_info' , audience : [ 'u1' ] } ) ;
462+ const actions = await svc . listActions ( req . id , SYS ) ;
463+ expect ( actions . at ( - 1 ) ) . toMatchObject ( { action : 'request_info' , comment : 'Need the Q3 numbers' } ) ;
464+ } ) ;
465+
466+ it ( 'comment: submitter and approver may reply; outsiders may not' , async ( ) => {
467+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
468+ await svc . comment ( req . id , { actorId : 'u1' , comment : 'Numbers attached.' } , CTX ) ;
469+ await svc . comment ( req . id , { actorId : 'u9' , comment : 'Thanks, reviewing.' } , CTX ) ;
470+ await expect ( svc . comment ( req . id , { actorId : 'outsider' , comment : 'hi' } , { roles : [ ] , permissions : [ ] } as any ) )
471+ . rejects . toThrow ( / F O R B I D D E N / ) ;
472+ const actions = await svc . listActions ( req . id , SYS ) ;
473+ expect ( actions . filter ( a => a . action === 'comment' ) ) . toHaveLength ( 2 ) ;
474+ } ) ;
475+
476+ // ── SLA + flow steps ────────────────────────────────────────────
477+
478+ it ( 'rows expose sla_due_at when the node declares escalation.timeoutHours' , async ( ) => {
479+ const req = await svc . openNodeRequest (
480+ openInput ( [ 'u9' ] , { } , { escalation : { timeoutHours : 48 , action : 'notify' , notifySubmitter : true } } ) , CTX ,
481+ ) ;
482+ expect ( req . sla_due_at ) . toBe ( new Date ( Date . parse ( req . created_at ! ) + 48 * 3600_000 ) . toISOString ( ) ) ;
483+ const noSla = await svc . openNodeRequest ( openInput ( [ 'u9' ] , { recordId : 'opp2' , record : { id : 'opp2' } } ) , CTX ) ;
484+ expect ( noSla . sla_due_at ) . toBeUndefined ( ) ;
485+ } ) ;
486+
487+ it ( 'getRequest attaches flow_steps from the owning flow graph' , async ( ) => {
488+ svc . attachAutomation ( {
489+ async getFlow ( name : string ) {
490+ if ( name !== 'deal_approval' ) return null ;
491+ return {
492+ name : 'deal_approval' ,
493+ nodes : [
494+ { id : 'start' , type : 'start' , label : 'Start' } ,
495+ { id : 'approve_step' , type : 'approval' , label : 'Manager Approval' } ,
496+ { id : 'gate' , type : 'decision' , label : 'Big?' } ,
497+ { id : 'exec_step' , type : 'approval' , label : 'Executive Approval' } ,
498+ { id : 'end' , type : 'end' , label : 'End' } ,
499+ ] ,
500+ edges : [
501+ { id : 'e1' , source : 'start' , target : 'approve_step' } ,
502+ { id : 'e2' , source : 'approve_step' , target : 'gate' , label : 'approve' } ,
503+ { id : 'e3' , source : 'gate' , target : 'exec_step' , label : 'true' } ,
504+ { id : 'e4' , source : 'exec_step' , target : 'end' , label : 'approve' } ,
505+ ] ,
506+ } ;
507+ } ,
508+ } ) ;
509+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
510+ const fresh = await svc . getRequest ( req . id , SYS ) ;
511+ expect ( fresh ?. flow_steps ) . toEqual ( [
512+ { id : 'approve_step' , label : 'Manager Approval' , state : 'current' } ,
513+ { id : 'exec_step' , label : 'Executive Approval' , state : 'upcoming' } ,
514+ ] ) ;
515+ } ) ;
516+
410517 it ( 'enrichment resolves an email submitter via sys_user.email' , async ( ) => {
411518 engine . _tables [ 'sys_user' ] = [ { id : 'u7' , name : 'Grace Hopper' , email : 'grace@example.com' } ] ;
412519 await svc . openNodeRequest ( openInput ( [ 'u9' ] , { submitterId : 'grace@example.com' } ) , CTX ) ;
0 commit comments