11// npx vitest run src/api/providers/__tests__/zoo-gateway.spec.ts
22
3- vitest . mock ( "vscode" , ( ) => ( { } ) )
3+ const { showErrorMessage, openExternal } = vitest . hoisted ( ( ) => ( {
4+ showErrorMessage : vitest . fn ( async ( ) => undefined as string | undefined ) ,
5+ openExternal : vitest . fn ( async ( ) => true ) ,
6+ } ) )
7+
8+ vitest . mock ( "vscode" , ( ) => ( {
9+ window : { showErrorMessage } ,
10+ env : { openExternal, uriScheme : "vscode" , appName : "VS Code" } ,
11+ Uri : { parse : ( value : string ) => ( { toString : ( ) => value } ) } ,
12+ } ) )
13+
14+ vitest . mock ( "../../../i18n" , ( ) => ( {
15+ t : ( key : string ) => key ,
16+ } ) )
417
518import OpenAI from "openai"
619
@@ -9,6 +22,7 @@ import { zooGatewayDefaultModelId, ZOO_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-
922import { ZooGatewayHandler } from "../zoo-gateway"
1023import { ApiHandlerOptions } from "../../../shared/api"
1124import { Package } from "../../../shared/package"
25+ import { clearZooCodeToken } from "../../../services/zoo-code-auth"
1226
1327vitest . mock ( "openai" )
1428vitest . mock ( "delay" , ( ) => ( { default : vitest . fn ( ( ) => Promise . resolve ( ) ) } ) )
@@ -78,9 +92,32 @@ describe("ZooGatewayHandler", () => {
7892 beforeEach ( ( ) => {
7993 vitest . clearAllMocks ( )
8094 mockCreate . mockClear ( )
95+ showErrorMessage . mockReset ( )
96+ showErrorMessage . mockResolvedValue ( undefined )
97+ openExternal . mockReset ( )
98+ openExternal . mockResolvedValue ( true )
8199 mockOpenAIClient ( )
82100 } )
83101
102+ function makeApiError ( status : number , options : { code ?: string ; message ?: string } = { } ) {
103+ const err = new Error ( options . message ?? `HTTP ${ status } ` ) as Error & {
104+ status : number
105+ code ?: string
106+ }
107+ err . status = status
108+ if ( options . code ) err . code = options . code
109+ return err
110+ }
111+
112+ async function drainCreateMessage ( handler : ZooGatewayHandler ) {
113+ const stream = handler . createMessage ( "system" , [ { role : "user" , content : "hi" } ] )
114+ const out : unknown [ ] = [ ]
115+ for await ( const chunk of stream ) {
116+ out . push ( chunk )
117+ }
118+ return out
119+ }
120+
84121 describe ( "constructor" , ( ) => {
85122 it ( "allows construction without a session token (auth is enforced at request time)" , ( ) => {
86123 expect ( ( ) => new ZooGatewayHandler ( { } ) ) . not . toThrow ( )
@@ -336,4 +373,112 @@ describe("ZooGatewayHandler", () => {
336373 await expect ( handler . completePrompt ( "Test" ) ) . resolves . toBe ( "" )
337374 } )
338375 } )
376+
377+ describe ( "surfaceGatewayApiError" , ( ) => {
378+ it ( "clears the cached token and offers re-sign-in on 401" , async ( ) => {
379+ const handler = new ZooGatewayHandler ( mockOptions )
380+ mockCreate . mockImplementation ( ( ) => {
381+ throw makeApiError ( 401 )
382+ } )
383+ showErrorMessage . mockResolvedValueOnce ( "common:zooAuth.buttons.sign_in" )
384+
385+ await expect ( drainCreateMessage ( handler ) ) . rejects . toThrow ( )
386+ expect ( clearZooCodeToken ) . toHaveBeenCalledTimes ( 1 )
387+ expect ( showErrorMessage ) . toHaveBeenCalledWith (
388+ "common:zooAuth.errors.session_expired" ,
389+ "common:zooAuth.buttons.sign_in" ,
390+ )
391+ expect ( openExternal ) . toHaveBeenCalledTimes ( 1 )
392+ } )
393+
394+ it ( "does not open a URL on 401 when the user dismisses the prompt" , async ( ) => {
395+ const handler = new ZooGatewayHandler ( mockOptions )
396+ mockCreate . mockImplementation ( ( ) => {
397+ throw makeApiError ( 401 )
398+ } )
399+ showErrorMessage . mockResolvedValueOnce ( undefined )
400+
401+ await expect ( drainCreateMessage ( handler ) ) . rejects . toThrow ( )
402+ expect ( clearZooCodeToken ) . toHaveBeenCalledTimes ( 1 )
403+ expect ( openExternal ) . not . toHaveBeenCalled ( )
404+ } )
405+
406+ it ( "prompts to add credits on 402" , async ( ) => {
407+ const handler = new ZooGatewayHandler ( mockOptions )
408+ mockCreate . mockImplementation ( ( ) => {
409+ throw makeApiError ( 402 )
410+ } )
411+ showErrorMessage . mockResolvedValueOnce ( "common:zooAuth.buttons.add_credits" )
412+
413+ await expect ( drainCreateMessage ( handler ) ) . rejects . toThrow ( )
414+ expect ( clearZooCodeToken ) . not . toHaveBeenCalled ( )
415+ expect ( showErrorMessage ) . toHaveBeenCalledWith (
416+ "common:zooAuth.errors.out_of_credits" ,
417+ "common:zooAuth.buttons.add_credits" ,
418+ )
419+ expect ( openExternal ) . toHaveBeenCalledTimes ( 1 )
420+ } )
421+
422+ it ( "shows the budget message on 429 with a budget code" , async ( ) => {
423+ const handler = new ZooGatewayHandler ( mockOptions )
424+ mockCreate . mockImplementation ( ( ) => {
425+ throw makeApiError ( 429 , { code : "monthly_budget_exceeded" } )
426+ } )
427+
428+ await expect ( drainCreateMessage ( handler ) ) . rejects . toThrow ( )
429+ expect ( showErrorMessage ) . toHaveBeenCalledWith (
430+ "common:zooAuth.errors.budget_exceeded" ,
431+ "common:zooAuth.buttons.add_credits" ,
432+ )
433+ } )
434+
435+ it ( "does not surface a notification on 429 without a budget code" , async ( ) => {
436+ const handler = new ZooGatewayHandler ( mockOptions )
437+ mockCreate . mockImplementation ( ( ) => {
438+ throw makeApiError ( 429 , { code : "rate_limited" } )
439+ } )
440+
441+ await expect ( drainCreateMessage ( handler ) ) . rejects . toThrow ( )
442+ expect ( showErrorMessage ) . not . toHaveBeenCalled ( )
443+ } )
444+
445+ it ( "offers contact support on 403" , async ( ) => {
446+ const handler = new ZooGatewayHandler ( mockOptions )
447+ mockCreate . mockImplementation ( ( ) => {
448+ throw makeApiError ( 403 )
449+ } )
450+ showErrorMessage . mockResolvedValueOnce ( "common:zooAuth.buttons.contact_support" )
451+
452+ await expect ( drainCreateMessage ( handler ) ) . rejects . toThrow ( )
453+ expect ( showErrorMessage ) . toHaveBeenCalledWith (
454+ "common:zooAuth.errors.account_unavailable" ,
455+ "common:zooAuth.buttons.contact_support" ,
456+ )
457+ expect ( openExternal ) . toHaveBeenCalledTimes ( 1 )
458+ } )
459+
460+ it ( "ignores errors without an HTTP status" , async ( ) => {
461+ const handler = new ZooGatewayHandler ( mockOptions )
462+ mockCreate . mockImplementation ( ( ) => {
463+ throw new Error ( "network down" )
464+ } )
465+
466+ await expect ( drainCreateMessage ( handler ) ) . rejects . toThrow ( "network down" )
467+ expect ( showErrorMessage ) . not . toHaveBeenCalled ( )
468+ expect ( clearZooCodeToken ) . not . toHaveBeenCalled ( )
469+ } )
470+
471+ it ( "surfaces the gateway error then wraps the message in completePrompt" , async ( ) => {
472+ const handler = new ZooGatewayHandler ( mockOptions )
473+ mockCreate . mockImplementation ( ( ) => {
474+ throw makeApiError ( 402 , { message : "out of credits" } )
475+ } )
476+
477+ await expect ( handler . completePrompt ( "ping" ) ) . rejects . toThrow ( "Zoo Gateway completion error: out of credits" )
478+ expect ( showErrorMessage ) . toHaveBeenCalledWith (
479+ "common:zooAuth.errors.out_of_credits" ,
480+ "common:zooAuth.buttons.add_credits" ,
481+ )
482+ } )
483+ } )
339484} )
0 commit comments