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