@@ -585,6 +585,117 @@ describe("claude plugin", () => {
585585 expect ( ( ) => plugin . probe ( ctx ) ) . not . toThrow ( )
586586 } )
587587
588+ it ( "falls back to keychain when file oauth exists but has no access token" , async ( ) => {
589+ const ctx = makeCtx ( )
590+ ctx . host . fs . exists = ( ) => true
591+ ctx . host . fs . readText = ( ) => JSON . stringify ( { claudeAiOauth : { refreshToken : "only-refresh" } } )
592+ ctx . host . keychain . readGenericPassword . mockReturnValue (
593+ JSON . stringify ( { claudeAiOauth : { accessToken : "keychain-token" , subscriptionType : "pro" } } )
594+ )
595+ ctx . host . http . request . mockReturnValue ( {
596+ status : 200 ,
597+ bodyText : JSON . stringify ( {
598+ five_hour : { utilization : 10 , resets_at : "2099-01-01T00:00:00.000Z" } ,
599+ } ) ,
600+ } )
601+
602+ const plugin = await loadPlugin ( )
603+ const result = plugin . probe ( ctx )
604+ expect ( result . lines . find ( ( line ) => line . label === "Session" ) ) . toBeTruthy ( )
605+ } )
606+
607+ it ( "treats keychain oauth without access token as not logged in" , async ( ) => {
608+ const ctx = makeCtx ( )
609+ ctx . host . fs . exists = ( ) => false
610+ ctx . host . keychain . readGenericPassword . mockReturnValue (
611+ JSON . stringify ( { claudeAiOauth : { refreshToken : "only-refresh" } } )
612+ )
613+ const plugin = await loadPlugin ( )
614+ expect ( ( ) => plugin . probe ( ctx ) ) . toThrow ( "Not logged in" )
615+ } )
616+
617+ it ( "continues with existing token when refresh cannot return a usable token" , async ( ) => {
618+ const baseCreds = JSON . stringify ( {
619+ claudeAiOauth : {
620+ accessToken : "token" ,
621+ refreshToken : "refresh" ,
622+ expiresAt : Date . now ( ) - 1 ,
623+ } ,
624+ } )
625+
626+ const runCase = async ( refreshResp ) => {
627+ const ctx = makeCtx ( )
628+ ctx . host . fs . exists = ( ) => true
629+ ctx . host . fs . readText = ( ) => baseCreds
630+ ctx . host . http . request . mockImplementation ( ( opts ) => {
631+ if ( String ( opts . url ) . includes ( "/v1/oauth/token" ) ) return refreshResp
632+ return {
633+ status : 200 ,
634+ bodyText : JSON . stringify ( {
635+ five_hour : { utilization : 10 , resets_at : "2099-01-01T00:00:00.000Z" } ,
636+ } ) ,
637+ }
638+ } )
639+
640+ delete globalThis . __openusage_plugin
641+ vi . resetModules ( )
642+ const plugin = await loadPlugin ( )
643+ const result = plugin . probe ( ctx )
644+ expect ( result . lines . find ( ( line ) => line . label === "Session" ) ) . toBeTruthy ( )
645+ }
646+
647+ await runCase ( { status : 500 , bodyText : "" } )
648+ await runCase ( { status : 200 , bodyText : "not-json" } )
649+ await runCase ( { status : 200 , bodyText : JSON . stringify ( { } ) } )
650+ } )
651+
652+ it ( "skips proactive refresh when token is not near expiry" , async ( ) => {
653+ const ctx = makeCtx ( )
654+ const now = 1_700_000_000_000
655+ vi . spyOn ( Date , "now" ) . mockReturnValue ( now )
656+ ctx . host . fs . exists = ( ) => true
657+ ctx . host . fs . readText = ( ) =>
658+ JSON . stringify ( {
659+ claudeAiOauth : {
660+ accessToken : "token" ,
661+ refreshToken : "refresh" ,
662+ expiresAt : now + 24 * 60 * 60 * 1000 ,
663+ subscriptionType : "pro" ,
664+ } ,
665+ } )
666+ ctx . host . http . request . mockReturnValue ( {
667+ status : 200 ,
668+ bodyText : JSON . stringify ( {
669+ five_hour : { utilization : 10 , resets_at : "2099-01-01T00:00:00.000Z" } ,
670+ } ) ,
671+ } )
672+
673+ const plugin = await loadPlugin ( )
674+ plugin . probe ( ctx )
675+ expect (
676+ ctx . host . http . request . mock . calls . some ( ( call ) => String ( call [ 0 ] ?. url ) . includes ( "/v1/oauth/token" ) )
677+ ) . toBe ( false )
678+ } )
679+
680+ it ( "handles malformed ccusage payload shape as runner_failed" , async ( ) => {
681+ const ctx = makeCtx ( )
682+ ctx . host . fs . exists = ( ) => true
683+ ctx . host . fs . readText = ( ) => JSON . stringify ( { claudeAiOauth : { accessToken : "token" , subscriptionType : " " } } )
684+ ctx . host . http . request . mockReturnValue ( {
685+ status : 200 ,
686+ bodyText : JSON . stringify ( {
687+ five_hour : { utilization : 10 , resets_at : "2099-01-01T00:00:00.000Z" } ,
688+ } ) ,
689+ } )
690+ ctx . host . ccusage . query = vi . fn ( ( ) => ( { status : "ok" , data : { } } ) )
691+
692+ const plugin = await loadPlugin ( )
693+ const result = plugin . probe ( ctx )
694+ expect ( result . plan ) . toBeNull ( )
695+ expect ( result . lines . find ( ( line ) => line . label === "Session" ) ) . toBeTruthy ( )
696+ expect ( result . lines . find ( ( line ) => line . label === "Today" ) ) . toBeUndefined ( )
697+ } )
698+
588699 it ( "throws usage request failed after refresh when retry errors" , async ( ) => {
589700 const ctx = makeCtx ( )
590701 ctx . host . fs . exists = ( ) => true
0 commit comments