test(api): couvrir les routes CRUD, random, thèmes et les helpers HTTP

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.
This commit is contained in:
devcodetools committed 2026-09-21 18:22:06 +02:00
1 parent 19a5a47b28
commit 1b8aac89f9
16 files changed
+369 -14

No files matched your search

+99
View File
@@ -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<string, unknown>) {
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)
})
})
+1 -1
View File
@@ -31,4 +31,4 @@ export async function DELETE(_request: NextRequest, context: RouteContext) {
} catch (error) {
return handleError(error)
}
}
}
+53
View File
@@ -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)
})
})
+1 -1
View File
@@ -10,4 +10,4 @@ export async function GET(request: NextRequest) {
} catch (error) {
return handleError(error)
}
}
}
+105
View File
@@ -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)
})
})
+1 -1
View File
@@ -20,4 +20,4 @@ export async function POST(request: NextRequest) {
} catch (error) {
return handleError(error)
}
}
}
+29
View File
@@ -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)
})
})
+1 -1
View File
@@ -9,4 +9,4 @@ export async function GET() {
} catch (error) {
return handleError(error)
}
}
}
+4 -2
View File
@@ -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()
})
})
})
+1 -1
View File
@@ -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()
export const updateCardSchema = createCardSchema.partial()
+1 -1
View File
@@ -18,4 +18,4 @@ export class ValidationError extends AppError {
constructor(message: string) {
super(400, message)
}
}
}
+49
View File
@@ -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)
})
})
+9 -3
View File
@@ -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<unknown> {
@@ -22,4 +28,4 @@ export async function parseJsonBody(request: NextRequest): Promise<unknown> {
} catch {
throw new ValidationError('Corps de requête invalide (JSON attendu)')
}
}
}
+10
View File
@@ -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)
})
})
+1 -1
View File
@@ -112,4 +112,4 @@ describe('CardService', () => {
repository.findThemes.mockResolvedValueOnce(['docker', 'git'])
await expect(service.themes()).resolves.toEqual(['docker', 'git'])
})
})
})
+4 -2
View File
@@ -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<string[]> {
return this.repository.findThemes()
}
}
}