diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index ec9e13c2..ba9cb1d8 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -26,7 +26,7 @@ const AUTH = JSON.stringify({ ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; ((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; -import { fetchQuotaForProvider } from './quotaProviders'; +import { fetchHyperQuota, fetchQuotaForProvider } from './quotaProviders'; type MockResponseInit = { ok?: boolean; status?: number }; @@ -85,6 +85,13 @@ const stubFetchFailing = (json: () => Promise, init: MockResponseInit): globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch; }; +test('dispatches Charm Hyper through the generic quota API', async () => { + stubFetchReturning(async () => Response.json({ balance: 100 })); + const result = await fetchQuotaForProvider('hyper'); + assert.equal(result.ok, true); + assert.equal(result.usage?.windows.credits?.valueLabel, '100'); +}); + describe('OpenCode Go quota provider (VS Code parity)', () => { test('uses the opencode-go key from auth.json', async () => { let request: RequestInit | undefined; @@ -717,85 +724,128 @@ describe('DeepSeek quota provider (VS Code parity)', () => { }); describe('Charm Hyper quota provider (VS Code parity)', () => { - beforeEach(() => { - const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string }; - fsMock.existsSync = () => true; - fsMock.readFileSync = () => AUTH; - }); + const readAuth = () => ({ hyper: { key: 'test-token' } }); - test('builds credits and credits_balance windows from documented payload (numeric balance)', async () => { - stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 100 }))); + for (const { balance, credits, dollars } of [ + { balance: 100, credits: '100', dollars: '$5.00' }, + { balance: '50', credits: '50', dollars: '$2.50' }, + { balance: 25.5, credits: '25.50', dollars: '$1.28' }, + { balance: 0, credits: '0', dollars: '$0.00' }, + { balance: '0', credits: '0', dollars: '$0.00' }, + ]) { + test(`formats balance ${JSON.stringify(balance)} without an untranslated unit`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => Response.json({ balance }) }); + assert.equal(result.ok, true); + assert.equal(result.providerId, 'hyper'); + assert.equal(result.configured, true); + assert.ok(result.usage); + assert.equal(result.usage.windows.credits?.valueLabel, credits); + assert.equal(result.usage.windows.credits_balance?.valueLabel, dollars); + for (const window of Object.values(result.usage.windows)) { + assert.equal(window.usedPercent, null); + assert.equal(window.remainingPercent, null); + assert.equal(window.windowSeconds, null); + assert.equal(window.resetAt, null); + assert.equal(window.resetAfterSeconds, null); + } + }); + } - const result = await fetchQuotaForProvider('hyper'); + for (const payload of [ + {}, null, [], { balance: '' }, { balance: ' \t ' }, { balance: 'NaN' }, + { balance: 'Infinity' }, { balance: null }, { balance: true }, { balance: [] }, + { balance: {} }, + ]) { + test(`rejects invalid payload ${JSON.stringify(payload)} instead of showing zero`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => Response.json(payload) }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, 'No quota data in response'); + assert.equal(result.usage, null); + }); + } - assert.equal(result.ok, true); - assert.equal(result.providerId, 'hyper'); + for (const [index, auth] of [ + { hyper: { key: 'test-token' } }, + { hyper: { token: 'test-token' } }, + { hyper: 'test-token' }, + { hyper: { key: ' ', token: 'test-token' } }, + { hyper: { key: 42, token: 'test-token' } }, + ].entries()) { + test(`uses validated credential variant ${index} for the documented request`, async () => { + let requests = 0; + const result = await fetchHyperQuota({ + readAuth: () => auth, + fetchImpl: async (url, options) => { + requests += 1; + assert.equal(url, 'https://hyper.charm.land/v1/credits'); + assert.equal(options.method, 'GET'); + assert.equal(new Headers(options.headers).get('Authorization'), 'Bearer test-token'); + assert.ok(options.signal instanceof AbortSignal); + return Response.json({ balance: 100 }); + }, + }); + assert.equal(requests, 1); + assert.equal(result.ok, true); + assert.equal(JSON.stringify(result).includes('test-token'), false); + }); + } - const balanceWindow = result.usage!.windows.credits_balance!; - assert.equal(balanceWindow.valueLabel, '$5.00'); - assert.equal(balanceWindow.usedPercent, null); - assert.equal(balanceWindow.windowSeconds, null); - assert.equal(balanceWindow.resetAt, null); + for (const [index, readInvalidAuth] of [ + () => ({}), + () => ({ hyper: { key: '' } }), + () => ({ hyper: { key: ' ' } }), + () => ({ hyper: { key: 42 } }), + ].entries()) { + test(`does not request usage with missing or invalid credential variant ${index}`, async () => { + let requests = 0; + const result = await fetchHyperQuota({ + readAuth: readInvalidAuth, + fetchImpl: async () => { + requests += 1; + return Response.json({ balance: 100 }); + }, + }); + assert.equal(requests, 0); + assert.equal(result.ok, false); + assert.equal(result.configured, false); + assert.equal(result.error, 'Not configured'); + }); + } - const creditsWindow = result.usage!.windows.credits!; - assert.equal(creditsWindow.valueLabel, '100 credits'); - assert.equal(creditsWindow.usedPercent, null); - assert.equal(creditsWindow.windowSeconds, null); - assert.equal(creditsWindow.resetAt, null); - }); - - test('tolerates a string balance', async () => { - stubFetchReturning(() => Promise.resolve(mockResponse({ balance: '50' }))); - - const result = await fetchQuotaForProvider('hyper'); - - assert.equal(result.ok, true); - assert.equal(result.usage!.windows.credits!.valueLabel, '50 credits'); - assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$2.50'); - }); - - test('maps 401 to session-expired', async () => { - stubFetchFailing(async () => ({}), { ok: false, status: 401 }); - - const result = await fetchQuotaForProvider('hyper'); - - assert.equal(result.ok, false); - assert.equal(result.error, 'Session expired — please re-authenticate with Charm Hyper'); - }); - - test('reports a normalized timeout error', async () => { - stubFetchReturning(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError'))); - - const result = await fetchQuotaForProvider('hyper'); - - assert.equal(result.ok, false); - assert.equal(result.error, 'Request timed out'); - }); - - test('returns no-quota-data on a 200 payload with no balance', async () => { - stubFetchReturning(() => Promise.resolve(mockResponse({}))); - - const result = await fetchQuotaForProvider('hyper'); + for (const { status, error } of [ + { status: 401, error: 'Session expired — please re-authenticate with Charm Hyper' }, + { status: 403, error: 'Session expired — please re-authenticate with Charm Hyper' }, + { status: 429, error: 'API error: 429' }, + { status: 500, error: 'API error: 500' }, + ]) { + test(`reports HTTP ${status} as a failure`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, error); + assert.equal(result.usage, null); + }); + } + test('reports invalid JSON as a parse failure', async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => new Response('{') }); + assert.equal(result.error, 'Invalid response from provider'); assert.equal(result.ok, false); assert.equal(result.configured, true); - assert.equal(result.error, 'No quota data in response'); assert.equal(result.usage, null); }); - test('keeps a literal zero balance as a valid valueLabel', async () => { - stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 0 }))); - - const result = await fetchQuotaForProvider('hyper'); - - assert.equal(result.ok, true); - assert.equal(result.usage!.windows.credits!.valueLabel, '0 credits'); - assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00'); - }); - - test('teardown: restore fs', () => { - const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown }; - fsMock.existsSync = ORIGINAL_FS.existsSync; - fsMock.readFileSync = ORIGINAL_FS.readFileSync; - }); + for (const { failure, message } of [ + { failure: new DOMException('Timed out', 'TimeoutError'), message: 'Request timed out' }, + { failure: new Error('Network unavailable'), message: 'Network unavailable' }, + ]) { + test(`reports ${message}`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => { throw failure; } }); + assert.equal(result.error, message); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + }); + } }); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index b1518f2d..8e282b6b 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -155,10 +155,6 @@ type DeepseekPayload = { }>; }; -type HyperPayload = { - balance?: number | string; -}; - type NeuralwattPayload = { balance?: { credits_remaining_usd?: number | string; @@ -857,8 +853,7 @@ export const listConfiguredQuotaProviders = () => { configured.add('deepseek'); } - const hyperAuth = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])); - if (hyperAuth && ((hyperAuth as Record).key || (hyperAuth as Record).token)) { + if (getHyperApiKey(auth)) { configured.add('hyper'); } @@ -2798,10 +2793,18 @@ const fetchDeepseekQuota = async (): Promise => { const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits'; const HYPER_CREDIT_TO_USD = 0.05; -const fetchHyperQuota = async (): Promise => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])) as Record | null; - const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); +const getHyperApiKey = (auth: AuthFile) => { + const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])); + return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token); +}; + +type HyperQuotaDependencies = { + readAuth?: () => AuthFile; + fetchImpl?: (url: string, options: RequestInit) => Promise; +}; + +export const fetchHyperQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: HyperQuotaDependencies = {}): Promise => { + const apiKey = getHyperApiKey(readAuth()); if (!apiKey) { return buildResult({ @@ -2816,7 +2819,7 @@ const fetchHyperQuota = async (): Promise => { const timeoutSignal = AbortSignal.timeout(15_000); try { - const response = await fetch(HYPER_QUOTA_URL, { + const response = await fetchImpl(HYPER_QUOTA_URL, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, @@ -2837,11 +2840,10 @@ const fetchHyperQuota = async (): Promise => { }); } - const payload = await response.json() as HyperPayload; + const payload = asObject(await response.json()); const rawBalance = payload?.balance; - const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) - ? toNumber(rawBalance) - : null; + const balance = toNumber(asNonEmptyString(rawBalance) + ?? (Number.isFinite(rawBalance) ? rawBalance : null)); if (balance === null) { return buildResult({ @@ -2854,7 +2856,7 @@ const fetchHyperQuota = async (): Promise => { } const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance); - const windows: Record = { + const windows = { credits_balance: toUsageWindow({ usedPercent: null, windowSeconds: null, @@ -2865,7 +2867,7 @@ const fetchHyperQuota = async (): Promise => { usedPercent: null, windowSeconds: null, resetAt: null, - valueLabel: `${creditsLabel} credits`, + valueLabel: creditsLabel, }), }; diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 69bf5889..867d7132 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -91,6 +91,12 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo - **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent. - **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows. +## Charm Hyper balance semantics + +`GET https://hyper.charm.land/v1/credits` returns a team's current Hypercredit balance, not a percentage or reset timestamp. The [Hyper FAQ](https://hyper.charm.land/faq) defines one Hypercredit as $0.05. Both runtimes expose `credits_balance` in dollars and `credits` as a numeric label under the UI's localized window title. Keep English unit text out of that numeric label. + +Web and VS Code accept finite numeric balances and non-empty numeric strings. Missing, blank, or malformed balances remain explicit failures; zero is valid. Credential lookup uses a non-empty string `key`, then `token`, so malformed or blank keys cannot mark the provider configured or hide a valid fallback token. Hyper fetchers accept `readAuth` and `fetchImpl` dependencies for tests without replacing filesystem or auth modules. + ## Kimi for Coding field semantics `GET https://api.kimi.com/coding/v1/usages` is inconsistent about which field carries consumption: diff --git a/packages/web/server/lib/quota/providers/hyper.js b/packages/web/server/lib/quota/providers/hyper.js index 19170511..b86d6b2e 100644 --- a/packages/web/server/lib/quota/providers/hyper.js +++ b/packages/web/server/lib/quota/providers/hyper.js @@ -5,25 +5,26 @@ import { buildResult, toUsageWindow, toNumber, - formatMoney + formatMoney, + asObject, + asNonEmptyString } from '../utils/index.js'; export const providerId = 'hyper'; export const providerName = 'Charm Hyper'; -const aliases = ['hyper']; +export const aliases = ['hyper']; const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits'; const CREDIT_TO_USD = 0.05; -export const isConfigured = () => { - const auth = readAuthFile(); +const getApiKey = (auth) => { const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - return Boolean(entry?.key || entry?.token); + return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token); }; -export const fetchQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - const apiKey = entry?.key ?? entry?.token; +export const isConfigured = (auth = readAuthFile()) => Boolean(getApiKey(auth)); + +export const fetchQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch } = {}) => { + const apiKey = getApiKey(readAuth()); if (!apiKey) { return buildResult({ @@ -38,7 +39,7 @@ export const fetchQuota = async () => { const timeoutSignal = AbortSignal.timeout(15_000); try { - const response = await fetch(HYPER_QUOTA_URL, { + const response = await fetchImpl(HYPER_QUOTA_URL, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, @@ -59,11 +60,10 @@ export const fetchQuota = async () => { }); } - const payload = await response.json(); + const payload = asObject(await response.json()); const rawBalance = payload?.balance; - const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) - ? toNumber(rawBalance) - : null; + const balance = toNumber(asNonEmptyString(rawBalance) + ?? (Number.isFinite(rawBalance) ? rawBalance : null)); if (balance === null) { return buildResult({ @@ -87,7 +87,7 @@ export const fetchQuota = async () => { usedPercent: null, windowSeconds: null, resetAt: null, - valueLabel: `${creditsLabel} credits` + valueLabel: creditsLabel }) }; @@ -115,4 +115,4 @@ export const fetchQuota = async () => { : (error instanceof Error ? error.message : 'Request failed') }); } -}; \ No newline at end of file +}; diff --git a/packages/web/server/lib/quota/providers/hyper.test.js b/packages/web/server/lib/quota/providers/hyper.test.js index 9fd869f0..ad143d66 100644 --- a/packages/web/server/lib/quota/providers/hyper.test.js +++ b/packages/web/server/lib/quota/providers/hyper.test.js @@ -1,128 +1,126 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import { fetchQuota, isConfigured } from './hyper.js'; -vi.mock('../../opencode/auth.js', () => ({ - readAuthFile: () => ({ hyper: { key: 'test-token' } }), -})); +const readAuth = () => ({ hyper: { key: 'test-token' } }); -import { fetchQuota } from './hyper.js'; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -const mockResponse = (body, init = {}) => ({ - ok: true, - status: 200, - json: async () => body, - ...init, -}); - -// Documented payload shape from https://hyper.charm.land/docs/api/credits.html -// The balance is denominated in Hypercredits; 1 credit = $0.05. +// https://hyper.charm.land/docs/api/credits.html documents the balance payload. +// https://hyper.charm.land/faq defines one Hypercredit as $0.05. describe('Charm Hyper quota provider', () => { - it('builds credits and credits_balance windows from documented payload (numeric balance)', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 100 }))); - - const result = await fetchQuota(); + it.each([ + [100, '100', '$5.00'], + ['50', '50', '$2.50'], + [25.5, '25.50', '$1.28'], + [0, '0', '$0.00'], + ['0', '0', '$0.00'], + ])('formats balance %s without an untranslated unit', async (balance, credits, dollars) => { + const result = await fetchQuota({ + readAuth, + fetchImpl: async () => Response.json({ balance }), + }); expect(result.ok).toBe(true); expect(result.providerId).toBe('hyper'); - - const balanceWindow = result.usage.windows.credits_balance; - expect(balanceWindow).toBeDefined(); - expect(balanceWindow.valueLabel).toBe('$5.00'); - expect(balanceWindow.usedPercent).toBeNull(); - expect(balanceWindow.windowSeconds).toBeNull(); - expect(balanceWindow.resetAt).toBeNull(); - - const creditsWindow = result.usage.windows.credits; - expect(creditsWindow).toBeDefined(); - expect(creditsWindow.valueLabel).toBe('100 credits'); - expect(creditsWindow.usedPercent).toBeNull(); - expect(creditsWindow.windowSeconds).toBeNull(); - expect(creditsWindow.resetAt).toBeNull(); + expect(result.configured).toBe(true); + expect(result.usage.windows.credits.valueLabel).toBe(credits); + expect(result.usage.windows.credits_balance.valueLabel).toBe(dollars); + for (const window of Object.values(result.usage.windows)) { + expect(window.usedPercent).toBeNull(); + expect(window.remainingPercent).toBeNull(); + expect(window.windowSeconds).toBeNull(); + expect(window.resetAt).toBeNull(); + expect(window.resetAfterSeconds).toBeNull(); + } }); - it('tolerates a string balance', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '50' }))); + it.each([ + {}, null, [], { balance: '' }, { balance: ' \t ' }, { balance: 'NaN' }, + { balance: 'Infinity' }, { balance: null }, { balance: true }, { balance: [] }, + { balance: {} }, + ])('rejects invalid payload %j instead of showing zero', async (payload) => { + const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json(payload) }); - const result = await fetchQuota(); + expect(result.ok).toBe(false); + expect(result.configured).toBe(true); + expect(result.error).toBe('No quota data in response'); + expect(result.usage).toBeNull(); + }); + it.each([ + { hyper: { key: 'test-token' } }, + { hyper: { token: 'test-token' } }, + { hyper: 'test-token' }, + { hyper: { key: ' ', token: 'test-token' } }, + { hyper: { key: 42, token: 'test-token' } }, + ])('uses a validated credential for the documented request', async (auth) => { + expect(isConfigured(auth)).toBe(true); + let requests = 0; + const result = await fetchQuota({ + readAuth: () => auth, + fetchImpl: async (url, options) => { + requests += 1; + expect(url).toBe('https://hyper.charm.land/v1/credits'); + expect(options.method).toBe('GET'); + expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token'); + expect(options.signal).toBeInstanceOf(AbortSignal); + return Response.json({ balance: 100 }); + }, + }); + + expect(requests).toBe(1); expect(result.ok).toBe(true); - expect(result.usage.windows.credits.valueLabel).toBe('50 credits'); - expect(result.usage.windows.credits_balance.valueLabel).toBe('$2.50'); + expect(JSON.stringify(result)).not.toContain('test-token'); }); - it('formats a fractional balance in both windows', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 25.5 }))); + it.each([{}, { hyper: { key: '' } }, { hyper: { key: ' ' } }, { hyper: { key: 42 } }])( + 'does not request usage without a valid credential', + async (auth) => { + expect(isConfigured(auth)).toBe(false); + let requests = 0; + const result = await fetchQuota({ + readAuth: () => auth, + fetchImpl: async () => { + requests += 1; + return Response.json({ balance: 100 }); + }, + }); - const result = await fetchQuota(); + expect(requests).toBe(0); + expect(result.ok).toBe(false); + expect(result.configured).toBe(false); + expect(result.error).toBe('Not configured'); + }, + ); - expect(result.ok).toBe(true); - expect(result.usage.windows.credits.valueLabel).toBe('25.50 credits'); - expect(result.usage.windows.credits_balance.valueLabel).toBe('$1.28'); - }); - - it('maps 401 to session-expired error', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })); - - const result = await fetchQuota(); + it.each([ + [401, 'Session expired — please re-authenticate with Charm Hyper'], + [403, 'Session expired — please re-authenticate with Charm Hyper'], + [429, 'API error: 429'], + [500, 'API error: 500'], + ])('reports HTTP %s as a failure', async (status, error) => { + const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) }); expect(result.ok).toBe(false); - expect(result.error).toBe('Session expired — please re-authenticate with Charm Hyper'); + expect(result.configured).toBe(true); + expect(result.error).toBe(error); + expect(result.usage).toBeNull(); }); - it('maps 403 to session-expired error', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({}) })); - - const result = await fetchQuota(); - - expect(result.ok).toBe(false); - expect(result.error).toBe('Session expired — please re-authenticate with Charm Hyper'); - }); - - it('reports invalid-response on JSON parse failure', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => { throw new SyntaxError('Unexpected token'); }, - })); - - const result = await fetchQuota(); - - expect(result.ok).toBe(false); + it('reports invalid JSON as a parse failure', async () => { + const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response('{') }); expect(result.error).toBe('Invalid response from provider'); - }); - - it('returns no-quota-data on a 200 payload with no balance', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({}))); - - const result = await fetchQuota(); - expect(result.ok).toBe(false); expect(result.configured).toBe(true); - expect(result.error).toBe('No quota data in response'); expect(result.usage).toBeNull(); }); - it('returns no-quota-data on an empty-string balance', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '' }))); - - const result = await fetchQuota(); - + it.each([ + [new DOMException('Timed out', 'TimeoutError'), 'Request timed out'], + [new Error('Network unavailable'), 'Network unavailable'], + ])('reports request failure', async (failure, message) => { + const result = await fetchQuota({ readAuth, fetchImpl: async () => { throw failure; } }); + expect(result.error).toBe(message); expect(result.ok).toBe(false); expect(result.configured).toBe(true); - expect(result.error).toBe('No quota data in response'); expect(result.usage).toBeNull(); }); - - it('keeps a literal zero balance as a valid valueLabel', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 0 }))); - - const result = await fetchQuota(); - - expect(result.ok).toBe(true); - expect(result.usage.windows.credits.valueLabel).toBe('0 credits'); - expect(result.usage.windows.credits_balance.valueLabel).toBe('$0.00'); - }); -}); \ No newline at end of file +});