fix(quota): validate Hyper credentials and clean up balance labels

Reject invalid credentials while preserving valid token fallback, parse balances with existing boundary helpers, and keep credit values free of untranslated unit text. Inject auth and HTTP dependencies for focused tests in both runtimes.

Validated web quota and registry tests (35 passed), VS Code quota tests (70 passed), both package type checks and lint, extension build, and changed-line anti-slop checks. Reviewed dead-code output. Live Hyper validation was not run because no API key is available.
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 23:26:35 +03:00
parent 5e7c147785
commit b0282b2720
5 changed files with 259 additions and 203 deletions
+121 -71
View File
@@ -26,7 +26,7 @@ const AUTH = JSON.stringify({
((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true;
((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; ((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH;
import { fetchQuotaForProvider } from './quotaProviders'; import { fetchHyperQuota, fetchQuotaForProvider } from './quotaProviders';
type MockResponseInit = { ok?: boolean; status?: number }; type MockResponseInit = { ok?: boolean; status?: number };
@@ -85,6 +85,13 @@ const stubFetchFailing = (json: () => Promise<unknown>, init: MockResponseInit):
globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch; 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)', () => { describe('OpenCode Go quota provider (VS Code parity)', () => {
test('uses the opencode-go key from auth.json', async () => { test('uses the opencode-go key from auth.json', async () => {
let request: RequestInit | undefined; let request: RequestInit | undefined;
@@ -717,85 +724,128 @@ describe('DeepSeek quota provider (VS Code parity)', () => {
}); });
describe('Charm Hyper quota provider (VS Code parity)', () => { describe('Charm Hyper quota provider (VS Code parity)', () => {
beforeEach(() => { const readAuth = () => ({ hyper: { key: 'test-token' } });
const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string };
fsMock.existsSync = () => true;
fsMock.readFileSync = () => AUTH;
});
test('builds credits and credits_balance windows from documented payload (numeric balance)', async () => { for (const { balance, credits, dollars } of [
stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 100 }))); { 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); for (const [index, auth] of [
assert.equal(result.providerId, 'hyper'); { 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!; for (const [index, readInvalidAuth] of [
assert.equal(balanceWindow.valueLabel, '$5.00'); () => ({}),
assert.equal(balanceWindow.usedPercent, null); () => ({ hyper: { key: '' } }),
assert.equal(balanceWindow.windowSeconds, null); () => ({ hyper: { key: ' ' } }),
assert.equal(balanceWindow.resetAt, null); () => ({ 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!; for (const { status, error } of [
assert.equal(creditsWindow.valueLabel, '100 credits'); { status: 401, error: 'Session expired — please re-authenticate with Charm Hyper' },
assert.equal(creditsWindow.usedPercent, null); { status: 403, error: 'Session expired — please re-authenticate with Charm Hyper' },
assert.equal(creditsWindow.windowSeconds, null); { status: 429, error: 'API error: 429' },
assert.equal(creditsWindow.resetAt, null); { status: 500, error: 'API error: 500' },
}); ]) {
test(`reports HTTP ${status} as a failure`, async () => {
test('tolerates a string balance', async () => { const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) });
stubFetchReturning(() => Promise.resolve(mockResponse({ balance: '50' }))); assert.equal(result.ok, false);
assert.equal(result.configured, true);
const result = await fetchQuotaForProvider('hyper'); assert.equal(result.error, error);
assert.equal(result.usage, null);
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');
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.ok, false);
assert.equal(result.configured, true); assert.equal(result.configured, true);
assert.equal(result.error, 'No quota data in response');
assert.equal(result.usage, null); assert.equal(result.usage, null);
}); });
test('keeps a literal zero balance as a valid valueLabel', async () => { for (const { failure, message } of [
stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 0 }))); { failure: new DOMException('Timed out', 'TimeoutError'), message: 'Request timed out' },
{ failure: new Error('Network unavailable'), message: 'Network unavailable' },
const result = await fetchQuotaForProvider('hyper'); ]) {
test(`reports ${message}`, async () => {
assert.equal(result.ok, true); const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => { throw failure; } });
assert.equal(result.usage!.windows.credits!.valueLabel, '0 credits'); assert.equal(result.error, message);
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00'); assert.equal(result.ok, false);
}); assert.equal(result.configured, true);
assert.equal(result.usage, null);
test('teardown: restore fs', () => { });
const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown }; }
fsMock.existsSync = ORIGINAL_FS.existsSync;
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
});
}); });
+19 -17
View File
@@ -155,10 +155,6 @@ type DeepseekPayload = {
}>; }>;
}; };
type HyperPayload = {
balance?: number | string;
};
type NeuralwattPayload = { type NeuralwattPayload = {
balance?: { balance?: {
credits_remaining_usd?: number | string; credits_remaining_usd?: number | string;
@@ -857,8 +853,7 @@ export const listConfiguredQuotaProviders = () => {
configured.add('deepseek'); configured.add('deepseek');
} }
const hyperAuth = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])); if (getHyperApiKey(auth)) {
if (hyperAuth && ((hyperAuth as Record<string, unknown>).key || (hyperAuth as Record<string, unknown>).token)) {
configured.add('hyper'); configured.add('hyper');
} }
@@ -2798,10 +2793,18 @@ const fetchDeepseekQuota = async (): Promise<ProviderResult> => {
const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits'; const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits';
const HYPER_CREDIT_TO_USD = 0.05; const HYPER_CREDIT_TO_USD = 0.05;
const fetchHyperQuota = async (): Promise<ProviderResult> => { const getHyperApiKey = (auth: AuthFile) => {
const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper']));
const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])) as Record<string, unknown> | null; return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); };
type HyperQuotaDependencies = {
readAuth?: () => AuthFile;
fetchImpl?: (url: string, options: RequestInit) => Promise<Response>;
};
export const fetchHyperQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: HyperQuotaDependencies = {}): Promise<ProviderResult> => {
const apiKey = getHyperApiKey(readAuth());
if (!apiKey) { if (!apiKey) {
return buildResult({ return buildResult({
@@ -2816,7 +2819,7 @@ const fetchHyperQuota = async (): Promise<ProviderResult> => {
const timeoutSignal = AbortSignal.timeout(15_000); const timeoutSignal = AbortSignal.timeout(15_000);
try { try {
const response = await fetch(HYPER_QUOTA_URL, { const response = await fetchImpl(HYPER_QUOTA_URL, {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
@@ -2837,11 +2840,10 @@ const fetchHyperQuota = async (): Promise<ProviderResult> => {
}); });
} }
const payload = await response.json() as HyperPayload; const payload = asObject(await response.json());
const rawBalance = payload?.balance; const rawBalance = payload?.balance;
const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) const balance = toNumber(asNonEmptyString(rawBalance)
? toNumber(rawBalance) ?? (Number.isFinite(rawBalance) ? rawBalance : null));
: null;
if (balance === null) { if (balance === null) {
return buildResult({ return buildResult({
@@ -2854,7 +2856,7 @@ const fetchHyperQuota = async (): Promise<ProviderResult> => {
} }
const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance); const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance);
const windows: Record<string, UsageWindow> = { const windows = {
credits_balance: toUsageWindow({ credits_balance: toUsageWindow({
usedPercent: null, usedPercent: null,
windowSeconds: null, windowSeconds: null,
@@ -2865,7 +2867,7 @@ const fetchHyperQuota = async (): Promise<ProviderResult> => {
usedPercent: null, usedPercent: null,
windowSeconds: null, windowSeconds: null,
resetAt: null, resetAt: null,
valueLabel: `${creditsLabel} credits`, valueLabel: creditsLabel,
}), }),
}; };
@@ -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. - **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. - **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 ## Kimi for Coding field semantics
`GET https://api.kimi.com/coding/v1/usages` is inconsistent about which field carries consumption: `GET https://api.kimi.com/coding/v1/usages` is inconsistent about which field carries consumption:
@@ -5,25 +5,26 @@ import {
buildResult, buildResult,
toUsageWindow, toUsageWindow,
toNumber, toNumber,
formatMoney formatMoney,
asObject,
asNonEmptyString
} from '../utils/index.js'; } from '../utils/index.js';
export const providerId = 'hyper'; export const providerId = 'hyper';
export const providerName = 'Charm Hyper'; export const providerName = 'Charm Hyper';
const aliases = ['hyper']; export const aliases = ['hyper'];
const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits'; const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits';
const CREDIT_TO_USD = 0.05; const CREDIT_TO_USD = 0.05;
export const isConfigured = () => { const getApiKey = (auth) => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
return Boolean(entry?.key || entry?.token); return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
}; };
export const fetchQuota = async () => { export const isConfigured = (auth = readAuthFile()) => Boolean(getApiKey(auth));
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); export const fetchQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch } = {}) => {
const apiKey = entry?.key ?? entry?.token; const apiKey = getApiKey(readAuth());
if (!apiKey) { if (!apiKey) {
return buildResult({ return buildResult({
@@ -38,7 +39,7 @@ export const fetchQuota = async () => {
const timeoutSignal = AbortSignal.timeout(15_000); const timeoutSignal = AbortSignal.timeout(15_000);
try { try {
const response = await fetch(HYPER_QUOTA_URL, { const response = await fetchImpl(HYPER_QUOTA_URL, {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: `Bearer ${apiKey}`, 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 rawBalance = payload?.balance;
const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) const balance = toNumber(asNonEmptyString(rawBalance)
? toNumber(rawBalance) ?? (Number.isFinite(rawBalance) ? rawBalance : null));
: null;
if (balance === null) { if (balance === null) {
return buildResult({ return buildResult({
@@ -87,7 +87,7 @@ export const fetchQuota = async () => {
usedPercent: null, usedPercent: null,
windowSeconds: null, windowSeconds: null,
resetAt: null, resetAt: null,
valueLabel: `${creditsLabel} credits` valueLabel: creditsLabel
}) })
}; };
@@ -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', () => ({ const readAuth = () => ({ hyper: { key: 'test-token' } });
readAuthFile: () => ({ hyper: { key: 'test-token' } }),
}));
import { fetchQuota } from './hyper.js'; // https://hyper.charm.land/docs/api/credits.html documents the balance payload.
// https://hyper.charm.land/faq defines one Hypercredit as $0.05.
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.
describe('Charm Hyper quota provider', () => { describe('Charm Hyper quota provider', () => {
it('builds credits and credits_balance windows from documented payload (numeric balance)', async () => { it.each([
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 100 }))); [100, '100', '$5.00'],
['50', '50', '$2.50'],
const result = await fetchQuota(); [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.ok).toBe(true);
expect(result.providerId).toBe('hyper'); expect(result.providerId).toBe('hyper');
expect(result.configured).toBe(true);
const balanceWindow = result.usage.windows.credits_balance; expect(result.usage.windows.credits.valueLabel).toBe(credits);
expect(balanceWindow).toBeDefined(); expect(result.usage.windows.credits_balance.valueLabel).toBe(dollars);
expect(balanceWindow.valueLabel).toBe('$5.00'); for (const window of Object.values(result.usage.windows)) {
expect(balanceWindow.usedPercent).toBeNull(); expect(window.usedPercent).toBeNull();
expect(balanceWindow.windowSeconds).toBeNull(); expect(window.remainingPercent).toBeNull();
expect(balanceWindow.resetAt).toBeNull(); expect(window.windowSeconds).toBeNull();
expect(window.resetAt).toBeNull();
const creditsWindow = result.usage.windows.credits; expect(window.resetAfterSeconds).toBeNull();
expect(creditsWindow).toBeDefined(); }
expect(creditsWindow.valueLabel).toBe('100 credits');
expect(creditsWindow.usedPercent).toBeNull();
expect(creditsWindow.windowSeconds).toBeNull();
expect(creditsWindow.resetAt).toBeNull();
}); });
it('tolerates a string balance', async () => { it.each([
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '50' }))); {}, 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.ok).toBe(true);
expect(result.usage.windows.credits.valueLabel).toBe('50 credits'); expect(JSON.stringify(result)).not.toContain('test-token');
expect(result.usage.windows.credits_balance.valueLabel).toBe('$2.50');
}); });
it('formats a fractional balance in both windows', async () => { it.each([{}, { hyper: { key: '' } }, { hyper: { key: ' ' } }, { hyper: { key: 42 } }])(
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 25.5 }))); '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); it.each([
expect(result.usage.windows.credits.valueLabel).toBe('25.50 credits'); [401, 'Session expired — please re-authenticate with Charm Hyper'],
expect(result.usage.windows.credits_balance.valueLabel).toBe('$1.28'); [403, 'Session expired — please re-authenticate with Charm Hyper'],
}); [429, 'API error: 429'],
[500, 'API error: 500'],
it('maps 401 to session-expired error', async () => { ])('reports HTTP %s as a failure', async (status, error) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })); const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) });
const result = await fetchQuota();
expect(result.ok).toBe(false); 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 () => { it('reports invalid JSON as a parse failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({}) })); const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response('{') });
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);
expect(result.error).toBe('Invalid response from provider'); 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.ok).toBe(false);
expect(result.configured).toBe(true); expect(result.configured).toBe(true);
expect(result.error).toBe('No quota data in response');
expect(result.usage).toBeNull(); expect(result.usage).toBeNull();
}); });
it('returns no-quota-data on an empty-string balance', async () => { it.each([
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '' }))); [new DOMException('Timed out', 'TimeoutError'), 'Request timed out'],
[new Error('Network unavailable'), 'Network unavailable'],
const result = await fetchQuota(); ])('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.ok).toBe(false);
expect(result.configured).toBe(true); expect(result.configured).toBe(true);
expect(result.error).toBe('No quota data in response');
expect(result.usage).toBeNull(); 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');
});
}); });