diff --git a/frontend/src/__tests__/api-apikeys.test.ts b/frontend/src/__tests__/api-apikeys.test.ts index e54471c1d..ef38a9a89 100644 --- a/frontend/src/__tests__/api-apikeys.test.ts +++ b/frontend/src/__tests__/api-apikeys.test.ts @@ -5,6 +5,7 @@ import { fetchMock } from './setup'; import { getApiKeys, + getApiKeysUsageStats, createApiKey, revokeApiKey, deleteApiKey @@ -56,6 +57,46 @@ describe('API Keys API Module', () => { }); }); + describe('getApiKeysUsageStats', () => { + test('fetches section-level usage summary', async () => { + const mockResponse = { + total_active: 2, + total_requests_window: 42, + total_requests_lifetime: 1234, + top_keys: [ + { id: 'key-1', name: 'Busy', key_prefix: 'abc12345', request_count_window: 30 }, + ], + }; + + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await getApiKeysUsageStats(); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/api-keys/usage-stats', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Authorization': 'Bearer test-token', + }), + }) + ); + expect(result).toEqual(mockResponse); + }); + + test('throws error on API failure', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: 'Unauthorized' }), + }); + + await expect(getApiKeysUsageStats()).rejects.toThrow('Unauthorized'); + }); + }); + describe('createApiKey', () => { test('creates API key with name only', async () => { const mockResponse = { diff --git a/frontend/src/__tests__/apikeys-usage.test.ts b/frontend/src/__tests__/apikeys-usage.test.ts new file mode 100644 index 000000000..c6ff86d10 --- /dev/null +++ b/frontend/src/__tests__/apikeys-usage.test.ts @@ -0,0 +1,225 @@ +/** + * Tests for src/apikeys_usage.ts -- the API keys section summary card. + * + * The module had no direct coverage when it was introduced; these tests + * cover the render path, the count formatting boundaries, the error path, + * and the out-of-order-response guard. + */ +import { loadApiKeysUsageStats, renderApiKeysUsageSummary } from '../apikeys_usage'; +import type { APIKeysUsageStats } from '../api/types'; +import * as api from '../api'; + +jest.mock('../api'); + +const mockedApi = api as jest.Mocked; + +function setupContainer(): HTMLElement { + document.body.innerHTML = '
'; + return document.getElementById('apikeys-usage-summary') as HTMLElement; +} + +function stats(overrides: Partial = {}): APIKeysUsageStats { + return { + total_active: 2, + total_requests_window: 42, + total_requests_lifetime: 1234, + top_keys: [], + ...overrides, + }; +} + +function tileValues(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('.apikeys-usage-tile-value')).map( + el => el.textContent ?? '' + ); +} + +describe('renderApiKeysUsageSummary', () => { + test('renders the three summary tiles', () => { + const container = setupContainer(); + + renderApiKeysUsageSummary(stats()); + + expect(tileValues(container)).toEqual(['2', '42', '1.2k']); + }); + + // Regression: a lifetime total that excludes keys predating the counters is + // a lower bound. Rendering it bare would present an undercount as exact. + test('marks a partial lifetime total as a lower bound', () => { + const container = setupContainer(); + + renderApiKeysUsageSummary(stats({ lifetime_partial: true })); + + expect(tileValues(container)[2]).toBe('1.2k+'); + }); + + test('leaves a complete lifetime total unqualified', () => { + const container = setupContainer(); + + renderApiKeysUsageSummary(stats({ lifetime_partial: false })); + + expect(tileValues(container)[2]).toBe('1.2k'); + }); + + test('renders the top-keys list when keys have window activity', () => { + const container = setupContainer(); + + renderApiKeysUsageSummary( + stats({ + top_keys: [ + { id: 'key-1', name: 'Busy', key_prefix: 'aaaa1111', request_count_window: 30 }, + { id: 'key-2', name: 'Medium', key_prefix: 'bbbb2222', request_count_window: 12 }, + ], + }) + ); + + const items = container.querySelectorAll('.apikeys-usage-top-list li'); + expect(items).toHaveLength(2); + expect(items[0]?.querySelector('strong')?.textContent).toBe('Busy'); + expect(items[0]?.querySelector('code')?.textContent).toBe('aaaa1111...'); + expect(items[0]?.querySelector('.apikeys-usage-top-count')?.textContent).toBe('30 req'); + }); + + test('omits the top-keys section when there is no window activity', () => { + const container = setupContainer(); + + renderApiKeysUsageSummary(stats({ top_keys: [] })); + + expect(container.querySelector('.apikeys-usage-top-list')).toBeNull(); + expect(container.querySelector('.apikeys-usage-top-heading')).toBeNull(); + }); + + // The card is built with createElement/textContent rather than innerHTML, + // so a key name containing markup must land as literal text. + test('does not interpret markup in a key name as HTML', () => { + const container = setupContainer(); + + renderApiKeysUsageSummary( + stats({ + top_keys: [ + { + id: 'key-1', + name: '', + key_prefix: '