@@ -34,6 +34,8 @@ vi.mock("../lib/proactive-refresh.js", () => ({
3434function createManagedAccount ( index : number ) : ManagedAccount {
3535 return {
3636 index,
37+ accountId : `acct-${ index } ` ,
38+ email : `user${ index } @example.com` ,
3739 refreshToken : `refresh-${ index } ` ,
3840 addedAt : Date . now ( ) - 10_000 ,
3941 lastUsed : Date . now ( ) - 5_000 ,
@@ -331,7 +333,7 @@ describe("refresh-guardian", () => {
331333 expect ( tickSpy ) . toHaveBeenCalledTimes ( 1 ) ;
332334 } ) ;
333335
334- it ( "resolves refreshed account using stable refresh token when indices shift" , async ( ) => {
336+ it ( "resolves refreshed account by accountId when indices shift" , async ( ) => {
335337 const originalA = createManagedAccount ( 0 ) ;
336338 const originalB = createManagedAccount ( 1 ) ;
337339 const liveB = { ...originalB , index : 0 } ;
@@ -399,6 +401,196 @@ describe("refresh-guardian", () => {
399401 ) ;
400402 } ) ;
401403
404+ it ( "resolves refreshed account by accountId after refresh token rotation" , async ( ) => {
405+ const originalA = createManagedAccount ( 0 ) ;
406+ const originalB = createManagedAccount ( 1 ) ;
407+ const liveA = {
408+ ...originalA ,
409+ index : 1 ,
410+ refreshToken : "refresh-0-rotated" ,
411+ } ;
412+ const liveB = { ...originalB , index : 0 } ;
413+ const snapshots = [
414+ [ originalA , originalB ] ,
415+ [ liveB , liveA ] ,
416+ ] ;
417+ let readCount = 0 ;
418+ const manager = {
419+ getAccountsSnapshot : vi . fn (
420+ ( ) => snapshots [ Math . min ( readCount ++ , snapshots . length - 1 ) ] ,
421+ ) ,
422+ getAccountByIndex : vi . fn (
423+ ( index : number ) =>
424+ [ liveB , liveA ] . find ( ( account ) => account . index === index ) ?? null ,
425+ ) ,
426+ clearAuthFailures : vi . fn ( ) ,
427+ markAccountCoolingDown : vi . fn ( ) ,
428+ setAccountEnabled : vi . fn ( ) ,
429+ saveToDiskDebounced : vi . fn ( ) ,
430+ } as unknown as AccountManager ;
431+ const { RefreshGuardian } = await import ( "../lib/refresh-guardian.js" ) ;
432+ const guardian = new RefreshGuardian ( ( ) => manager , {
433+ bufferMs : 60_000 ,
434+ intervalMs : 5_000 ,
435+ } ) ;
436+
437+ refreshExpiringAccountsMock . mockResolvedValue (
438+ new Map ( [
439+ [
440+ 0 ,
441+ {
442+ refreshed : true ,
443+ reason : "success" ,
444+ tokenResult : {
445+ type : "success" ,
446+ access : "access-account-id" ,
447+ refresh : "refresh-account-id" ,
448+ expires : Date . now ( ) + 3_600_000 ,
449+ } ,
450+ } ,
451+ ] ,
452+ ] ) ,
453+ ) ;
454+
455+ await guardian . tick ( ) ;
456+
457+ expect ( applyRefreshResultMock ) . toHaveBeenCalledWith (
458+ liveA ,
459+ expect . objectContaining ( { type : "success" } ) ,
460+ ) ;
461+ expect (
462+ manager . clearAuthFailures as ReturnType < typeof vi . fn > ,
463+ ) . toHaveBeenCalledWith ( liveA ) ;
464+ } ) ;
465+
466+ it ( "falls back to refreshToken when accountId is unavailable and email is invalid" , async ( ) => {
467+ const originalA = {
468+ ...createManagedAccount ( 0 ) ,
469+ accountId : undefined ,
470+ email : "invalid-no-at" ,
471+ } ;
472+ const originalB = createManagedAccount ( 1 ) ;
473+ const liveA = {
474+ ...originalA ,
475+ index : 1 ,
476+ } ;
477+ const liveB = { ...originalB , index : 0 } ;
478+ const snapshots = [
479+ [ originalA , originalB ] ,
480+ [ liveB , liveA ] ,
481+ ] ;
482+ let readCount = 0 ;
483+ const manager = {
484+ getAccountsSnapshot : vi . fn (
485+ ( ) => snapshots [ Math . min ( readCount ++ , snapshots . length - 1 ) ] ,
486+ ) ,
487+ getAccountByIndex : vi . fn (
488+ ( index : number ) =>
489+ [ liveB , liveA ] . find ( ( account ) => account . index === index ) ?? null ,
490+ ) ,
491+ clearAuthFailures : vi . fn ( ) ,
492+ markAccountCoolingDown : vi . fn ( ) ,
493+ setAccountEnabled : vi . fn ( ) ,
494+ saveToDiskDebounced : vi . fn ( ) ,
495+ } as unknown as AccountManager ;
496+ const { RefreshGuardian } = await import ( "../lib/refresh-guardian.js" ) ;
497+ const guardian = new RefreshGuardian ( ( ) => manager , {
498+ bufferMs : 60_000 ,
499+ intervalMs : 5_000 ,
500+ } ) ;
501+
502+ refreshExpiringAccountsMock . mockResolvedValue (
503+ new Map ( [
504+ [
505+ 0 ,
506+ {
507+ refreshed : true ,
508+ reason : "success" ,
509+ tokenResult : {
510+ type : "success" ,
511+ access : "access-token" ,
512+ refresh : "refresh-token" ,
513+ expires : Date . now ( ) + 3_600_000 ,
514+ } ,
515+ } ,
516+ ] ,
517+ ] ) ,
518+ ) ;
519+
520+ await guardian . tick ( ) ;
521+
522+ expect ( applyRefreshResultMock ) . toHaveBeenCalledWith (
523+ liveA ,
524+ expect . objectContaining ( { type : "success" } ) ,
525+ ) ;
526+ expect (
527+ manager . clearAuthFailures as ReturnType < typeof vi . fn > ,
528+ ) . toHaveBeenCalledWith ( liveA ) ;
529+ } ) ;
530+
531+ it ( "treats empty string accountId the same as undefined by using normalized email" , async ( ) => {
532+ const originalA = { ...createManagedAccount ( 0 ) , accountId : "" , email : " User0@Example.com " } ;
533+ const originalB = createManagedAccount ( 1 ) ;
534+ const liveA = {
535+ ...originalA ,
536+ index : 1 ,
537+ refreshToken : "refresh-0-rotated" ,
538+ email : "user0@example.com" ,
539+ } ;
540+ const liveB = { ...originalB , index : 0 } ;
541+ const snapshots = [
542+ [ originalA , originalB ] ,
543+ [ liveB , liveA ] ,
544+ ] ;
545+ let readCount = 0 ;
546+ const manager = {
547+ getAccountsSnapshot : vi . fn (
548+ ( ) => snapshots [ Math . min ( readCount ++ , snapshots . length - 1 ) ] ,
549+ ) ,
550+ getAccountByIndex : vi . fn (
551+ ( index : number ) =>
552+ [ liveB , liveA ] . find ( ( account ) => account . index === index ) ?? null ,
553+ ) ,
554+ clearAuthFailures : vi . fn ( ) ,
555+ markAccountCoolingDown : vi . fn ( ) ,
556+ setAccountEnabled : vi . fn ( ) ,
557+ saveToDiskDebounced : vi . fn ( ) ,
558+ } as unknown as AccountManager ;
559+ const { RefreshGuardian } = await import ( "../lib/refresh-guardian.js" ) ;
560+ const guardian = new RefreshGuardian ( ( ) => manager , {
561+ bufferMs : 60_000 ,
562+ intervalMs : 5_000 ,
563+ } ) ;
564+
565+ refreshExpiringAccountsMock . mockResolvedValue (
566+ new Map ( [
567+ [
568+ 0 ,
569+ {
570+ refreshed : true ,
571+ reason : "success" ,
572+ tokenResult : {
573+ type : "success" ,
574+ access : "access-email" ,
575+ refresh : "refresh-email" ,
576+ expires : Date . now ( ) + 3_600_000 ,
577+ } ,
578+ } ,
579+ ] ,
580+ ] ) ,
581+ ) ;
582+
583+ await guardian . tick ( ) ;
584+
585+ expect ( applyRefreshResultMock ) . toHaveBeenCalledWith (
586+ liveA ,
587+ expect . objectContaining ( { type : "success" } ) ,
588+ ) ;
589+ expect (
590+ manager . clearAuthFailures as ReturnType < typeof vi . fn > ,
591+ ) . toHaveBeenCalledWith ( liveA ) ;
592+ } ) ;
593+
402594 it ( "classifies failure reasons and handles no-op branches" , async ( ) => {
403595 const accountA = createManagedAccount ( 0 ) ;
404596 const accountB = createManagedAccount ( 1 ) ;
@@ -649,6 +841,9 @@ describe("refresh-guardian", () => {
649841 expect (
650842 manager . saveToDiskDebounced as ReturnType < typeof vi . fn > ,
651843 ) . toHaveBeenCalledTimes ( 1 ) ;
844+ expect (
845+ manager . getAccountsSnapshot as ReturnType < typeof vi . fn > ,
846+ ) . toHaveBeenCalledTimes ( 2 ) ;
652847
653848 const stats = guardian . getStats ( ) ;
654849 expect ( stats . runs ) . toBe ( 1 ) ;
0 commit comments