import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiError, MALFORMED_RESPONSE_CODE, NETWORK_ERROR_CODE, requestApi } from './client'; /** Baut eine Antwort, wie sie das Backend liefern würde. */ function buildJsonResponse(responseBody: unknown, statusCode: number): Response { return new Response(JSON.stringify(responseBody), { status: statusCode, headers: { 'Content-Type': 'application/json' }, }); } afterEach(() => { vi.unstubAllGlobals(); }); describe('requestApi', () => { it('gibt die Nutzlast aus der Standard-Hülle zurück', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( buildJsonResponse({ data: { status: 'healthy' }, meta: { request_id: 'abc' } }, 200), ), ); const loadedPayload = await requestApi<{ status: string }>('/health'); expect(loadedPayload).toEqual({ status: 'healthy' }); }); it('sendet eine Correlation ID mit', async () => { const fetchMock = vi .fn() .mockResolvedValue(buildJsonResponse({ data: {}, meta: { request_id: 'abc' } }, 200)); vi.stubGlobal('fetch', fetchMock); await requestApi('/health'); // Ohne Correlation ID ließe sich eine Operation nicht Ende-zu-Ende verfolgen. const firstCall = fetchMock.mock.calls[0]; expect(firstCall).toBeDefined(); const sentHeaders = (firstCall?.[1] as RequestInit).headers as Record; expect(sentHeaders['X-Correlation-ID']).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ); }); it('wandelt eine Fehlerantwort in einen ApiError um', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( buildJsonResponse( { error: { code: 'REPOSITORY_UNAVAILABLE', message: 'Das Repository ist nicht erreichbar.', request_id: 'req-42', }, }, 503, ), ), ); await expect(requestApi('/repositories')).rejects.toMatchObject({ code: 'REPOSITORY_UNAVAILABLE', statusCode: 503, requestId: 'req-42', }); }); it('meldet eine nicht erreichbare API verständlich', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); // Ein Netzwerkfehler darf nicht als leere Antwort durchgehen. await expect(requestApi('/health')).rejects.toMatchObject({ code: NETWORK_ERROR_CODE }); }); it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200))); // Eine Antwort außerhalb des Vertrags darf nicht stillschweigend // als leeres Ergebnis interpretiert werden (PROMPT.md §140). await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE }); }); it('lehnt eine Fehlerantwort ohne Fehlerkörper ab', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500))); await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE }); }); it('reicht einen Abbruch unverändert durch', async () => { vi.stubGlobal( 'fetch', vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')), ); // Ein Abbruch ist Folge des Aufräumens und kein Fehlerfall. await expect(requestApi('/health')).rejects.toSatisfy( (thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError), ); }); });