@@ -290,6 +290,98 @@ describe('ApprovalService (node era)', () => {
290290 it ( 'getRequest: returns null for an unknown id' , async ( ) => {
291291 expect ( await svc . getRequest ( 'nope' , SYS ) ) . toBeNull ( ) ;
292292 } ) ;
293+
294+ // ── recall ──────────────────────────────────────────────────────
295+
296+ it ( 'recall: submitter withdraws a pending request' , async ( ) => {
297+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
298+ const out = await svc . recall ( req . id , { actorId : 'u1' , comment : 'changed my mind' } , CTX ) ;
299+ expect ( out . request . status ) . toBe ( 'recalled' ) ;
300+ expect ( out . request . completed_at ) . toBeTruthy ( ) ;
301+ expect ( out . request . pending_approvers ) . toEqual ( [ ] ) ;
302+ const actions = await svc . listActions ( req . id , SYS ) ;
303+ expect ( actions . map ( a => a . action ) ) . toEqual ( [ 'submit' , 'recall' ] ) ;
304+ expect ( actions [ 1 ] . comment ) . toBe ( 'changed my mind' ) ;
305+ } ) ;
306+
307+ it ( 'recall: blocks a non-submitter in a non-system context' , async ( ) => {
308+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
309+ await expect ( svc . recall ( req . id , { actorId : 'u9' } , { roles : [ ] , permissions : [ ] } as any ) )
310+ . rejects . toThrow ( / F O R B I D D E N / ) ;
311+ } ) ;
312+
313+ it ( 'recall: rejects a recall on a non-pending request' , async ( ) => {
314+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
315+ await svc . decideNode ( req . id , { decision : 'approve' , actorId : 'u9' } , SYS ) ;
316+ await expect ( svc . recall ( req . id , { actorId : 'u1' } , SYS ) ) . rejects . toThrow ( / I N V A L I D _ S T A T E / ) ;
317+ } ) ;
318+
319+ it ( 'recall: resumes the owning run down the reject branch with decision=recall' , async ( ) => {
320+ const resumed : any [ ] = [ ] ;
321+ svc . attachAutomation ( { async resume ( runId , signal ) { resumed . push ( { runId, signal } ) ; } } ) ;
322+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
323+ const out = await svc . recall ( req . id , { actorId : 'u1' } , CTX ) ;
324+ expect ( out . resumed ) . toBe ( true ) ;
325+ expect ( resumed [ 0 ] ) . toMatchObject ( {
326+ runId : 'run_1' ,
327+ signal : { branchLabel : 'reject' , output : { decision : 'recall' } } ,
328+ } ) ;
329+ } ) ;
330+
331+ it ( 'recall: mirrors `recalled` onto the business record when configured' , async ( ) => {
332+ engine . _tables [ 'opportunity' ] = [ { id : 'opp1' , amount : 100 } ] ;
333+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] , { } , { approvalStatusField : 'approval_status' } ) , CTX ) ;
334+ await svc . recall ( req . id , { actorId : 'u1' } , CTX ) ;
335+ expect ( engine . _tables [ 'opportunity' ] [ 0 ] . approval_status ) . toBe ( 'recalled' ) ;
336+ } ) ;
337+
338+ // ── inbox display fields ────────────────────────────────────────
339+
340+ it ( 'rows expose submitted_at as an alias of created_at' , async ( ) => {
341+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
342+ expect ( req . submitted_at ) . toBeTruthy ( ) ;
343+ expect ( req . submitted_at ) . toBe ( req . created_at ) ;
344+ const listed = await svc . listRequests ( { status : 'pending' } , SYS ) ;
345+ expect ( listed [ 0 ] . submitted_at ) . toBe ( listed [ 0 ] . created_at ) ;
346+ } ) ;
347+
348+ it ( 'rows carry authored flow/node labels when provided' , async ( ) => {
349+ const req = await svc . openNodeRequest (
350+ openInput ( [ 'u9' ] , { flowLabel : 'Deal Approval' , nodeLabel : 'Manager Review' } ) , CTX ,
351+ ) ;
352+ expect ( req . process_label ) . toBe ( 'Deal Approval' ) ;
353+ expect ( req . step_label ) . toBe ( 'Manager Review' ) ;
354+ } ) ;
355+
356+ it ( 'rows fall back to prettified machine names when labels are absent' , async ( ) => {
357+ const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
358+ expect ( req . process_label ) . toBe ( 'Deal Approval' ) ; // from `flow:deal_approval`
359+ expect ( req . step_label ) . toBe ( 'Approve Step' ) ; // from `approve_step`
360+ } ) ;
361+
362+ it ( 'listRequests enriches record_title and submitter_name' , async ( ) => {
363+ engine . _tables [ 'opportunity' ] = [ { id : 'opp1' , name : 'Acme Renewal' , amount : 100 } ] ;
364+ engine . _tables [ 'sys_user' ] = [ { id : 'u1' , name : 'Ada Lovelace' , email : 'ada@example.com' } ] ;
365+ await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ; // submitter_id = u1 (CTX.userId)
366+ const rows = await svc . listRequests ( { status : 'pending' } , SYS ) ;
367+ expect ( rows [ 0 ] . record_title ) . toBe ( 'Acme Renewal' ) ;
368+ expect ( rows [ 0 ] . submitter_name ) . toBe ( 'Ada Lovelace' ) ;
369+ } ) ;
370+
371+ it ( 'enrichment falls back to the payload snapshot when the record is gone' , async ( ) => {
372+ await svc . openNodeRequest (
373+ openInput ( [ 'u9' ] , { record : { id : 'opp1' , name : 'Snapshot Title' , amount : 1 } } ) , CTX ,
374+ ) ;
375+ const rows = await svc . listRequests ( { status : 'pending' } , SYS ) ;
376+ expect ( rows [ 0 ] . record_title ) . toBe ( 'Snapshot Title' ) ;
377+ } ) ;
378+
379+ it ( 'enrichment resolves an email submitter via sys_user.email' , async ( ) => {
380+ engine . _tables [ 'sys_user' ] = [ { id : 'u7' , name : 'Grace Hopper' , email : 'grace@example.com' } ] ;
381+ await svc . openNodeRequest ( openInput ( [ 'u9' ] , { submitterId : 'grace@example.com' } ) , CTX ) ;
382+ const rows = await svc . listRequests ( { status : 'pending' } , SYS ) ;
383+ expect ( rows [ 0 ] . submitter_name ) . toBe ( 'Grace Hopper' ) ;
384+ } ) ;
293385} ) ;
294386
295387describe ( 'record-lock hook (node era)' , ( ) => {
0 commit comments