feat: POST /simulations — quota check + insert productions — 65/65 tests

This commit is contained in:
Hermann_Kitio 2026-04-16 14:13:47 +03:00
parent 2fba6f2003
commit bf2c48b2c7
5 changed files with 361 additions and 0 deletions

View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run:*)"
]
}
}

View file

@ -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<Record<string, unknown>> = {}) {
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<typeof buildProfile>) {
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')
})
})

View file

@ -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 }
}

View file

@ -3,6 +3,7 @@ import { Hono } from 'hono'
import { serve } from '@hono/node-server' import { serve } from '@hono/node-server'
import authRoutes from './routes/auth' import authRoutes from './routes/auth'
import plansRoutes from './routes/plans' import plansRoutes from './routes/plans'
import simulationsRoutes from './routes/simulations'
const app = new Hono() const app = new Hono()
@ -12,6 +13,7 @@ app.get('/', (c) => {
app.route('/auth', authRoutes) app.route('/auth', authRoutes)
app.route('/plans', plansRoutes) app.route('/plans', plansRoutes)
app.route('/simulations', simulationsRoutes)
const port = Number(process.env.PORT) || 3000 const port = Number(process.env.PORT) || 3000

81
src/routes/simulations.ts Normal file
View file

@ -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