diff --git a/src/lib/api-client.test.ts b/src/lib/api-client.test.ts new file mode 100644 index 0000000..a64a5eb --- /dev/null +++ b/src/lib/api-client.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiClientError, cardsApi } from './api-client' + +const CARD = { + id: '10000000-0000-4000-8000-000000000001', + question: 'Q ?', + response: 'R', + theme: 'git', + tags: ['a'], + createdAt: '2026-01-01T08:00:00.000Z', + updatedAt: '2026-01-01T08:00:00.000Z', +} + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +const fetchMock = vi.fn() + +beforeEach(() => { + global.fetch = fetchMock as unknown as typeof fetch + fetchMock.mockReset() +}) + +function expectRequest(method: string, path: string) { + expect(fetchMock).toHaveBeenCalledWith( + path, + expect.objectContaining({ method }), + ) +} + +describe('cardsApi', () => { + it('list() appelle GET /api/cards', async () => { + fetchMock.mockResolvedValue(jsonResponse([CARD])) + await expect(cardsApi.list()).resolves.toEqual([CARD]) + expectRequest('GET', '/api/cards') + }) + + it('get() appelle GET /api/cards/:id', async () => { + fetchMock.mockResolvedValue(jsonResponse(CARD)) + await expect(cardsApi.get(CARD.id)).resolves.toEqual(CARD) + expectRequest('GET', `/api/cards/${CARD.id}`) + }) + + it('create() envoie POST avec un corps JSON', async () => { + fetchMock.mockResolvedValue(jsonResponse(CARD, 201)) + await expect(cardsApi.create({ question: 'Q ?', response: 'R', theme: 'git' } as never)).resolves.toEqual(CARD) + const [_, init] = fetchMock.mock.calls[0] + expect(init.method).toBe('POST') + expect(JSON.parse(init.body)).toEqual({ question: 'Q ?', response: 'R', theme: 'git' }) + }) + + it('update() envoie PUT /api/cards/:id', async () => { + fetchMock.mockResolvedValue(jsonResponse(CARD)) + await expect(cardsApi.update(CARD.id, { question: 'Q ?' } as never)).resolves.toEqual(CARD) + expectRequest('PUT', `/api/cards/${CARD.id}`) + }) + + it('remove() envoie DELETE et ignore le corps', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + await expect(cardsApi.remove(CARD.id)).resolves.toBeUndefined() + expectRequest('DELETE', `/api/cards/${CARD.id}`) + }) + + it('random() sans thème et avec thème (URL-encodé)', async () => { + fetchMock.mockResolvedValue(jsonResponse(CARD)) + await cardsApi.random() + expectRequest('GET', '/api/cards/random') + await cardsApi.random('git ops') + expectRequest('GET', '/api/cards/random?theme=git%20ops') + }) + + it('themes() appelle GET /api/themes', async () => { + fetchMock.mockResolvedValue(jsonResponse(['git', 'docker'])) + await expect(cardsApi.themes()).resolves.toEqual(['git', 'docker']) + expectRequest('GET', '/api/themes') + }) + + it('lève ApiClientError avec le message de l API sur une erreur 404', async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: 'NotFoundError', message: 'Carte inconnue' }, 404)) + await expect(cardsApi.get('nope')).rejects.toMatchObject({ + name: 'NotFoundError', + message: 'Carte inconnue', + status: 404, + }) + }) + + it('lève ApiClientError sur une réponse invalide', async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: 'ValidationError', message: 'Payload invalide' }, 400)) + await expect(cardsApi.create({} as never)).rejects.toBeInstanceOf(ApiClientError) + }) + + it('lève ApiClientError NetworkError quand fetch échoue', async () => { + fetchMock.mockRejectedValue(new TypeError('network')) + await expect(cardsApi.list()).rejects.toMatchObject({ name: 'NetworkError', status: 0 }) + }) +}) + +describe('ApiClientError', () => { + it('porte status, name et message', () => { + const err = new ApiClientError(404, 'NotFoundError', 'inconnu') + expect(err.status).toBe(404) + expect(err.name).toBe('NotFoundError') + expect(err.message).toBe('inconnu') + }) +}) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..4d16270 --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,47 @@ +import type { Card, CardInput } from '@/domain/card' + +export class ApiClientError extends Error { + status: number + constructor(status: number, error: string, message: string) { + super(message) + this.name = error + this.status = status + } +} + +interface ApiErrorPayload { + error?: string + message?: string +} + +async function request(path: string, init?: RequestInit): Promise { + let res: Response + try { + res = await fetch(path, { + method: init?.method ?? 'GET', + ...init, + headers: { 'Content-Type': 'application/json', ...init?.headers }, + }) + } catch { + throw new ApiClientError(0, 'NetworkError', 'Impossible de joindre le serveur') + } + if (res.status === 204) return undefined as T + const body: T | ApiErrorPayload = await res.json().catch(() => ({})) + if (!res.ok) { + const payload = body as ApiErrorPayload + throw new ApiClientError(res.status, payload.error ?? 'HttpError', payload.message ?? `Erreur ${res.status}`) + } + return body as T +} + +export const cardsApi = { + list: () => request('/api/cards'), + get: (id: string) => request(`/api/cards/${id}`), + create: (input: CardInput) => request('/api/cards', { method: 'POST', body: JSON.stringify(input) }), + update: (id: string, input: CardInput) => + request(`/api/cards/${id}`, { method: 'PUT', body: JSON.stringify(input) }), + remove: (id: string) => request(`/api/cards/${id}`, { method: 'DELETE' }), + random: (theme?: string) => + request(`/api/cards/random${theme ? `?theme=${encodeURIComponent(theme)}` : ''}`), + themes: () => request('/api/themes'), +}