2026-09-05 23:26:35 +03:00
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
import { fetchQuota, isConfigured } from './hyper.js';
|
2026-09-06 05:16:13 +09:00
|
|
|
|
2026-09-05 23:26:35 +03:00
|
|
|
const readAuth = () => ({ hyper: { key: 'test-token' } });
|
2026-09-06 05:16:13 +09:00
|
|
|
|
2026-09-05 23:26:35 +03:00
|
|
|
// https://hyper.charm.land/docs/api/credits.html documents the balance payload.
|
|
|
|
|
// https://hyper.charm.land/faq defines one Hypercredit as $0.05.
|
2026-09-06 05:16:13 +09:00
|
|
|
describe('Charm Hyper quota provider', () => {
|
2026-09-05 23:26:35 +03:00
|
|
|
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 }),
|
|
|
|
|
});
|
2026-09-06 05:16:13 +09:00
|
|
|
|
|
|
|
|
expect(result.ok).toBe(true);
|
|
|
|
|
expect(result.providerId).toBe('hyper');
|
2026-09-05 23:26:35 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-09-06 05:16:13 +09:00
|
|
|
});
|
|
|
|
|
|
2026-09-05 23:26:35 +03:00
|
|
|
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) });
|
2026-09-06 05:16:13 +09:00
|
|
|
|
2026-09-05 23:26:35 +03:00
|
|
|
expect(result.ok).toBe(false);
|
|
|
|
|
expect(result.configured).toBe(true);
|
|
|
|
|
expect(result.error).toBe('No quota data in response');
|
|
|
|
|
expect(result.usage).toBeNull();
|
2026-09-06 05:16:13 +09:00
|
|
|
});
|
|
|
|
|
|
2026-09-05 23:26:35 +03:00
|
|
|
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);
|
2026-09-06 05:16:13 +09:00
|
|
|
expect(result.ok).toBe(true);
|
2026-09-05 23:26:35 +03:00
|
|
|
expect(JSON.stringify(result)).not.toContain('test-token');
|
2026-09-06 05:16:13 +09:00
|
|
|
});
|
|
|
|
|
|
2026-09-05 23:26:35 +03:00
|
|
|
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 });
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(requests).toBe(0);
|
|
|
|
|
expect(result.ok).toBe(false);
|
|
|
|
|
expect(result.configured).toBe(false);
|
|
|
|
|
expect(result.error).toBe('Not configured');
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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 }) });
|
2026-09-06 05:16:13 +09:00
|
|
|
|
|
|
|
|
expect(result.ok).toBe(false);
|
2026-09-05 23:26:35 +03:00
|
|
|
expect(result.configured).toBe(true);
|
|
|
|
|
expect(result.error).toBe(error);
|
|
|
|
|
expect(result.usage).toBeNull();
|
2026-09-06 05:16:13 +09:00
|
|
|
});
|
|
|
|
|
|
2026-09-05 23:26:35 +03:00
|
|
|
it('reports invalid JSON as a parse failure', async () => {
|
|
|
|
|
const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response('{') });
|
2026-09-06 05:16:13 +09:00
|
|
|
expect(result.error).toBe('Invalid response from provider');
|
|
|
|
|
expect(result.ok).toBe(false);
|
|
|
|
|
expect(result.configured).toBe(true);
|
|
|
|
|
expect(result.usage).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 23:26:35 +03:00
|
|
|
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);
|
2026-09-06 05:16:13 +09:00
|
|
|
expect(result.ok).toBe(false);
|
|
|
|
|
expect(result.configured).toBe(true);
|
|
|
|
|
expect(result.usage).toBeNull();
|
|
|
|
|
});
|
2026-09-05 23:26:35 +03:00
|
|
|
});
|