Skip to content

Commit c11c031

Browse files
James Mtendamemacursoragent
andcommitted
test(zoo-gateway): cover surfaceGatewayApiError UX branches for codecov patch
Adds vscode + i18n mocks and asserts the 401/402/403/429 paths in surfaceGatewayApiError: token clear + sign-in URL on 401, add-credits URL on 402 and budget-coded 429, support URL on 403, no-op on 429 without a budget code or on errors without a status. Also verifies the helper still runs before completePrompt rewraps the upstream error. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a66439 commit c11c031

1 file changed

Lines changed: 146 additions & 1 deletion

File tree

src/api/providers/__tests__/zoo-gateway.spec.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
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

518
import OpenAI from "openai"
619

@@ -9,6 +22,7 @@ import { zooGatewayDefaultModelId, ZOO_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-
922
import { ZooGatewayHandler } from "../zoo-gateway"
1023
import { ApiHandlerOptions } from "../../../shared/api"
1124
import { Package } from "../../../shared/package"
25+
import { clearZooCodeToken } from "../../../services/zoo-code-auth"
1226

1327
vitest.mock("openai")
1428
vitest.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

Comments
 (0)