From 0680a6382fcee03235b18b1d519f02e8dfcf9c52 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Mon, 20 Apr 2026 04:41:24 +0300 Subject: [PATCH] =?UTF-8?q?feat(simulations):=20GET=20/simulations/:id=20?= =?UTF-8?q?=E2=80=94=20lecture=20rapport=20avec=20auth=20+=20REPORT=5FNOT?= =?UTF-8?q?=5FREADY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/simulationController.test.ts | 134 ++++++++++++++++++ src/controllers/simulationController.ts | 67 +++++++++ src/routes/simulations.ts | 14 ++ 3 files changed, 215 insertions(+) diff --git a/src/controllers/__tests__/simulationController.test.ts b/src/controllers/__tests__/simulationController.test.ts index 5d27b45..5531af0 100644 --- a/src/controllers/__tests__/simulationController.test.ts +++ b/src/controllers/__tests__/simulationController.test.ts @@ -64,6 +64,17 @@ function mockUpdate() { } as any) } +/** Mock from('productions').select(...).eq(...).single() pour getById */ +function mockProductionSelect(data: unknown, error: unknown = null) { + vi.mocked(supabase.from).mockReturnValueOnce({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn(() => ({ data, error })), + })), + })), + } as any) +} + function createApp() { const app = new Hono<{ Variables: AppVariables }>() app.route('/simulations', simulationsRoutes) @@ -193,3 +204,126 @@ describe('POST /simulations', () => { expect(body.code).toBe('VALIDATION_ERROR') }) }) + +describe('GET /simulations/:id', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const VALID_RAPPORT = { + score: 14, + nclc: 8, + feedback_court: 'Bonne production générale.', + criteres: [ + { nom: 'Coherence et cohesion', score: 4, commentaire: 'OK' }, + { nom: 'Lexique', score: 3, commentaire: 'OK' }, + { nom: 'Morphosyntaxe', score: 4, commentaire: 'OK' }, + { nom: 'Pertinence', score: 3, commentaire: 'OK' }, + ], + erreurs: ['erreur 1'], + modele: 'Texte modèle.', + idees: ['idée 1'], + exercices: ['exo 1'], + } + + it('OK : rapport trouvé et appartenant à l\'utilisateur → 200 avec simulation_id', async () => { + const profile = buildProfile({ id: 'user-123', plan: 'standard' }) + mockAuth(profile) + mockProductionSelect({ + id: 'prod-42', + user_id: 'user-123', + tache: 'EE_T1', + mode: 'entrainement', + score: 14, + nclc: 8, + rapport: JSON.stringify(VALID_RAPPORT), + created_at: '2024-01-01T00:00:00Z', + }) + + const app = createApp() + const res = await app.request('/simulations/prod-42', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.simulation_id).toBe('prod-42') + expect(body.tache).toBe('EE_T1') + expect(body.mode).toBe('entrainement') + expect(body.created_at).toBe('2024-01-01T00:00:00Z') + expect(body.score).toBe(14) + expect(body.nclc).toBe(8) + expect(body.feedback_court).toBe('Bonne production générale.') + expect(body.criteres).toHaveLength(4) + expect(body.erreurs).toEqual(['erreur 1']) + expect(body.modele).toBe('Texte modèle.') + expect(body.idees).toEqual(['idée 1']) + expect(body.exercices).toEqual(['exo 1']) + }) + + it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionSelect(null, { message: 'No rows returned' }) + + const app = createApp() + const res = await app.request('/simulations/does-not-exist', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBe(true) + expect(body.code).toBe('SIMULATION_NOT_FOUND') + }) + + it('AUTH_REQUIRED : user_id !== profile.id → 401', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionSelect({ + id: 'prod-42', + user_id: 'another-user-456', + tache: 'EE_T1', + mode: 'entrainement', + score: 14, + nclc: 8, + rapport: JSON.stringify(VALID_RAPPORT), + created_at: '2024-01-01T00:00:00Z', + }) + + const app = createApp() + const res = await app.request('/simulations/prod-42', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBe(true) + expect(body.code).toBe('AUTH_REQUIRED') + }) + + it('REPORT_NOT_READY : rapport === null → 404', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionSelect({ + id: 'prod-42', + user_id: 'user-123', + tache: 'EE_T1', + mode: 'entrainement', + score: null, + nclc: null, + rapport: null, + created_at: '2024-01-01T00:00:00Z', + }) + + const app = createApp() + const res = await app.request('/simulations/prod-42', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBe(true) + expect(body.code).toBe('REPORT_NOT_READY') + }) +}) diff --git a/src/controllers/simulationController.ts b/src/controllers/simulationController.ts index c7bcbe1..9d0dcc7 100644 --- a/src/controllers/simulationController.ts +++ b/src/controllers/simulationController.ts @@ -1,6 +1,7 @@ import { supabase } from '../lib/supabase.js' import { canUserSimulate, getPlanPermissions } from '../lib/access.js' import type { Plan } from '../lib/access.js' +import type { EERapport } from '../lib/deepseek.js' import type { AuthProfile } from '../middleware/auth.js' export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3' | 'EO_T2_LIVE' @@ -74,3 +75,69 @@ export async function create( return { data: data as CreateResult } } + +// EERapport et EORapport ont la même structure depuis l'étape A — +// on utilise EERapport comme représentation canonique du rapport parsé. +export type GetByIdResult = EERapport & { + simulation_id: string + tache: Tache + mode: Mode + created_at: string +} + +type GetByIdError = { + error: true + code: string + message: string + status: number +} + +export async function getById( + id: string, + profile: AuthProfile +): Promise<{ data: GetByIdResult } | GetByIdError> { + const { data, error } = await supabase + .from('productions') + .select('id, user_id, tache, mode, score, nclc, rapport, created_at') + .eq('id', id) + .single() + + if (error || !data) { + return { + error: true, + code: 'SIMULATION_NOT_FOUND', + message: 'Simulation introuvable.', + status: 404, + } + } + + if (data.user_id !== profile.id) { + return { + error: true, + code: 'AUTH_REQUIRED', + message: 'Cette simulation ne vous appartient pas.', + status: 401, + } + } + + if (data.rapport === null) { + return { + error: true, + code: 'REPORT_NOT_READY', + message: "Le rapport n'est pas encore disponible pour cette simulation.", + status: 404, + } + } + + const rapport = JSON.parse(data.rapport) as EERapport + + return { + data: { + ...rapport, + simulation_id: data.id, + tache: data.tache as Tache, + mode: data.mode as Mode, + created_at: data.created_at, + }, + } +} diff --git a/src/routes/simulations.ts b/src/routes/simulations.ts index b72da77..2202122 100644 --- a/src/routes/simulations.ts +++ b/src/routes/simulations.ts @@ -78,4 +78,18 @@ simulations.post('/', authMiddleware, async (c) => { return c.json(result.data, 201) }) +simulations.get('/:id', authMiddleware, async (c) => { + // `:id` est garanti présent par le pattern de route Hono + const id = c.req.param('id')! + const profile = c.get('profile') + + const result = await simulationController.getById(id, profile) + + if ('error' in result) { + return c.json(result, result.status as 401 | 404 | 500) + } + + return c.json(result.data, 200) +}) + export default simulations