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