feat(ui): client API typé (list, CRUD, random, thèmes) avec gestion d'erreurs
This commit is contained in:
1 parent
de00a827ed
commit
405ce6e603
2 files changed
+152
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
}
|
||||
Reference in new issue
Block a user