feat: POST /simulations — quota check + insert productions — 65/65 tests
This commit is contained in:
parent
2fba6f2003
commit
bf2c48b2c7
5 changed files with 361 additions and 0 deletions
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
195
src/controllers/__tests__/simulationController.test.ts
Normal file
195
src/controllers/__tests__/simulationController.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
76
src/controllers/simulationController.ts
Normal file
76
src/controllers/simulationController.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
81
src/routes/simulations.ts
Normal file
81
src/routes/simulations.ts
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue