@@ -762,6 +762,221 @@ describe('SessionTokenCache', () => {
762762 } ) ;
763763 } ) ;
764764
765+ describe ( 'proactive refresh timer' , ( ) => {
766+ it ( 'fires onExpiringSoon callback at REFRESH_BUFFER seconds before leeway zone' , async ( ) => {
767+ const nowSeconds = Math . floor ( Date . now ( ) / 1000 ) ;
768+ const jwt = createJwtWithTtl ( nowSeconds , 60 ) ;
769+
770+ const token = new Token ( {
771+ id : 'proactive-refresh-token' ,
772+ jwt,
773+ object : 'token' ,
774+ } ) ;
775+
776+ const tokenResolver = Promise . resolve < TokenResource > ( token ) ;
777+ const onExpiringSoon = vi . fn ( ) ;
778+ const key = { tokenId : 'proactive-refresh-token' } ;
779+
780+ SessionTokenCache . set ( { ...key , tokenResolver, onExpiringSoon } ) ;
781+ await tokenResolver ;
782+
783+ // Timer should fire at: expiresIn - LEEWAY - SYNC_LEEWAY - REFRESH_BUFFER = 60 - 10 - 5 - 2 = 43s
784+ expect ( onExpiringSoon ) . not . toHaveBeenCalled ( ) ;
785+
786+ // Advance to just before the timer (42s)
787+ vi . advanceTimersByTime ( 42 * 1000 ) ;
788+ expect ( onExpiringSoon ) . not . toHaveBeenCalled ( ) ;
789+
790+ // Advance 1 more second to hit the timer (43s)
791+ vi . advanceTimersByTime ( 1 * 1000 ) ;
792+ expect ( onExpiringSoon ) . toHaveBeenCalledTimes ( 1 ) ;
793+ } ) ;
794+
795+ it ( 'does not call onExpiringSoon if entry was replaced before timer fires' , async ( ) => {
796+ const nowSeconds = Math . floor ( Date . now ( ) / 1000 ) ;
797+ const jwt1 = createJwtWithTtl ( nowSeconds , 60 ) ;
798+ const jwt2 = createJwtWithTtl ( nowSeconds , 60 ) ;
799+
800+ const token1 = new Token ( { id : 'replaced-token' , jwt : jwt1 , object : 'token' } ) ;
801+ const token2 = new Token ( { id : 'replaced-token' , jwt : jwt2 , object : 'token' } ) ;
802+
803+ const resolver1 = Promise . resolve < TokenResource > ( token1 ) ;
804+ const resolver2 = Promise . resolve < TokenResource > ( token2 ) ;
805+ const onExpiringSoon1 = vi . fn ( ) ;
806+ const onExpiringSoon2 = vi . fn ( ) ;
807+ const key = { tokenId : 'replaced-token' } ;
808+
809+ // Set first entry
810+ SessionTokenCache . set ( { ...key , tokenResolver : resolver1 , onExpiringSoon : onExpiringSoon1 } ) ;
811+ await resolver1 ;
812+
813+ // Advance time partway (20s)
814+ vi . advanceTimersByTime ( 20 * 1000 ) ;
815+
816+ // Replace with new entry before timer fires
817+ SessionTokenCache . set ( { ...key , tokenResolver : resolver2 , onExpiringSoon : onExpiringSoon2 } ) ;
818+ await resolver2 ;
819+
820+ // Advance to when original timer would fire (23s more = 43s total from first set)
821+ vi . advanceTimersByTime ( 23 * 1000 ) ;
822+
823+ // Original callback should NOT be called (entry was replaced)
824+ expect ( onExpiringSoon1 ) . not . toHaveBeenCalled ( ) ;
825+
826+ // New callback should NOT be called yet (only 23s since second set, need 43s)
827+ expect ( onExpiringSoon2 ) . not . toHaveBeenCalled ( ) ;
828+
829+ // Advance 20 more seconds (43s from second set)
830+ vi . advanceTimersByTime ( 20 * 1000 ) ;
831+ expect ( onExpiringSoon2 ) . toHaveBeenCalledTimes ( 1 ) ;
832+ } ) ;
833+
834+ it ( 'returns old token while proactive refresh is in progress (fetch not complete)' , async ( ) => {
835+ const nowSeconds = Math . floor ( Date . now ( ) / 1000 ) ;
836+ const jwt1 = createJwtWithTtl ( nowSeconds , 60 ) ;
837+
838+ const token1 = new Token ( { id : 'proactive-test' , jwt : jwt1 , object : 'token' } ) ;
839+ const resolver1 = Promise . resolve < TokenResource > ( token1 ) ;
840+ const key = { tokenId : 'proactive-test' } ;
841+
842+ let refreshTriggered = false ;
843+ let resolveNewToken : ( token : TokenResource ) => void ;
844+ const newTokenPromise = new Promise < TokenResource > ( resolve => {
845+ resolveNewToken = resolve ;
846+ } ) ;
847+
848+ SessionTokenCache . set ( {
849+ ...key ,
850+ tokenResolver : resolver1 ,
851+ onExpiringSoon : ( ) => {
852+ refreshTriggered = true ;
853+ // Simulate background refresh that takes time - DON'T update cache yet
854+ // In real code, Session.#proactiveRefresh only updates cache after fetch completes
855+ } ,
856+ } ) ;
857+ await resolver1 ;
858+
859+ // Advance to timer fire time (43s)
860+ vi . advanceTimersByTime ( 43 * 1000 ) ;
861+ expect ( refreshTriggered ) . toBe ( true ) ;
862+
863+ // At t=44 (between timer at 43s and leeway at 45s)
864+ // The old token is still in cache because proactive refresh hasn't completed yet
865+ vi . advanceTimersByTime ( 1 * 1000 ) ;
866+
867+ const cached = SessionTokenCache . get ( key ) ;
868+ expect ( cached ) . toBeDefined ( ) ;
869+
870+ // Should still be the OLD token (iat = nowSeconds, not nowSeconds + 44)
871+ const resolvedToken = await cached ! . tokenResolver ;
872+ expect ( resolvedToken . jwt ?. claims ?. iat ) . toBe ( nowSeconds ) ;
873+ } ) ;
874+
875+ it ( 'returns new token after proactive refresh completes' , async ( ) => {
876+ const nowSeconds = Math . floor ( Date . now ( ) / 1000 ) ;
877+ const jwt1 = createJwtWithTtl ( nowSeconds , 60 ) ;
878+
879+ const token1 = new Token ( { id : 'proactive-complete' , jwt : jwt1 , object : 'token' } ) ;
880+ const resolver1 = Promise . resolve < TokenResource > ( token1 ) ;
881+ const key = { tokenId : 'proactive-complete' } ;
882+
883+ let refreshTriggered = false ;
884+
885+ SessionTokenCache . set ( {
886+ ...key ,
887+ tokenResolver : resolver1 ,
888+ onExpiringSoon : ( ) => {
889+ refreshTriggered = true ;
890+ // Simulate proactive refresh completing - update cache with new token
891+ const newJwt = createJwtWithTtl ( nowSeconds + 43 , 60 ) ;
892+ const newToken = new Token ( { id : 'proactive-complete' , jwt : newJwt , object : 'token' } ) ;
893+ SessionTokenCache . set ( { ...key , tokenResolver : Promise . resolve ( newToken ) } ) ;
894+ } ,
895+ } ) ;
896+ await resolver1 ;
897+
898+ // Advance to timer fire time (43s) - refresh completes immediately in this test
899+ vi . advanceTimersByTime ( 43 * 1000 ) ;
900+ expect ( refreshTriggered ) . toBe ( true ) ;
901+
902+ // At t=44, the new token should be in cache
903+ vi . advanceTimersByTime ( 1 * 1000 ) ;
904+
905+ const cached = SessionTokenCache . get ( key ) ;
906+ expect ( cached ) . toBeDefined ( ) ;
907+
908+ // Should be the NEW token
909+ const resolvedToken = await cached ! . tokenResolver ;
910+ expect ( resolvedToken . jwt ?. claims ?. iat ) . toBe ( nowSeconds + 43 ) ;
911+ } ) ;
912+
913+ it ( 'does not schedule refresh timer when refreshDelay would be negative' , async ( ) => {
914+ const nowSeconds = Math . floor ( Date . now ( ) / 1000 ) ;
915+ // Token with only 10s TTL - refreshDelay = 10 - 10 - 5 - 2 = -7 (negative)
916+ const jwt = createJwtWithTtl ( nowSeconds , 10 ) ;
917+
918+ const token = new Token ( { id : 'short-lived-token' , jwt, object : 'token' } ) ;
919+ const tokenResolver = Promise . resolve < TokenResource > ( token ) ;
920+ const onExpiringSoon = vi . fn ( ) ;
921+ const key = { tokenId : 'short-lived-token' } ;
922+
923+ SessionTokenCache . set ( { ...key , tokenResolver, onExpiringSoon } ) ;
924+ await tokenResolver ;
925+
926+ // Advance past token expiration
927+ vi . advanceTimersByTime ( 15 * 1000 ) ;
928+
929+ // Callback should never be called for tokens that expire too soon
930+ expect ( onExpiringSoon ) . not . toHaveBeenCalled ( ) ;
931+ } ) ;
932+
933+ it ( 'clears refresh timer when entry is deleted via clear()' , async ( ) => {
934+ const nowSeconds = Math . floor ( Date . now ( ) / 1000 ) ;
935+ const jwt = createJwtWithTtl ( nowSeconds , 60 ) ;
936+
937+ const token = new Token ( { id : 'cleared-token' , jwt, object : 'token' } ) ;
938+ const tokenResolver = Promise . resolve < TokenResource > ( token ) ;
939+ const onExpiringSoon = vi . fn ( ) ;
940+ const key = { tokenId : 'cleared-token' } ;
941+
942+ SessionTokenCache . set ( { ...key , tokenResolver, onExpiringSoon } ) ;
943+ await tokenResolver ;
944+
945+ // Clear the cache
946+ SessionTokenCache . clear ( ) ;
947+
948+ // Advance to when timer would have fired
949+ vi . advanceTimersByTime ( 43 * 1000 ) ;
950+
951+ // Callback should NOT be called (timer was cleared)
952+ expect ( onExpiringSoon ) . not . toHaveBeenCalled ( ) ;
953+ } ) ;
954+
955+ it ( 'refresh timer fires before token enters leeway zone' , async ( ) => {
956+ const nowSeconds = Math . floor ( Date . now ( ) / 1000 ) ;
957+ const jwt = createJwtWithTtl ( nowSeconds , 60 ) ;
958+
959+ const token = new Token ( { id : 'timing-token' , jwt, object : 'token' } ) ;
960+ const tokenResolver = Promise . resolve < TokenResource > ( token ) ;
961+ const onExpiringSoon = vi . fn ( ) ;
962+ const key = { tokenId : 'timing-token' } ;
963+
964+ SessionTokenCache . set ( { ...key , tokenResolver, onExpiringSoon } ) ;
965+ await tokenResolver ;
966+
967+ // At t=43, callback fires (before leeway zone at t=45)
968+ // At t=46, token is in leeway zone and get() returns undefined
969+ vi . advanceTimersByTime ( 46 * 1000 ) ;
970+
971+ // The callback WAS called at t=43
972+ expect ( onExpiringSoon ) . toHaveBeenCalledTimes ( 1 ) ;
973+
974+ // But now the token is in leeway zone
975+ const cached = SessionTokenCache . get ( key ) ;
976+ expect ( cached ) . toBeUndefined ( ) ;
977+ } ) ;
978+ } ) ;
979+
765980 describe ( 'multi-session isolation' , ( ) => {
766981 it ( 'stores tokens from different session IDs separately without interference' , async ( ) => {
767982 const nowSeconds = Math . floor ( Date . now ( ) / 1000 ) ;
0 commit comments