From a394ce84295541f34384f03e5889c863d0841d45 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Wed, 22 Apr 2026 20:54:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(simulations):=20GET=20/simulations=20?= =?UTF-8?q?=E2=80=94=20liste=20pagin=C3=A9e=20des=20productions=20(Sprint?= =?UTF-8?q?=203.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/simulationController.test.ts | 244 ++++++++++++++++++ src/controllers/simulationController.ts | 72 ++++++ src/routes/simulations.ts | 41 +++ 3 files changed, 357 insertions(+) diff --git a/src/controllers/__tests__/simulationController.test.ts b/src/controllers/__tests__/simulationController.test.ts index ebb38d1..a4739e3 100644 --- a/src/controllers/__tests__/simulationController.test.ts +++ b/src/controllers/__tests__/simulationController.test.ts @@ -725,3 +725,247 @@ describe('PATCH /simulations/:id/sujet — FTD-21 changement de sujet', () => { expect(body.code).toBe('VALIDATION_ERROR') }) }) + +// ─── GET /simulations (Sprint 3.7) ──────────────────────────────────────────── + +/** + * Mock du chain Supabase pour `list` : + * from('productions').select(cols, {count:'exact'}).eq(...).order(...).range(...) + * retourne { data, error, count }. + */ +function mockProductionsList(params: { + data: unknown[] + count: number | null + error?: unknown +}) { + const { data, count, error = null } = params + const rangeFn = vi.fn(() => ({ data, error, count })) + const orderFn = vi.fn(() => ({ range: rangeFn })) + const eqFn = vi.fn(() => ({ order: orderFn })) + const selectFn = vi.fn(() => ({ eq: eqFn })) + vi.mocked(supabase.from).mockReturnValueOnce({ select: selectFn } as any) + return { selectFn, eqFn, orderFn, rangeFn } +} + +describe('GET /simulations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('liste vide → 200 avec data=[] et total=0', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionsList({ data: [], count: 0 }) + + const app = createApp() + const res = await app.request('/simulations', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toEqual([]) + expect(body.pagination).toEqual({ page: 1, limit: 20, total: 0 }) + }) + + it('liste avec items : renvoie les 3 items projetés aux champs autorisés', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionsList({ + data: [ + { + id: 'p1', + tache: 'EE_T1', + mode: 'entrainement', + score: 14, + nclc: 9, + nclc_cible: 9, + created_at: '2026-04-22T12:00:00Z', + }, + { + id: 'p2', + tache: 'EE_T2', + mode: 'examen', + score: 16, + nclc: 10, + nclc_cible: 10, + created_at: '2026-04-22T11:00:00Z', + }, + { + id: 'p3', + tache: 'EE_T3', + mode: 'entrainement', + score: null, + nclc: null, + nclc_cible: null, + created_at: '2026-04-22T10:00:00Z', + }, + ], + count: 3, + }) + + const app = createApp() + const res = await app.request('/simulations', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toHaveLength(3) + expect(body.pagination.total).toBe(3) + expect(body.data[0]).toEqual({ + id: 'p1', + tache: 'EE_T1', + mode: 'entrainement', + score: 14, + nclc: 9, + nclc_cible: 9, + created_at: '2026-04-22T12:00:00Z', + }) + // Champs exclus : contenu, rapport, exercices, modele, etc. — pas de fuite + expect(body.data[0]).not.toHaveProperty('contenu') + expect(body.data[0]).not.toHaveProperty('rapport') + expect(body.data[0]).not.toHaveProperty('exercices') + expect(body.data[0]).not.toHaveProperty('modele') + }) + + it('pagination par défaut : page=1, limit=20 → range(0, 19)', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + const mocks = mockProductionsList({ data: [], count: 0 }) + + const app = createApp() + await app.request('/simulations', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(mocks.rangeFn).toHaveBeenCalledWith(0, 19) + }) + + it('?page=2&limit=10 → range(10, 19), pagination reflète les params', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + const mocks = mockProductionsList({ data: [], count: 42 }) + + const app = createApp() + const res = await app.request('/simulations?page=2&limit=10', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(mocks.rangeFn).toHaveBeenCalledWith(10, 19) + expect(body.pagination).toEqual({ page: 2, limit: 10, total: 42 }) + }) + + it('tri `created_at DESC` appliqué côté Supabase', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + const mocks = mockProductionsList({ data: [], count: 0 }) + + const app = createApp() + await app.request('/simulations', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(mocks.orderFn).toHaveBeenCalledWith('created_at', { ascending: false }) + }) + + it('filtre `user_id = profile.id` appliqué', async () => { + const profile = buildProfile({ id: 'user-XYZ' }) + mockAuth(profile) + const mocks = mockProductionsList({ data: [], count: 0 }) + + const app = createApp() + await app.request('/simulations', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(mocks.eqFn).toHaveBeenCalledWith('user_id', 'user-XYZ') + }) + + it('select projette uniquement les champs autorisés + count exact', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + const mocks = mockProductionsList({ data: [], count: 0 }) + + const app = createApp() + await app.request('/simulations', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(mocks.selectFn).toHaveBeenCalledWith( + 'id, tache, mode, score, nclc, nclc_cible, created_at', + { count: 'exact' }, + ) + }) + + it('?limit=100 (>50) → 400 VALIDATION_ERROR', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + + const app = createApp() + const res = await app.request('/simulations?limit=100', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('VALIDATION_ERROR') + expect(body.message).toMatch(/limit/i) + }) + + it('?page=abc (non-numérique) → 400 VALIDATION_ERROR', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + + const app = createApp() + const res = await app.request('/simulations?page=abc', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('VALIDATION_ERROR') + expect(body.message).toMatch(/page/i) + }) + + it('?page=0 → 400 VALIDATION_ERROR', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + + const app = createApp() + const res = await app.request('/simulations?page=0', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('VALIDATION_ERROR') + }) + + it('sans JWT → 401 AUTH_REQUIRED', async () => { + // Pas de mockAuth — le middleware refuse. + const app = createApp() + const res = await app.request('/simulations') + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('AUTH_REQUIRED') + }) + + it('erreur Supabase sur la requête → 500 INTERNAL_ERROR', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionsList({ data: [], count: null, error: { message: 'db down' } }) + + const app = createApp() + const res = await app.request('/simulations', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(500) + const body = await res.json() + expect(body.code).toBe('INTERNAL_ERROR') + }) +}) diff --git a/src/controllers/simulationController.ts b/src/controllers/simulationController.ts index d1a5a29..cd5e1cc 100644 --- a/src/controllers/simulationController.ts +++ b/src/controllers/simulationController.ts @@ -131,6 +131,78 @@ export async function create( } } +// Sprint 3.7 — liste paginée des productions de l'utilisateur connecté. +// Renvoie uniquement les champs utiles à l'affichage en liste (pas de contenu, +// rapport, exercices, modele — trop lourds). + +export interface ListOptions { + page: number + limit: number +} + +export interface ListItem { + id: string + tache: Tache + mode: Mode + score: number | null + nclc: number | null + nclc_cible: 9 | 10 | null + created_at: string +} + +export interface ListResult { + data: ListItem[] + pagination: { + page: number + limit: number + total: number + } +} + +type ListError = ControllerError + +export async function list( + options: ListOptions, + profile: AuthProfile, +): Promise<{ data: ListResult } | ListError> { + const { page, limit } = options + const offset = (page - 1) * limit + + const { data, error, count } = await supabase + .from('productions') + .select('id, tache, mode, score, nclc, nclc_cible, created_at', { count: 'exact' }) + .eq('user_id', profile.id) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1) + + if (error) { + return { + error: true, + code: 'INTERNAL_ERROR', + message: 'Impossible de charger les simulations.', + status: 500, + } + } + + const items: ListItem[] = (data ?? []).map((row) => ({ + id: row.id as string, + tache: row.tache as Tache, + mode: row.mode as Mode, + score: (row.score as number | null) ?? null, + nclc: (row.nclc as number | null) ?? null, + nclc_cible: + row.nclc_cible === 9 || row.nclc_cible === 10 ? (row.nclc_cible as 9 | 10) : null, + created_at: row.created_at as string, + })) + + return { + data: { + data: items, + pagination: { page, limit, total: count ?? 0 }, + }, + } +} + // Sprint 3.6a — structure enrichie (revelation, diagnostic, conseil_nclc, // erreurs_codes) + statuts des jobs asynchrones (modele, exercices). // diff --git a/src/routes/simulations.ts b/src/routes/simulations.ts index a26a547..630ee3e 100644 --- a/src/routes/simulations.ts +++ b/src/routes/simulations.ts @@ -151,6 +151,47 @@ simulations.patch('/:id/sujet', authMiddleware, async (c) => { return c.json(result.data, 200) }) +// Sprint 3.7 — liste paginée des productions de l'utilisateur connecté. +// `GET /` est distinct de `GET /:id` côté routeur Hono (match par (méthode, chemin)). +simulations.get('/', authMiddleware, async (c) => { + const rawPage = c.req.query('page') + const rawLimit = c.req.query('limit') + + const page = rawPage === undefined ? 1 : Number(rawPage) + const limit = rawLimit === undefined ? 20 : Number(rawLimit) + + if (!Number.isInteger(page) || page < 1) { + return c.json( + { + error: true, + code: 'VALIDATION_ERROR', + message: '`page` doit être un entier supérieur ou égal à 1.', + }, + 400 + ) + } + + if (!Number.isInteger(limit) || limit < 1 || limit > 50) { + return c.json( + { + error: true, + code: 'VALIDATION_ERROR', + message: '`limit` doit être un entier entre 1 et 50.', + }, + 400 + ) + } + + const profile = c.get('profile') + const result = await simulationController.list({ page, limit }, profile) + + if ('error' in result) { + return c.json(result, result.status as 500) + } + + return c.json(result.data, 200) +}) + // GET /:id en dernier — route catch-all par id, ne doit pas masquer les routes plus spécifiques. simulations.get('/:id', authMiddleware, async (c) => { // `:id` est garanti présent par le pattern de route Hono