From 1b8aac89f977915522750ec61416378a8bedfeb5 Mon Sep 17 00:00:00 2001 From: gilles Date: Mon, 21 Sep 2026 18:22:06 +0200 Subject: [PATCH] =?UTF-8?q?test(api):=20couvrir=20les=20routes=20CRUD,=20r?= =?UTF-8?q?andom,=20th=C3=A8mes=20et=20les=20helpers=20HTTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute les tests des routes /api/cards, /api/cards/[id], /api/cards/random et /api/cards/themes, ainsi que les tests des helpers http (handleError, parseJsonBody) et de la factory getCardService. Corrige le formatage des fichiers sources embarqués non formatés par les commits précédents. --- src/app/api/cards/[id]/route.test.ts | 99 ++++++++++++++++++++ src/app/api/cards/[id]/route.ts | 2 +- src/app/api/cards/random/route.test.ts | 53 +++++++++++ src/app/api/cards/random/route.ts | 2 +- src/app/api/cards/route.test.ts | 105 ++++++++++++++++++++++ src/app/api/cards/route.ts | 2 +- src/app/api/themes/route.test.ts | 29 ++++++ src/app/api/themes/route.ts | 2 +- src/domain/card-schemas.test.ts | 6 +- src/domain/card-schemas.ts | 2 +- src/domain/errors.ts | 2 +- src/lib/http.test.ts | 49 ++++++++++ src/lib/http.ts | 12 ++- src/services/card-service-factory.test.ts | 10 +++ src/services/card-service.test.ts | 2 +- src/services/card-service.ts | 6 +- 16 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 src/app/api/cards/[id]/route.test.ts create mode 100644 src/app/api/cards/random/route.test.ts create mode 100644 src/app/api/cards/route.test.ts create mode 100644 src/app/api/themes/route.test.ts create mode 100644 src/lib/http.test.ts create mode 100644 src/services/card-service-factory.test.ts diff --git a/src/app/api/cards/[id]/route.test.ts b/src/app/api/cards/[id]/route.test.ts new file mode 100644 index 0000000..3ab5b34 --- /dev/null +++ b/src/app/api/cards/[id]/route.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' +import type { Card } from '@/domain/card' +import { NotFoundError } from '@/domain/errors' +import { getCardService } from '@/services/card-service-factory' +import { DELETE, GET, PUT } from './route' + +vi.mock('@/services/card-service-factory', () => ({ + getCardService: vi.fn(), +})) + +const CARD: Card = { + id: '10000000-0000-4000-8000-000000000001', + question: 'Question ?', + response: 'Réponse', + theme: 'javascript', + tags: ['scope'], + createdAt: new Date('2026-01-01T08:00:00Z'), + updatedAt: new Date('2026-01-01T08:00:00Z'), +} + +const CARD_JSON = JSON.parse(JSON.stringify(CARD)) + +const context = { params: Promise.resolve({ id: CARD.id }) } + +function jsonRequest(body: unknown): NextRequest { + return new NextRequest(`http://localhost/api/cards/${CARD.id}`, { + method: 'PUT', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) +} + +function serviceWith(methods: Record) { + vi.mocked(getCardService).mockReturnValue(methods as never) +} + +describe('GET /api/cards/[id]', () => { + beforeEach(() => { + vi.mocked(getCardService).mockReset() + }) + + it('renvoie la carte demandée', async () => { + serviceWith({ get: vi.fn().mockResolvedValue(CARD) }) + const res = await GET(new NextRequest('http://localhost/api/cards'), context) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(CARD_JSON) + }) + + it('renvoie 404 si la carte est inconnue', async () => { + serviceWith({ get: vi.fn().mockRejectedValue(new NotFoundError('Carte x introuvable')) }) + const res = await GET(new NextRequest('http://localhost/api/cards'), context) + expect(res.status).toBe(404) + }) +}) + +describe('PUT /api/cards/[id]', () => { + beforeEach(() => { + vi.mocked(getCardService).mockReset() + }) + + it('met à jour la carte', async () => { + serviceWith({ update: vi.fn().mockResolvedValue({ ...CARD, question: 'Nouvelle' }) }) + const res = await PUT(jsonRequest({ question: 'Nouvelle' }), context) + expect(res.status).toBe(200) + expect((await res.json()).question).toBe('Nouvelle') + }) + + it('renvoie 400 si le payload est invalide', async () => { + serviceWith({ update: vi.fn() }) + const res = await PUT(jsonRequest({ theme: '' }), context) + expect(res.status).toBe(400) + }) + + it('renvoie 404 si la carte est inconnue', async () => { + serviceWith({ update: vi.fn().mockRejectedValue(new NotFoundError('Carte x introuvable')) }) + const res = await PUT(jsonRequest({ question: 'Nouvelle' }), context) + expect(res.status).toBe(404) + }) +}) + +describe('DELETE /api/cards/[id]', () => { + beforeEach(() => { + vi.mocked(getCardService).mockReset() + }) + + it('supprime la carte et renvoie 204', async () => { + serviceWith({ remove: vi.fn().mockResolvedValue(undefined) }) + const res = await DELETE(new NextRequest('http://localhost/api/cards'), context) + expect(res.status).toBe(204) + }) + + it('renvoie 404 si la carte est inconnue', async () => { + serviceWith({ remove: vi.fn().mockRejectedValue(new NotFoundError('Carte x introuvable')) }) + const res = await DELETE(new NextRequest('http://localhost/api/cards'), context) + expect(res.status).toBe(404) + }) +}) diff --git a/src/app/api/cards/[id]/route.ts b/src/app/api/cards/[id]/route.ts index c4a9a65..5ea3894 100644 --- a/src/app/api/cards/[id]/route.ts +++ b/src/app/api/cards/[id]/route.ts @@ -31,4 +31,4 @@ export async function DELETE(_request: NextRequest, context: RouteContext) { } catch (error) { return handleError(error) } -} \ No newline at end of file +} diff --git a/src/app/api/cards/random/route.test.ts b/src/app/api/cards/random/route.test.ts new file mode 100644 index 0000000..dae94e9 --- /dev/null +++ b/src/app/api/cards/random/route.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' +import type { Card } from '@/domain/card' +import { getCardService } from '@/services/card-service-factory' +import { GET } from './route' + +vi.mock('@/services/card-service-factory', () => ({ + getCardService: vi.fn(), +})) + +const CARD: Card = { + id: '10000000-0000-4000-8000-000000000001', + question: 'Question ?', + response: 'Réponse', + theme: 'javascript', + tags: ['scope'], + createdAt: new Date('2026-01-01T08:00:00Z'), + updatedAt: new Date('2026-01-01T08:00:00Z'), +} + +const CARD_JSON = JSON.parse(JSON.stringify(CARD)) + +describe('GET /api/cards/random', () => { + const service = vi.mocked(getCardService) + + beforeEach(() => { + service.mockReset() + }) + + it('tire une carte au hasard sans thème', async () => { + const serviceMock = { random: vi.fn().mockResolvedValue(CARD) } + service.mockReturnValue(serviceMock as never) + const res = await GET(new NextRequest('http://localhost/api/cards/random')) + expect(res.status).toBe(200) + expect(serviceMock.random).toHaveBeenCalledWith(undefined) + expect(await res.json()).toEqual(CARD_JSON) + }) + + it('tire une carte au hasard pour un thème donné', async () => { + const serviceMock = { random: vi.fn().mockResolvedValue(CARD) } + service.mockReturnValue(serviceMock as never) + const res = await GET(new NextRequest('http://localhost/api/cards/random?theme=javascript')) + expect(res.status).toBe(200) + expect(serviceMock.random).toHaveBeenCalledWith('javascript') + }) + + it('propage les erreurs du service', async () => { + service.mockReturnValue({ random: vi.fn().mockRejectedValue(new Error('boom')) } as never) + const res = await GET(new NextRequest('http://localhost/api/cards/random')) + expect(res.status).toBe(500) + }) +}) diff --git a/src/app/api/cards/random/route.ts b/src/app/api/cards/random/route.ts index 32989ef..925d82f 100644 --- a/src/app/api/cards/random/route.ts +++ b/src/app/api/cards/random/route.ts @@ -10,4 +10,4 @@ export async function GET(request: NextRequest) { } catch (error) { return handleError(error) } -} \ No newline at end of file +} diff --git a/src/app/api/cards/route.test.ts b/src/app/api/cards/route.test.ts new file mode 100644 index 0000000..41fbf88 --- /dev/null +++ b/src/app/api/cards/route.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' +import type { Card } from '@/domain/card' +import { NotFoundError } from '@/domain/errors' +import { getCardService } from '@/services/card-service-factory' +import { GET, POST } from './route' + +vi.mock('@/services/card-service-factory', () => ({ + getCardService: vi.fn(), +})) + +const CARD: Card = { + id: '10000000-0000-4000-8000-000000000001', + question: 'Question ?', + response: 'Réponse', + theme: 'javascript', + tags: ['scope'], + createdAt: new Date('2026-01-01T08:00:00Z'), + updatedAt: new Date('2026-01-01T08:00:00Z'), +} + +const CARD_JSON = JSON.parse(JSON.stringify(CARD)) +const CARD_JSON_LIST = [CARD_JSON] + +describe('GET /api/cards', () => { + const service = vi.mocked(getCardService) + + beforeEach(() => { + service.mockReset() + }) + + it('renvoie la liste des cartes', async () => { + const serviceMock = { list: vi.fn().mockResolvedValue([CARD]) } + service.mockReturnValue(serviceMock as never) + const res = await GET() + expect(res.status).toBe(200) + expect(await res.json()).toEqual(CARD_JSON_LIST) + }) + + it('propage les erreurs du service', async () => { + const serviceMock = { list: vi.fn().mockRejectedValue(new Error('boom')) } + service.mockReturnValue(serviceMock as never) + const res = await GET() + expect(res.status).toBe(500) + }) +}) + +describe('POST /api/cards', () => { + const service = vi.mocked(getCardService) + + beforeEach(() => { + service.mockReset() + }) + + it('crée une carte et renvoie 201', async () => { + const serviceMock = { create: vi.fn().mockResolvedValue(CARD) } + service.mockReturnValue(serviceMock as never) + const request = new NextRequest('http://localhost/api/cards', { + method: 'POST', + body: JSON.stringify({ question: 'Q', response: 'R', theme: 'git', tags: ['a'] }), + headers: { 'Content-Type': 'application/json' }, + }) + const res = await POST(request) + expect(res.status).toBe(201) + expect(await res.json()).toEqual(CARD_JSON) + expect(serviceMock.create).toHaveBeenCalledWith({ + question: 'Q', + response: 'R', + theme: 'git', + tags: ['a'], + }) + }) + + it('renvoie 400 si le payload est invalide', async () => { + service.mockReturnValue({ create: vi.fn() } as never) + const request = new NextRequest('http://localhost/api/cards', { + method: 'POST', + body: JSON.stringify({ question: '' }), + headers: { 'Content-Type': 'application/json' }, + }) + const res = await POST(request) + expect(res.status).toBe(400) + expect(service).not.toHaveBeenCalled() + }) + + it('renvoie 400 si le corps n est pas du JSON', async () => { + service.mockReturnValue({ create: vi.fn() } as never) + const request = new NextRequest('http://localhost/api/cards', { method: 'POST', body: '{bad' }) + const res = await POST(request) + expect(res.status).toBe(400) + }) + + it('propage une erreur inattendue en 500', async () => { + const serviceMock = { create: vi.fn().mockRejectedValue(new NotFoundError('x')) } + service.mockReturnValue(serviceMock as never) + const request = new NextRequest('http://localhost/api/cards', { + method: 'POST', + body: JSON.stringify({ question: 'Q', response: 'R', theme: 'git' }), + headers: { 'Content-Type': 'application/json' }, + }) + const res = await POST(request) + expect(res.status).toBe(404) + }) +}) diff --git a/src/app/api/cards/route.ts b/src/app/api/cards/route.ts index bfcd33f..ec8502d 100644 --- a/src/app/api/cards/route.ts +++ b/src/app/api/cards/route.ts @@ -20,4 +20,4 @@ export async function POST(request: NextRequest) { } catch (error) { return handleError(error) } -} \ No newline at end of file +} diff --git a/src/app/api/themes/route.test.ts b/src/app/api/themes/route.test.ts new file mode 100644 index 0000000..e8bb529 --- /dev/null +++ b/src/app/api/themes/route.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getCardService } from '@/services/card-service-factory' +import { GET } from './route' + +vi.mock('@/services/card-service-factory', () => ({ + getCardService: vi.fn(), +})) + +describe('GET /api/themes', () => { + const service = vi.mocked(getCardService) + + beforeEach(() => { + service.mockReset() + }) + + it('renvoie la liste des thèmes', async () => { + service.mockReturnValue({ themes: vi.fn().mockResolvedValue(['docker', 'git']) } as never) + const res = await GET() + expect(res.status).toBe(200) + expect(await res.json()).toEqual(['docker', 'git']) + }) + + it('propage les erreurs du service', async () => { + service.mockReturnValue({ themes: vi.fn().mockRejectedValue(new Error('boom')) } as never) + const res = await GET() + expect(res.status).toBe(500) + }) +}) diff --git a/src/app/api/themes/route.ts b/src/app/api/themes/route.ts index c5a339f..b93dc1b 100644 --- a/src/app/api/themes/route.ts +++ b/src/app/api/themes/route.ts @@ -9,4 +9,4 @@ export async function GET() { } catch (error) { return handleError(error) } -} \ No newline at end of file +} diff --git a/src/domain/card-schemas.test.ts b/src/domain/card-schemas.test.ts index 7be3590..bb42b0d 100644 --- a/src/domain/card-schemas.test.ts +++ b/src/domain/card-schemas.test.ts @@ -15,7 +15,9 @@ describe('createCardSchema', () => { }) it('accepte une entrée sans tags', () => { - expect(createCardSchema.parse({ question: 'Q', response: 'R', theme: 'git' }).tags).toBeUndefined() + expect( + createCardSchema.parse({ question: 'Q', response: 'R', theme: 'git' }).tags, + ).toBeUndefined() }) it('rejette une question vide', () => { @@ -36,4 +38,4 @@ describe('updateCardSchema', () => { it('rejette un champ vide lors d une mise à jour', () => { expect(() => updateCardSchema.parse({ theme: '' })).toThrow() }) -}) \ No newline at end of file +}) diff --git a/src/domain/card-schemas.ts b/src/domain/card-schemas.ts index a52c7ed..477a520 100644 --- a/src/domain/card-schemas.ts +++ b/src/domain/card-schemas.ts @@ -7,4 +7,4 @@ export const createCardSchema = z.object({ tags: z.array(z.string().trim().min(1, 'Tag vide')).max(50, 'Trop de tags').optional(), }) -export const updateCardSchema = createCardSchema.partial() \ No newline at end of file +export const updateCardSchema = createCardSchema.partial() diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 05e7cbc..27e059c 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -18,4 +18,4 @@ export class ValidationError extends AppError { constructor(message: string) { super(400, message) } -} \ No newline at end of file +} diff --git a/src/lib/http.test.ts b/src/lib/http.test.ts new file mode 100644 index 0000000..00aa0b3 --- /dev/null +++ b/src/lib/http.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' +import { ZodError } from 'zod' +import { NotFoundError, ValidationError } from '@/domain/errors' +import { handleError, parseJsonBody } from './http' + +describe('handleError', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('renvoie 400 pour une erreur de validation zod', () => { + const res = handleError(new ZodError([])) + expect(res.status).toBe(400) + }) + + it('renvoie le statut de l erreur applicative (404)', () => { + const res = handleError(new NotFoundError('Carte x introuvable')) + expect(res.status).toBe(404) + }) + + it('renvoie 400 pour ValidationError', () => { + const res = handleError(new ValidationError('Payload invalide')) + expect(res.status).toBe(400) + }) + + it('renvoie 500 pour une erreur inattendue', async () => { + const res = handleError(new Error('boom')) + expect(res.status).toBe(500) + expect(await res.json()).toHaveProperty('error', 'InternalServerError') + }) +}) + +describe('parseJsonBody', () => { + it('renvoie le corps JSON parsé', async () => { + const request = new NextRequest('http://localhost/api/cards', { + method: 'POST', + body: JSON.stringify({ question: 'Q' }), + headers: { 'Content-Type': 'application/json' }, + }) + await expect(parseJsonBody(request)).resolves.toEqual({ question: 'Q' }) + }) + + it('lève ValidationError si le corps n est pas du JSON', async () => { + const request = new NextRequest('http://localhost/api/cards', { method: 'POST', body: '{bad' }) + await expect(parseJsonBody(request)).rejects.toThrow(ValidationError) + }) +}) diff --git a/src/lib/http.ts b/src/lib/http.ts index bb0f077..677f565 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -10,10 +10,16 @@ export function handleError(error: unknown): NextResponse { ) } if (error instanceof AppError) { - return NextResponse.json({ error: error.name, message: error.message }, { status: error.status }) + return NextResponse.json( + { error: error.name, message: error.message }, + { status: error.status }, + ) } console.error(error) - return NextResponse.json({ error: 'InternalServerError', message: 'Erreur interne' }, { status: 500 }) + return NextResponse.json( + { error: 'InternalServerError', message: 'Erreur interne' }, + { status: 500 }, + ) } export async function parseJsonBody(request: NextRequest): Promise { @@ -22,4 +28,4 @@ export async function parseJsonBody(request: NextRequest): Promise { } catch { throw new ValidationError('Corps de requête invalide (JSON attendu)') } -} \ No newline at end of file +} diff --git a/src/services/card-service-factory.test.ts b/src/services/card-service-factory.test.ts new file mode 100644 index 0000000..fb5037d --- /dev/null +++ b/src/services/card-service-factory.test.ts @@ -0,0 +1,10 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { CardService } from './card-service' +import { getCardService } from './card-service-factory' + +describe('getCardService', () => { + it('instancie un CardService branché sur le repository actif', () => { + expect(getCardService()).toBeInstanceOf(CardService) + }) +}) diff --git a/src/services/card-service.test.ts b/src/services/card-service.test.ts index 0e1062b..1cd14ab 100644 --- a/src/services/card-service.test.ts +++ b/src/services/card-service.test.ts @@ -112,4 +112,4 @@ describe('CardService', () => { repository.findThemes.mockResolvedValueOnce(['docker', 'git']) await expect(service.themes()).resolves.toEqual(['docker', 'git']) }) -}) \ No newline at end of file +}) diff --git a/src/services/card-service.ts b/src/services/card-service.ts index 4c966bf..b3fedeb 100644 --- a/src/services/card-service.ts +++ b/src/services/card-service.ts @@ -41,7 +41,9 @@ export class CardService { ? await this.repository.findRandomByTheme(theme) : await this.repository.findRandom() if (!card) { - throw new NotFoundError(theme ? `Aucune carte pour le thème ${theme}` : 'Aucune carte disponible') + throw new NotFoundError( + theme ? `Aucune carte pour le thème ${theme}` : 'Aucune carte disponible', + ) } return card } @@ -49,4 +51,4 @@ export class CardService { async themes(): Promise { return this.repository.findThemes() } -} \ No newline at end of file +}