import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClient, ApiError } from '../../../src/core/http/ApiClient'; import { Endpoint, HttpMethod } from '../../../src/core/http/Endpoint'; interface GetParams { id: number; } class GetThingEndpoint extends Endpoint<{ name: string }, GetParams> { readonly method: HttpMethod = 'GET'; path(params: GetParams): string { return `/things/${params.id}`; } query(): Record { return { verbose: 'true' }; } parseResponse(json: unknown): { name: string } { return json as { name: string }; } } class PostThingEndpoint extends Endpoint { readonly method: HttpMethod = 'POST'; readonly acceptableStatuses = [400]; path(): string { return '/things'; } body(params: { name: string }): unknown { return params; } parseResponse(): void { return undefined; } } class TestClient extends ApiClient { constructor(baseUrl: string) { super(baseUrl); } protected buildHeaders(): Record { return { ...super.buildHeaders(), 'X-Test': '1' }; } getThing(id: number) { return this.request(new GetThingEndpoint(), { id }); } postThing(name: string) { return this.request(new PostThingEndpoint(), { name }); } } describe('ApiClient', () => { const fetchMock = vi.fn(); beforeEach(() => { vi.stubGlobal('fetch', fetchMock); fetchMock.mockReset(); }); afterEach(() => { vi.unstubAllGlobals(); }); it('builds the URL with query params and headers, and parses the response', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ name: 'widget' }), }); const client = new TestClient('https://api.example.com'); const result = await client.getThing(42); expect(result).toEqual({ name: 'widget' }); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('https://api.example.com/things/42?verbose=true'); expect(init.method).toBe('GET'); expect(init.headers['X-Test']).toBe('1'); }); it('sends a JSON body and Content-Type header for POST', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' }); const client = new TestClient('https://api.example.com'); await client.postThing('widget'); const [, init] = fetchMock.mock.calls[0]; expect(init.body).toBe(JSON.stringify({ name: 'widget' })); expect(init.headers['Content-Type']).toBe('application/json'); }); it('treats an endpoint-declared acceptable status as success', async () => { fetchMock.mockResolvedValue({ ok: false, status: 400, text: async () => '' }); const client = new TestClient('https://api.example.com'); await expect(client.postThing('widget')).resolves.toBeUndefined(); }); it('throws ApiError for a non-ok, non-acceptable status', async () => { fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' }); const client = new TestClient('https://api.example.com'); await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError); }); });