feat(ui): client API typé (list, CRUD, random, thèmes) avec gestion d'erreurs

This commit is contained in:
devcodetools committed 2026-09-21 18:35:28 +02:00
1 parent de00a827ed
commit 405ce6e603
2 files changed
+152

No files matched your search

+105
View File
@@ -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')
})
})
+47
View File
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<Card[]>('/api/cards'),
get: (id: string) => request<Card>(`/api/cards/${id}`),
create: (input: CardInput) => request<Card>('/api/cards', { method: 'POST', body: JSON.stringify(input) }),
update: (id: string, input: CardInput) =>
request<Card>(`/api/cards/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
remove: (id: string) => request<void>(`/api/cards/${id}`, { method: 'DELETE' }),
random: (theme?: string) =>
request<Card>(`/api/cards/random${theme ? `?theme=${encodeURIComponent(theme)}` : ''}`),
themes: () => request<string[]>('/api/themes'),
}