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:
@@ -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<unknown>, 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>).key || (hyperAuth as Record<string, unknown>).token)) {
|
||||
if (getHyperApiKey(auth)) {
|
||||
configured.add('hyper');
|
||||
}
|
||||
|
||||
@@ -2798,10 +2793,18 @@ const fetchDeepseekQuota = async (): Promise<ProviderResult> => {
|
||||
const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits';
|
||||
const HYPER_CREDIT_TO_USD = 0.05;
|
||||
|
||||
const fetchHyperQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])) as Record<string, unknown> | 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<Response>;
|
||||
};
|
||||
|
||||
export const fetchHyperQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: HyperQuotaDependencies = {}): Promise<ProviderResult> => {
|
||||
const apiKey = getHyperApiKey(readAuth());
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
@@ -2816,7 +2819,7 @@ const fetchHyperQuota = async (): Promise<ProviderResult> => {
|
||||
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<ProviderResult> => {
|
||||
});
|
||||
}
|
||||
|
||||
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<ProviderResult> => {
|
||||
}
|
||||
|
||||
const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance);
|
||||
const windows: Record<string, UsageWindow> = {
|
||||
const windows = {
|
||||
credits_balance: toUsageWindow({
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
@@ -2865,7 +2867,7 @@ const fetchHyperQuota = async (): Promise<ProviderResult> => {
|
||||
usedPercent: null,
|
||||
windowSeconds: 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.
|
||||
- **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:
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user