feat(simulations): retourner un sujet aléatoire actif avec la production créée
This commit is contained in:
parent
0680a6382f
commit
b6b8c76cc2
2 changed files with 155 additions and 8 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue