diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..47b3190 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run:*)" + ] + } +} diff --git a/src/controllers/__tests__/simulationController.test.ts b/src/controllers/__tests__/simulationController.test.ts new file mode 100644 index 0000000..5d27b45 --- /dev/null +++ b/src/controllers/__tests__/simulationController.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Hono } from 'hono' +import type { AppVariables } from '../../middleware/auth' + +vi.mock('../../lib/supabase', () => ({ + supabase: { + auth: { getUser: vi.fn() }, + from: vi.fn(), + }, +})) + +import { supabase } from '../../lib/supabase' +import simulationsRoutes from '../../routes/simulations' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function buildProfile(overrides: Partial> = {}) { + return { + id: 'user-123', + email: 'test@example.com', + plan: 'free', + simulations_used: 0, + stripe_customer_id: null, + stripe_subscription_id: null, + plan_expires_at: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...overrides, + } +} + +/** Mock authMiddleware : getUser + from('profiles').select */ +function mockAuth(profile: ReturnType) { + vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({ + data: { user: { id: profile.id, email: profile.email } as any }, + error: null, + }) + vi.mocked(supabase.from).mockReturnValueOnce({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn(() => ({ data: profile, error: null })), + })), + })), + } as any) +} + +/** Mock from('productions').insert(...).select(...).single() */ +function mockInsert(returnData: { id: string; tache: string; mode: string; created_at: string }) { + vi.mocked(supabase.from).mockReturnValueOnce({ + insert: vi.fn(() => ({ + select: vi.fn(() => ({ + single: vi.fn(() => ({ data: returnData, error: null })), + })), + })), + } as any) +} + +/** Mock from('profiles').update(...).eq(...) */ +function mockUpdate() { + vi.mocked(supabase.from).mockReturnValueOnce({ + update: vi.fn(() => ({ + eq: vi.fn(() => ({ data: null, error: null })), + })), + } as any) +} + +function createApp() { + const app = new Hono<{ Variables: AppVariables }>() + app.route('/simulations', simulationsRoutes) + return app +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('POST /simulations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('free + 4 simulations utilisées → création OK, simulations_used incrémenté', async () => { + 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' }) + mockUpdate() + + 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() + 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') + }) + + it('free + 5 simulations utilisées → QUOTA_REACHED', async () => { + const profile = buildProfile({ plan: 'free', simulations_used: 5 }) + mockAuth(profile) + + 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(403) + const body = await res.json() + expect(body.error).toBe(true) + expect(body.code).toBe('QUOTA_REACHED') + // Aucun insert : seul l'appel from de l'auth middleware + expect(vi.mocked(supabase.from).mock.calls).toHaveLength(1) + }) + + it('standard + 999 simulations → création OK, simulations_used NON incrémenté', async () => { + 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' }) + // Pas de mockUpdate : standard n'a pas de limite + + const app = createApp() + const res = await app.request('/simulations', { + method: 'POST', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ tache: 'EE_T2', mode: 'entrainement' }), + }) + + 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) + }) + + it('premium + EO_T2_LIVE → création OK', 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 mockUpdate : premium n'a pas de limite + + const app = createApp() + const res = await app.request('/simulations', { + method: 'POST', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ tache: 'EO_T2_LIVE', mode: 'entrainement' }), + }) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.id).toBe('prod-3') + expect(body.tache).toBe('EO_T2_LIVE') + expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2) + }) + + it('tache invalide → erreur validation 400', async () => { + const profile = buildProfile({ plan: 'free', simulations_used: 0 }) + mockAuth(profile) + + const app = createApp() + const res = await app.request('/simulations', { + method: 'POST', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ tache: 'TACHE_INVALIDE', mode: 'entrainement' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toBe(true) + expect(body.code).toBe('VALIDATION_ERROR') + }) + + it('mode invalide → erreur validation 400', async () => { + const profile = buildProfile({ plan: 'free', simulations_used: 0 }) + mockAuth(profile) + + 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: 'MODE_INVALIDE' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toBe(true) + expect(body.code).toBe('VALIDATION_ERROR') + }) +}) diff --git a/src/controllers/simulationController.ts b/src/controllers/simulationController.ts new file mode 100644 index 0000000..de1cbeb --- /dev/null +++ b/src/controllers/simulationController.ts @@ -0,0 +1,76 @@ +import { supabase } from '../lib/supabase' +import { canUserSimulate, getPlanPermissions } from '../lib/access' +import type { Plan } from '../lib/access' +import type { AuthProfile } from '../middleware/auth' + +export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3' | 'EO_T2_LIVE' +export type Mode = 'entrainement' | 'examen' + +export interface CreateBody { + tache: Tache + mode: Mode + contenu?: string +} + +export interface CreateResult { + id: string + tache: Tache + mode: Mode + created_at: string +} + +type CreateError = { + error: true + code: string + message: string + status: number +} + +export async function create( + body: CreateBody, + profile: AuthProfile +): Promise<{ data: CreateResult } | CreateError> { + // 1. Vérifier le quota via canUserSimulate (lib/access.ts) + const check = canUserSimulate({ plan: profile.plan, simulations_used: profile.simulations_used }) + if (!check.allowed) { + return { + error: true, + code: 'QUOTA_REACHED', + message: + 'Vous avez utilisé vos 5 simulations gratuites. Passez en Standard pour continuer votre préparation.', + status: 403, + } + } + + // 2. Insérer dans productions + const { data, error } = await supabase + .from('productions') + .insert({ + user_id: profile.id, + tache: body.tache, + mode: body.mode, + contenu: body.contenu ?? null, + }) + .select('id, tache, mode, created_at') + .single() + + if (error || !data) { + return { + error: true, + code: 'INTERNAL_ERROR', + message: 'Une erreur est survenue. Veuillez réessayer dans quelques instants.', + status: 500, + } + } + + // 3. 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 + .from('profiles') + .update({ simulations_used: profile.simulations_used + 1 }) + .eq('id', profile.id) + } + + return { data: data as CreateResult } +} diff --git a/src/index.ts b/src/index.ts index cba25b9..cdf640d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono' import { serve } from '@hono/node-server' import authRoutes from './routes/auth' import plansRoutes from './routes/plans' +import simulationsRoutes from './routes/simulations' const app = new Hono() @@ -12,6 +13,7 @@ app.get('/', (c) => { app.route('/auth', authRoutes) app.route('/plans', plansRoutes) +app.route('/simulations', simulationsRoutes) const port = Number(process.env.PORT) || 3000 diff --git a/src/routes/simulations.ts b/src/routes/simulations.ts new file mode 100644 index 0000000..82a583d --- /dev/null +++ b/src/routes/simulations.ts @@ -0,0 +1,81 @@ +import { Hono } from 'hono' +import { authMiddleware } from '../middleware/auth' +import type { AppVariables } from '../middleware/auth' +import { getPlanPermissions } from '../lib/access' +import type { Plan } from '../lib/access' +import * as simulationController from '../controllers/simulationController' +import type { Tache, Mode } from '../controllers/simulationController' + +const VALID_TACHES: Tache[] = ['EE_T1', 'EE_T2', 'EE_T3', 'EO_T1', 'EO_T3', 'EO_T2_LIVE'] +const VALID_MODES: Mode[] = ['entrainement', 'examen'] + +const simulations = new Hono<{ Variables: AppVariables }>() + +simulations.post('/', authMiddleware, async (c) => { + let body: { tache?: unknown; mode?: unknown; contenu?: unknown } + try { + body = await c.req.json() + } catch { + return c.json( + { error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' }, + 400 + ) + } + + // Valider tache + if (!body.tache || !VALID_TACHES.includes(body.tache as Tache)) { + return c.json( + { + error: true, + code: 'VALIDATION_ERROR', + message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES.join(', ')}`, + }, + 400 + ) + } + + // Valider mode + if (!body.mode || !VALID_MODES.includes(body.mode as Mode)) { + return c.json( + { + error: true, + code: 'VALIDATION_ERROR', + message: `Mode invalide. Valeurs acceptées : ${VALID_MODES.join(', ')}`, + }, + 400 + ) + } + + const tache = body.tache as Tache + const mode = body.mode as Mode + + // Vérifier l'accès EO_T2_LIVE via getPlanPermissions (Règle D) + if (tache === 'EO_T2_LIVE') { + const profile = c.get('profile') + const perms = getPlanPermissions(profile.plan as Plan) + if (!perms.oral_t2_live) { + return c.json( + { + error: true, + code: 'PLAN_INSUFFICIENT', + message: 'La tâche EO T2 live est réservée au plan Premium.', + }, + 403 + ) + } + } + + const profile = c.get('profile') + const result = await simulationController.create( + { tache, mode, contenu: body.contenu as string | undefined }, + profile + ) + + if ('error' in result) { + return c.json(result, result.status as 403 | 500) + } + + return c.json(result.data, 201) +}) + +export default simulations