diff --git a/src/controllers/__tests__/simulationController.test.ts b/src/controllers/__tests__/simulationController.test.ts index 5531af0..1d5b99d 100644 --- a/src/controllers/__tests__/simulationController.test.ts +++ b/src/controllers/__tests__/simulationController.test.ts @@ -75,6 +75,41 @@ function mockProductionSelect(data: unknown, error: unknown = null) { } as any) } +/** Mock from('sujets').select(...).eq(...).eq(...).eq(...) pour create */ +function mockSujets(rows: unknown[]) { + vi.mocked(supabase.from).mockReturnValueOnce({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + eq: vi.fn(() => ({ + eq: vi.fn(() => ({ data: rows, error: null })), + })), + })), + })), + } as any) +} + +const MOCK_SUJET_EE_T1 = { + id: 'sujet-1', + consigne: 'Écrivez un texte argumentatif sur le télétravail.', + role: 'journaliste', + contexte: 'magazine francophone', + doc1_titre: null, + doc1_texte: null, + doc2_titre: null, + doc2_texte: null, +} + +const MOCK_SUJET_EE_T2 = { + id: 'sujet-2', + consigne: 'Rédigez un article sur la transition énergétique.', + role: 'chroniqueur', + contexte: 'rubrique environnement', + doc1_titre: 'Doc 1', + doc1_texte: 'Contenu 1', + doc2_titre: null, + doc2_texte: null, +} + function createApp() { const app = new Hono<{ Variables: AppVariables }>() app.route('/simulations', simulationsRoutes) @@ -92,6 +127,7 @@ describe('POST /simulations', () => { const profile = buildProfile({ plan: 'free', simulations_used: 4 }) mockAuth(profile) mockInsert({ id: 'prod-1', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) + mockSujets([MOCK_SUJET_EE_T1]) mockUpdate() const app = createApp() @@ -106,9 +142,11 @@ describe('POST /simulations', () => { expect(body.id).toBe('prod-1') expect(body.tache).toBe('EE_T1') expect(body.mode).toBe('entrainement') - // 3 appels from : profiles (auth) + productions (insert) + profiles (update) - expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3) - expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('profiles') + expect(body.sujet).toEqual(MOCK_SUJET_EE_T1) + // 4 appels from : profiles (auth) + productions (insert) + sujets (select) + profiles (update) + expect(vi.mocked(supabase.from).mock.calls).toHaveLength(4) + expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets') + expect(vi.mocked(supabase.from).mock.calls[3][0]).toBe('profiles') }) it('free + 5 simulations utilisées → QUOTA_REACHED', async () => { @@ -134,6 +172,7 @@ describe('POST /simulations', () => { const profile = buildProfile({ plan: 'standard', simulations_used: 999 }) mockAuth(profile) mockInsert({ id: 'prod-2', tache: 'EE_T2', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) + mockSujets([MOCK_SUJET_EE_T2]) // Pas de mockUpdate : standard n'a pas de limite const app = createApp() @@ -146,14 +185,17 @@ describe('POST /simulations', () => { expect(res.status).toBe(201) const body = await res.json() expect(body.id).toBe('prod-2') - // 2 appels from : profiles (auth) + productions (insert) — pas de update - expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2) + expect(body.sujet).toEqual(MOCK_SUJET_EE_T2) + // 3 appels from : profiles (auth) + productions (insert) + sujets (select) — pas de update + expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3) + expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets') }) - it('premium + EO_T2_LIVE → création OK', async () => { + it('premium + EO_T2_LIVE → création OK, sujet: null (pas de lookup)', async () => { const profile = buildProfile({ plan: 'premium', simulations_used: 0 }) mockAuth(profile) mockInsert({ id: 'prod-3', tache: 'EO_T2_LIVE', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) + // Pas de mockSujets : EO_T2_LIVE skip la query sujets // Pas de mockUpdate : premium n'a pas de limite const app = createApp() @@ -167,6 +209,8 @@ describe('POST /simulations', () => { const body = await res.json() expect(body.id).toBe('prod-3') expect(body.tache).toBe('EO_T2_LIVE') + expect(body.sujet).toBeNull() + // 2 appels from : profiles (auth) + productions (insert) — pas de sujets, pas de update expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2) }) @@ -203,6 +247,51 @@ describe('POST /simulations', () => { expect(body.error).toBe(true) expect(body.code).toBe('VALIDATION_ERROR') }) + + it('aucun sujet actif trouvé → création OK avec sujet: null (non bloquant)', async () => { + const profile = buildProfile({ plan: 'standard', simulations_used: 10 }) + mockAuth(profile) + mockInsert({ id: 'prod-4', tache: 'EE_T3', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) + mockSujets([]) // table vide pour ce filtre + + const app = createApp() + const res = await app.request('/simulations', { + method: 'POST', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ tache: 'EE_T3', mode: 'entrainement' }), + }) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.id).toBe('prod-4') + expect(body.sujet).toBeNull() + expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3) + expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets') + }) + + it('pick aléatoire parmi plusieurs sujets actifs', async () => { + const profile = buildProfile({ plan: 'standard', simulations_used: 10 }) + const candidates = [ + { ...MOCK_SUJET_EE_T1, id: 'sujet-a' }, + { ...MOCK_SUJET_EE_T1, id: 'sujet-b' }, + { ...MOCK_SUJET_EE_T1, id: 'sujet-c' }, + ] + mockAuth(profile) + mockInsert({ id: 'prod-5', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) + mockSujets(candidates) + + const app = createApp() + const res = await app.request('/simulations', { + method: 'POST', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ tache: 'EE_T1', mode: 'entrainement' }), + }) + + expect(res.status).toBe(201) + const body = await res.json() + const pickedIds = candidates.map((s) => s.id) + expect(pickedIds).toContain(body.sujet.id) + }) }) describe('GET /simulations/:id', () => { diff --git a/src/controllers/simulationController.ts b/src/controllers/simulationController.ts index 9d0dcc7..42f6fd3 100644 --- a/src/controllers/simulationController.ts +++ b/src/controllers/simulationController.ts @@ -13,11 +13,23 @@ export interface CreateBody { contenu?: string } +export interface SujetData { + id: string + consigne: string + role: string | null + contexte: string | null + doc1_titre: string | null + doc1_texte: string | null + doc2_titre: string | null + doc2_texte: string | null +} + export interface CreateResult { id: string tache: Tache mode: Mode created_at: string + sujet: SujetData | null } type CreateError = { @@ -27,6 +39,27 @@ type CreateError = { status: number } +// Mappe une Tache frontend vers les filtres de la table sujets. +// Retourne null pour EO_T2_LIVE (interaction live, pas de sujet pré-défini). +function mapTacheToSujetParams( + tache: Tache +): { mode: 'EE' | 'EO'; tacheNumber: number } | null { + switch (tache) { + case 'EE_T1': + return { mode: 'EE', tacheNumber: 1 } + case 'EE_T2': + return { mode: 'EE', tacheNumber: 2 } + case 'EE_T3': + return { mode: 'EE', tacheNumber: 3 } + case 'EO_T1': + return { mode: 'EO', tacheNumber: 1 } + case 'EO_T3': + return { mode: 'EO', tacheNumber: 3 } + case 'EO_T2_LIVE': + return null + } +} + export async function create( body: CreateBody, profile: AuthProfile @@ -64,7 +97,24 @@ export async function create( } } - // 3. Incrémenter simulations_used si le plan a une limite (via access.ts — Règle D) + // 3. Fetch un sujet aléatoire (non bloquant — sujet: null si introuvable). + // TODO: migrer vers une RPC PostgreSQL si la table sujets dépasse quelques centaines de lignes. + const sujetParams = mapTacheToSujetParams(body.tache) + let sujet: SujetData | null = null + if (sujetParams) { + const { data: sujets, error: sujetError } = await supabase + .from('sujets') + .select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte') + .eq('mode', sujetParams.mode) + .eq('tache', sujetParams.tacheNumber) + .eq('actif', true) + + if (!sujetError && sujets && sujets.length > 0) { + sujet = sujets[Math.floor(Math.random() * sujets.length)] as SujetData + } + } + + // 4. Incrémenter simulations_used si le plan a une limite (via access.ts — Règle D) const perms = getPlanPermissions(profile.plan as Plan) if (perms.simulations_lifetime !== null) { await supabase @@ -73,7 +123,15 @@ export async function create( .eq('id', profile.id) } - return { data: data as CreateResult } + return { + data: { + id: data.id, + tache: data.tache as Tache, + mode: data.mode as Mode, + created_at: data.created_at, + sujet, + }, + } } // EERapport et EORapport ont la même structure depuis l'étape A —