@@ -1076,3 +1076,150 @@ describe('record-lock hook (node era)', () => {
10761076 expect ( engine . _hooks [ 'beforeUpdate' ] ) . toHaveLength ( 0 ) ;
10771077 } ) ;
10781078} ) ;
1079+
1080+ // ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
1081+ //
1082+ // When a resolved individual approver has declared an active OOO delegation,
1083+ // the slot is rerouted to the delegate at resolution time (never a background
1084+ // job), audited as `ooo_substitute`, and both parties are notified. Group /
1085+ // graph approvers (position/team/department/tier) are left untouched.
1086+ describe ( 'ApprovalService — out-of-office delegation (#1322)' , ( ) => {
1087+ // Mid-window instant for the issue's own example (leave 5/26–5/30).
1088+ const OOO_NOW = new Date ( '2026-05-27T10:00:00Z' ) . getTime ( ) ;
1089+ let engine : ReturnType < typeof makeFakeEngine > ;
1090+ let svc : ApprovalService ;
1091+ let emitted : any [ ] ;
1092+
1093+ function seedDelegation ( rows : Array < Record < string , any > > ) {
1094+ engine . _tables [ 'sys_approval_delegation' ] = rows . map ( ( r , i ) => ( {
1095+ id : `del${ i } ` ,
1096+ organization_id : 't1' ,
1097+ valid_from : '2026-05-26T00:00:00Z' ,
1098+ valid_until : '2026-05-30T00:00:00Z' ,
1099+ reason : 'Annual leave' ,
1100+ ...r ,
1101+ } ) ) ;
1102+ }
1103+
1104+ beforeEach ( ( ) => {
1105+ engine = makeFakeEngine ( ) ;
1106+ emitted = [ ] ;
1107+ svc = new ApprovalService ( { engine : engine as any , clock : { now : ( ) => new Date ( OOO_NOW ) } } ) ;
1108+ svc . attachMessaging ( { emit : async ( m : any ) => { emitted . push ( m ) ; } } ) ;
1109+ } ) ;
1110+
1111+ it ( 'type:user — reroutes an out-of-office approver to the delegate' , async ( ) => {
1112+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' } ] ) ;
1113+ const req = await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1114+ expect ( req . pending_approvers ) . toEqual ( [ 'bob' ] ) ;
1115+ } ) ;
1116+
1117+ it ( 'records an ooo_substitute audit action with "A → B — reason"' , async ( ) => {
1118+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' } ] ) ;
1119+ await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1120+ const sub = engine . _tables [ 'sys_approval_action' ] . find ( ( a : any ) => a . action === 'ooo_substitute' ) ;
1121+ expect ( sub ) . toBeTruthy ( ) ;
1122+ expect ( sub . comment ) . toBe ( 'alice → bob — Annual leave' ) ;
1123+ expect ( sub . actor_id ) . toBeNull ( ) ; // system-recorded reroute, no human actor
1124+ } ) ;
1125+
1126+ it ( 'notifies both the delegate and the skipped approver' , async ( ) => {
1127+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' } ] ) ;
1128+ await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1129+ const to = emitted . find ( e => e . topic === 'approval.ooo_substituted' ) ;
1130+ const from = emitted . find ( e => e . topic === 'approval.ooo_skipped' ) ;
1131+ expect ( to ?. audience ) . toEqual ( [ 'bob' ] ) ;
1132+ expect ( from ?. audience ) . toEqual ( [ 'alice' ] ) ;
1133+ } ) ;
1134+
1135+ it ( 'does not reroute before valid_from (window not yet open)' , async ( ) => {
1136+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' , valid_from : '2026-05-28T00:00:00Z' } ] ) ;
1137+ const req = await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1138+ expect ( req . pending_approvers ) . toEqual ( [ 'alice' ] ) ;
1139+ expect ( engine . _tables [ 'sys_approval_action' ] . some ( ( a : any ) => a . action === 'ooo_substitute' ) ) . toBe ( false ) ;
1140+ } ) ;
1141+
1142+ it ( 'does not reroute at/after valid_until (half-open window)' , async ( ) => {
1143+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' , valid_until : '2026-05-27T10:00:00Z' } ] ) ;
1144+ const req = await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1145+ expect ( req . pending_approvers ) . toEqual ( [ 'alice' ] ) ;
1146+ } ) ;
1147+
1148+ it ( 'type:field — reroutes the user stored in the record field' , async ( ) => {
1149+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' } ] ) ;
1150+ const input = {
1151+ ...openInput ( [ ] ) ,
1152+ record : { id : 'opp1' , reviewer : 'alice' } ,
1153+ config : { approvers : [ { type : 'field' , value : 'reviewer' } ] , behavior : 'first_response' , lockRecord : true } ,
1154+ } ;
1155+ const req = await svc . openNodeRequest ( input as any , CTX ) ;
1156+ expect ( req . pending_approvers ) . toEqual ( [ 'bob' ] ) ;
1157+ } ) ;
1158+
1159+ it ( 'type:manager — reroutes when the resolved manager is out of office' , async ( ) => {
1160+ engine . _tables [ 'sys_user' ] = [ { id : 'carol' , manager_id : 'alice' } ] ;
1161+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' } ] ) ;
1162+ const input = {
1163+ ...openInput ( [ ] ) ,
1164+ record : { id : 'opp1' , owner_id : 'carol' } ,
1165+ config : { approvers : [ { type : 'manager' , value : 'owner_id' } ] , behavior : 'first_response' , lockRecord : true } ,
1166+ } ;
1167+ const req = await svc . openNodeRequest ( input as any , CTX ) ;
1168+ expect ( req . pending_approvers ) . toEqual ( [ 'bob' ] ) ;
1169+ } ) ;
1170+
1171+ it ( 'follows a delegation chain A → B → C' , async ( ) => {
1172+ seedDelegation ( [
1173+ { delegator_id : 'alice' , delegate_id : 'bob' } ,
1174+ { delegator_id : 'bob' , delegate_id : 'carol' } ,
1175+ ] ) ;
1176+ const req = await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1177+ expect ( req . pending_approvers ) . toEqual ( [ 'carol' ] ) ;
1178+ expect ( engine . _tables [ 'sys_approval_action' ] . filter ( ( a : any ) => a . action === 'ooo_substitute' ) ) . toHaveLength ( 2 ) ;
1179+ } ) ;
1180+
1181+ it ( 'stops on a cycle A → B → A without looping' , async ( ) => {
1182+ seedDelegation ( [
1183+ { delegator_id : 'alice' , delegate_id : 'bob' } ,
1184+ { delegator_id : 'bob' , delegate_id : 'alice' } ,
1185+ ] ) ;
1186+ const req = await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1187+ expect ( req . pending_approvers ) . toEqual ( [ 'bob' ] ) ;
1188+ } ) ;
1189+
1190+ it ( 'ignores a self-delegation (A → A)' , async ( ) => {
1191+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'alice' } ] ) ;
1192+ const req = await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1193+ expect ( req . pending_approvers ) . toEqual ( [ 'alice' ] ) ;
1194+ expect ( engine . _tables [ 'sys_approval_action' ] . some ( ( a : any ) => a . action === 'ooo_substitute' ) ) . toBe ( false ) ;
1195+ } ) ;
1196+
1197+ it ( 'leaves approvers unchanged when there is no active delegation' , async ( ) => {
1198+ const req = await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1199+ expect ( req . pending_approvers ) . toEqual ( [ 'alice' ] ) ;
1200+ } ) ;
1201+
1202+ it ( 'does not OOO-substitute group-routed (position) approvers' , async ( ) => {
1203+ engine . _tables [ 'sys_user_position' ] = [ { id : 'up1' , user_id : 'alice' , position : 'sales_manager' , organization_id : 't1' } ] ;
1204+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' } ] ) ;
1205+ const input = {
1206+ ...openInput ( [ ] ) ,
1207+ config : { approvers : [ { type : 'position' , value : 'sales_manager' } ] , behavior : 'first_response' , lockRecord : true } ,
1208+ } ;
1209+ const req = await svc . openNodeRequest ( input as any , CTX ) ;
1210+ // Position-routed leave is ADR-0091's job, not this path: the holder stays.
1211+ expect ( req . pending_approvers ) . toEqual ( [ 'alice' ] ) ;
1212+ } ) ;
1213+
1214+ it ( 'respects tenant scope: a rule scoped to another org does not apply' , async ( ) => {
1215+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' , organization_id : 't2' } ] ) ;
1216+ const req = await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1217+ expect ( req . pending_approvers ) . toEqual ( [ 'alice' ] ) ;
1218+ } ) ;
1219+
1220+ it ( 'applies a cross-tenant (null org) rule regardless of request tenant' , async ( ) => {
1221+ seedDelegation ( [ { delegator_id : 'alice' , delegate_id : 'bob' , organization_id : null } ] ) ;
1222+ const req = await svc . openNodeRequest ( openInput ( [ 'alice' ] ) , CTX ) ;
1223+ expect ( req . pending_approvers ) . toEqual ( [ 'bob' ] ) ;
1224+ } ) ;
1225+ } ) ;
0 commit comments