feat(simulations): retourner un sujet aléatoire actif avec la production créée

This commit is contained in:
Hermann_Kitio 2026-04-20 06:01:02 +03:00
parent 0680a6382f
commit b6b8c76cc2
2 changed files with 155 additions and 8 deletions

View file

@ -75,6 +75,41 @@ function mockProductionSelect(data: unknown, error: unknown = null) {
} as any)
}
/** Mock from('sujets').select(...).eq(...).eq(...).eq(...) pour create */
function mockSujets(rows: unknown[]) {
vi.mocked(supabase.from).mockReturnValueOnce({
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({ data: rows, error: null })),
})),
})),
})),
} as any)
}
const MOCK_SUJET_EE_T1 = {
id: 'sujet-1',
consigne: 'Écrivez un texte argumentatif sur le télétravail.',
role: 'journaliste',
contexte: 'magazine francophone',
doc1_titre: null,
doc1_texte: null,
doc2_titre: null,
doc2_texte: null,
}
const MOCK_SUJET_EE_T2 = {
id: 'sujet-2',
consigne: 'Rédigez un article sur la transition énergétique.',
role: 'chroniqueur',
contexte: 'rubrique environnement',
doc1_titre: 'Doc 1',
doc1_texte: 'Contenu 1',
doc2_titre: null,
doc2_texte: null,
}
function createApp() {
const app = new Hono<{ Variables: AppVariables }>()
app.route('/simulations', simulationsRoutes)
@ -92,6 +127,7 @@ describe('POST /simulations', () => {
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' })
mockSujets([MOCK_SUJET_EE_T1])
mockUpdate()
const app = createApp()
@ -106,9 +142,11 @@ describe('POST /simulations', () => {
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')
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
// 4 appels from : profiles (auth) + productions (insert) + sujets (select) + profiles (update)
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(4)
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets')
expect(vi.mocked(supabase.from).mock.calls[3][0]).toBe('profiles')
})
it('free + 5 simulations utilisées → QUOTA_REACHED', async () => {
@ -134,6 +172,7 @@ describe('POST /simulations', () => {
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' })
mockSujets([MOCK_SUJET_EE_T2])
// Pas de mockUpdate : standard n'a pas de limite
const app = createApp()
@ -146,14 +185,17 @@ describe('POST /simulations', () => {
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)
expect(body.sujet).toEqual(MOCK_SUJET_EE_T2)
// 3 appels from : profiles (auth) + productions (insert) + sujets (select) — pas de update
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets')
})
it('premium + EO_T2_LIVE → création OK', async () => {
it('premium + EO_T2_LIVE → création OK, sujet: null (pas de lookup)', 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 mockSujets : EO_T2_LIVE skip la query sujets
// Pas de mockUpdate : premium n'a pas de limite
const app = createApp()
@ -167,6 +209,8 @@ describe('POST /simulations', () => {
const body = await res.json()
expect(body.id).toBe('prod-3')
expect(body.tache).toBe('EO_T2_LIVE')
expect(body.sujet).toBeNull()
// 2 appels from : profiles (auth) + productions (insert) — pas de sujets, pas de update
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2)
})
@ -203,6 +247,51 @@ describe('POST /simulations', () => {
expect(body.error).toBe(true)
expect(body.code).toBe('VALIDATION_ERROR')
})
it('aucun sujet actif trouvé → création OK avec sujet: null (non bloquant)', async () => {
const profile = buildProfile({ plan: 'standard', simulations_used: 10 })
mockAuth(profile)
mockInsert({ id: 'prod-4', tache: 'EE_T3', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
mockSujets([]) // table vide pour ce filtre
const app = createApp()
const res = await app.request('/simulations', {
method: 'POST',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ tache: 'EE_T3', mode: 'entrainement' }),
})
expect(res.status).toBe(201)
const body = await res.json()
expect(body.id).toBe('prod-4')
expect(body.sujet).toBeNull()
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets')
})
it('pick aléatoire parmi plusieurs sujets actifs', async () => {
const profile = buildProfile({ plan: 'standard', simulations_used: 10 })
const candidates = [
{ ...MOCK_SUJET_EE_T1, id: 'sujet-a' },
{ ...MOCK_SUJET_EE_T1, id: 'sujet-b' },
{ ...MOCK_SUJET_EE_T1, id: 'sujet-c' },
]
mockAuth(profile)
mockInsert({ id: 'prod-5', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
mockSujets(candidates)
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()
const pickedIds = candidates.map((s) => s.id)
expect(pickedIds).toContain(body.sujet.id)
})
})
describe('GET /simulations/:id', () => {

View file

@ -13,11 +13,23 @@ export interface CreateBody {
contenu?: string
}
export interface SujetData {
id: string
consigne: string
role: string | null
contexte: string | null
doc1_titre: string | null
doc1_texte: string | null
doc2_titre: string | null
doc2_texte: string | null
}
export interface CreateResult {
id: string
tache: Tache
mode: Mode
created_at: string
sujet: SujetData | null
}
type CreateError = {
@ -27,6 +39,27 @@ type CreateError = {
status: number
}
// Mappe une Tache frontend vers les filtres de la table sujets.
// Retourne null pour EO_T2_LIVE (interaction live, pas de sujet pré-défini).
function mapTacheToSujetParams(
tache: Tache
): { mode: 'EE' | 'EO'; tacheNumber: number } | null {
switch (tache) {
case 'EE_T1':
return { mode: 'EE', tacheNumber: 1 }
case 'EE_T2':
return { mode: 'EE', tacheNumber: 2 }
case 'EE_T3':
return { mode: 'EE', tacheNumber: 3 }
case 'EO_T1':
return { mode: 'EO', tacheNumber: 1 }
case 'EO_T3':
return { mode: 'EO', tacheNumber: 3 }
case 'EO_T2_LIVE':
return null
}
}
export async function create(
body: CreateBody,
profile: AuthProfile
@ -64,7 +97,24 @@ export async function create(
}
}
// 3. Incrémenter simulations_used si le plan a une limite (via access.ts — Règle D)
// 3. Fetch un sujet aléatoire (non bloquant — sujet: null si introuvable).
// TODO: migrer vers une RPC PostgreSQL si la table sujets dépasse quelques centaines de lignes.
const sujetParams = mapTacheToSujetParams(body.tache)
let sujet: SujetData | null = null
if (sujetParams) {
const { data: sujets, error: sujetError } = await supabase
.from('sujets')
.select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte')
.eq('mode', sujetParams.mode)
.eq('tache', sujetParams.tacheNumber)
.eq('actif', true)
if (!sujetError && sujets && sujets.length > 0) {
sujet = sujets[Math.floor(Math.random() * sujets.length)] as SujetData
}
}
// 4. 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
@ -73,7 +123,15 @@ export async function create(
.eq('id', profile.id)
}
return { data: data as CreateResult }
return {
data: {
id: data.id,
tache: data.tache as Tache,
mode: data.mode as Mode,
created_at: data.created_at,
sujet,
},
}
}
// EERapport et EORapport ont la même structure depuis l'étape A —