diff --git a/src/api/client.test.ts b/src/api/client.test.ts index b367dff6..4b00fd01 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -324,3 +324,54 @@ describe('apiFetch rate limiting (defence-in-depth)', () => { }) }) }) + +describe('apiFetch logging', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('logs an error with path and status when a fetch fails with a non-2xx response', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await apiFetch('/bonds/missing').catch(() => {}) + + expect(errorSpy).toHaveBeenCalledTimes(1) + const line = errorSpy.mock.calls[0]?.[0] ?? '' + expect(line).toMatch(/event=api_fetch_failed/) + expect(line).toContain('path=/bonds/missing') + expect(line).toMatch(/status=404/) + expect(line).toMatch(/error=Not found/) + }) + + it('logs an error with status 0 when a network error occurs', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')) + vi.stubGlobal('fetch', fetchMock) + + await apiFetch('/bonds').catch(() => {}) + + expect(errorSpy).toHaveBeenCalledTimes(1) + const line = errorSpy.mock.calls[0]?.[0] ?? '' + expect(line).toMatch(/event=api_fetch_failed/) + expect(line).toContain('path=/bonds') + expect(line).toMatch(/status=0/) + expect(line).toMatch(/error=Failed to fetch/) + }) + + it('does not log when a fetch succeeds', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })) + vi.stubGlobal('fetch', fetchMock) + + await apiFetch('/health') + + expect(errorSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/api/client.ts b/src/api/client.ts index c9d9d6b6..d5a1c72f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,3 +1,4 @@ +import { logError } from '../lib/log' import { ApiRateLimiter, DEFAULT_API_RATE_LIMIT, readApiRateLimitOverrides } from './rateLimit' export interface ApiFetchOptions extends Omit { @@ -191,12 +192,18 @@ export async function apiFetch(path: string, options: ApiFetchOptions = {}): throw error } const message = error instanceof Error ? error.message : 'Network request failed' + logError('api_fetch_failed', { path, status: '0', error: message }) throw new ApiError(0, message, error) } const payload = await parseResponse(response) if (!response.ok) { + logError('api_fetch_failed', { + path, + status: String(response.status), + error: errorMessage(response.status, payload), + }) throw new ApiError(response.status, errorMessage(response.status, payload), payload) }