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)
|
} 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() {
|
function createApp() {
|
||||||
const app = new Hono<{ Variables: AppVariables }>()
|
const app = new Hono<{ Variables: AppVariables }>()
|
||||||
app.route('/simulations', simulationsRoutes)
|
app.route('/simulations', simulationsRoutes)
|
||||||
|
|
@ -92,6 +127,7 @@ describe('POST /simulations', () => {
|
||||||
const profile = buildProfile({ plan: 'free', simulations_used: 4 })
|
const profile = buildProfile({ plan: 'free', simulations_used: 4 })
|
||||||
mockAuth(profile)
|
mockAuth(profile)
|
||||||
mockInsert({ id: 'prod-1', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
mockInsert({ id: 'prod-1', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
||||||
|
mockSujets([MOCK_SUJET_EE_T1])
|
||||||
mockUpdate()
|
mockUpdate()
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
|
|
@ -106,9 +142,11 @@ describe('POST /simulations', () => {
|
||||||
expect(body.id).toBe('prod-1')
|
expect(body.id).toBe('prod-1')
|
||||||
expect(body.tache).toBe('EE_T1')
|
expect(body.tache).toBe('EE_T1')
|
||||||
expect(body.mode).toBe('entrainement')
|
expect(body.mode).toBe('entrainement')
|
||||||
// 3 appels from : profiles (auth) + productions (insert) + profiles (update)
|
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
|
||||||
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
|
// 4 appels from : profiles (auth) + productions (insert) + sujets (select) + profiles (update)
|
||||||
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('profiles')
|
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 () => {
|
it('free + 5 simulations utilisées → QUOTA_REACHED', async () => {
|
||||||
|
|
@ -134,6 +172,7 @@ describe('POST /simulations', () => {
|
||||||
const profile = buildProfile({ plan: 'standard', simulations_used: 999 })
|
const profile = buildProfile({ plan: 'standard', simulations_used: 999 })
|
||||||
mockAuth(profile)
|
mockAuth(profile)
|
||||||
mockInsert({ id: 'prod-2', tache: 'EE_T2', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
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
|
// Pas de mockUpdate : standard n'a pas de limite
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
|
|
@ -146,14 +185,17 @@ describe('POST /simulations', () => {
|
||||||
expect(res.status).toBe(201)
|
expect(res.status).toBe(201)
|
||||||
const body = await res.json()
|
const body = await res.json()
|
||||||
expect(body.id).toBe('prod-2')
|
expect(body.id).toBe('prod-2')
|
||||||
// 2 appels from : profiles (auth) + productions (insert) — pas de update
|
expect(body.sujet).toEqual(MOCK_SUJET_EE_T2)
|
||||||
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2)
|
// 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 })
|
const profile = buildProfile({ plan: 'premium', simulations_used: 0 })
|
||||||
mockAuth(profile)
|
mockAuth(profile)
|
||||||
mockInsert({ id: 'prod-3', tache: 'EO_T2_LIVE', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
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
|
// Pas de mockUpdate : premium n'a pas de limite
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
|
|
@ -167,6 +209,8 @@ describe('POST /simulations', () => {
|
||||||
const body = await res.json()
|
const body = await res.json()
|
||||||
expect(body.id).toBe('prod-3')
|
expect(body.id).toBe('prod-3')
|
||||||
expect(body.tache).toBe('EO_T2_LIVE')
|
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)
|
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -203,6 +247,51 @@ describe('POST /simulations', () => {
|
||||||
expect(body.error).toBe(true)
|
expect(body.error).toBe(true)
|
||||||
expect(body.code).toBe('VALIDATION_ERROR')
|
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', () => {
|
describe('GET /simulations/:id', () => {
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,23 @@ export interface CreateBody {
|
||||||
contenu?: string
|
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 {
|
export interface CreateResult {
|
||||||
id: string
|
id: string
|
||||||
tache: Tache
|
tache: Tache
|
||||||
mode: Mode
|
mode: Mode
|
||||||
created_at: string
|
created_at: string
|
||||||
|
sujet: SujetData | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateError = {
|
type CreateError = {
|
||||||
|
|
@ -27,6 +39,27 @@ type CreateError = {
|
||||||
status: number
|
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(
|
export async function create(
|
||||||
body: CreateBody,
|
body: CreateBody,
|
||||||
profile: AuthProfile
|
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)
|
const perms = getPlanPermissions(profile.plan as Plan)
|
||||||
if (perms.simulations_lifetime !== null) {
|
if (perms.simulations_lifetime !== null) {
|
||||||
await supabase
|
await supabase
|
||||||
|
|
@ -73,7 +123,15 @@ export async function create(
|
||||||
.eq('id', profile.id)
|
.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 —
|
// EERapport et EORapport ont la même structure depuis l'étape A —
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue