feat(simulations): persistance session — autosave + sujet_id + getById tolère rapport=null (FTD-21)
This commit is contained in:
parent
fc76fac981
commit
fcd8fe7017
3 changed files with 533 additions and 77 deletions
|
|
@ -66,7 +66,7 @@ function mockProductionSelect(data: unknown, error: unknown = null) {
|
||||||
} as any)
|
} as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mock from('sujets').select(...).eq(...).eq(...).eq(...) pour create */
|
/** Mock from('sujets').select(...).eq(...).eq(...).eq(...) pour create (liste filtrée) */
|
||||||
function mockSujets(rows: unknown[]) {
|
function mockSujets(rows: unknown[]) {
|
||||||
vi.mocked(supabase.from).mockReturnValueOnce({
|
vi.mocked(supabase.from).mockReturnValueOnce({
|
||||||
select: vi.fn(() => ({
|
select: vi.fn(() => ({
|
||||||
|
|
@ -79,6 +79,26 @@ function mockSujets(rows: unknown[]) {
|
||||||
} as any)
|
} as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mock from('sujets').select(...).eq(...).single() pour getById/updateSujet */
|
||||||
|
function mockSujetById(data: unknown, error: unknown = null) {
|
||||||
|
vi.mocked(supabase.from).mockReturnValueOnce({
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
eq: vi.fn(() => ({
|
||||||
|
single: vi.fn(() => ({ data, error })),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock from('productions').update(...).eq(...) pour autosave/updateSujet */
|
||||||
|
function mockUpdate(error: unknown = null) {
|
||||||
|
vi.mocked(supabase.from).mockReturnValueOnce({
|
||||||
|
update: vi.fn(() => ({
|
||||||
|
eq: vi.fn(() => ({ error })),
|
||||||
|
})),
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
|
||||||
const MOCK_SUJET_EE_T1 = {
|
const MOCK_SUJET_EE_T1 = {
|
||||||
id: 'sujet-1',
|
id: 'sujet-1',
|
||||||
consigne: 'Écrivez un texte argumentatif sur le télétravail.',
|
consigne: 'Écrivez un texte argumentatif sur le télétravail.',
|
||||||
|
|
@ -117,8 +137,8 @@ describe('POST /simulations', () => {
|
||||||
it('free + 4 simulations utilisées → création OK, pas d\'incrément (il a lieu après correction)', async () => {
|
it('free + 4 simulations utilisées → création OK, pas d\'incrément (il a lieu après correction)', async () => {
|
||||||
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' })
|
|
||||||
mockSujets([MOCK_SUJET_EE_T1])
|
mockSujets([MOCK_SUJET_EE_T1])
|
||||||
|
mockInsert({ id: 'prod-1', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
const res = await app.request('/simulations', {
|
const res = await app.request('/simulations', {
|
||||||
|
|
@ -133,9 +153,10 @@ describe('POST /simulations', () => {
|
||||||
expect(body.tache).toBe('EE_T1')
|
expect(body.tache).toBe('EE_T1')
|
||||||
expect(body.mode).toBe('entrainement')
|
expect(body.mode).toBe('entrainement')
|
||||||
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
|
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
|
||||||
// 3 appels from : profiles (auth) + productions (insert) + sujets (select)
|
// 3 appels from : profiles (auth) + sujets (select) + productions (insert)
|
||||||
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
|
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
|
||||||
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets')
|
expect(vi.mocked(supabase.from).mock.calls[1][0]).toBe('sujets')
|
||||||
|
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('productions')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('free + 5 simulations utilisées → QUOTA_REACHED', async () => {
|
it('free + 5 simulations utilisées → QUOTA_REACHED', async () => {
|
||||||
|
|
@ -160,9 +181,8 @@ describe('POST /simulations', () => {
|
||||||
it('standard + 999 simulations → création OK, simulations_used NON incrémenté', async () => {
|
it('standard + 999 simulations → création OK, simulations_used NON incrémenté', async () => {
|
||||||
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' })
|
|
||||||
mockSujets([MOCK_SUJET_EE_T2])
|
mockSujets([MOCK_SUJET_EE_T2])
|
||||||
// Pas de mockUpdate : standard n'a pas de limite
|
mockInsert({ id: 'prod-2', tache: 'EE_T2', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
const res = await app.request('/simulations', {
|
const res = await app.request('/simulations', {
|
||||||
|
|
@ -175,9 +195,10 @@ describe('POST /simulations', () => {
|
||||||
const body = await res.json()
|
const body = await res.json()
|
||||||
expect(body.id).toBe('prod-2')
|
expect(body.id).toBe('prod-2')
|
||||||
expect(body.sujet).toEqual(MOCK_SUJET_EE_T2)
|
expect(body.sujet).toEqual(MOCK_SUJET_EE_T2)
|
||||||
// 3 appels from : profiles (auth) + productions (insert) + sujets (select) — pas de update
|
// 3 appels from : profiles (auth) + sujets (select) + productions (insert)
|
||||||
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
|
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
|
||||||
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets')
|
expect(vi.mocked(supabase.from).mock.calls[1][0]).toBe('sujets')
|
||||||
|
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('productions')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('premium + EO_T2_LIVE → création OK, sujet: null (pas de lookup)', async () => {
|
it('premium + EO_T2_LIVE → création OK, sujet: null (pas de lookup)', async () => {
|
||||||
|
|
@ -185,7 +206,6 @@ describe('POST /simulations', () => {
|
||||||
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 mockSujets : EO_T2_LIVE skip la query sujets
|
||||||
// Pas de mockUpdate : premium n'a pas de limite
|
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
const res = await app.request('/simulations', {
|
const res = await app.request('/simulations', {
|
||||||
|
|
@ -199,7 +219,7 @@ describe('POST /simulations', () => {
|
||||||
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()
|
expect(body.sujet).toBeNull()
|
||||||
// 2 appels from : profiles (auth) + productions (insert) — pas de sujets, pas de update
|
// 2 appels from : profiles (auth) + productions (insert) — pas de sujets
|
||||||
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2)
|
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -240,8 +260,8 @@ describe('POST /simulations', () => {
|
||||||
it('aucun sujet actif trouvé → création OK avec sujet: null (non bloquant)', async () => {
|
it('aucun sujet actif trouvé → création OK avec sujet: null (non bloquant)', async () => {
|
||||||
const profile = buildProfile({ plan: 'standard', simulations_used: 10 })
|
const profile = buildProfile({ plan: 'standard', simulations_used: 10 })
|
||||||
mockAuth(profile)
|
mockAuth(profile)
|
||||||
mockInsert({ id: 'prod-4', tache: 'EE_T3', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
|
||||||
mockSujets([]) // table vide pour ce filtre
|
mockSujets([]) // table vide pour ce filtre
|
||||||
|
mockInsert({ id: 'prod-4', tache: 'EE_T3', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
const res = await app.request('/simulations', {
|
const res = await app.request('/simulations', {
|
||||||
|
|
@ -255,7 +275,7 @@ describe('POST /simulations', () => {
|
||||||
expect(body.id).toBe('prod-4')
|
expect(body.id).toBe('prod-4')
|
||||||
expect(body.sujet).toBeNull()
|
expect(body.sujet).toBeNull()
|
||||||
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
|
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
|
||||||
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets')
|
expect(vi.mocked(supabase.from).mock.calls[1][0]).toBe('sujets')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pick aléatoire parmi plusieurs sujets actifs', async () => {
|
it('pick aléatoire parmi plusieurs sujets actifs', async () => {
|
||||||
|
|
@ -266,8 +286,8 @@ describe('POST /simulations', () => {
|
||||||
{ ...MOCK_SUJET_EE_T1, id: 'sujet-c' },
|
{ ...MOCK_SUJET_EE_T1, id: 'sujet-c' },
|
||||||
]
|
]
|
||||||
mockAuth(profile)
|
mockAuth(profile)
|
||||||
mockInsert({ id: 'prod-5', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
|
||||||
mockSujets(candidates)
|
mockSujets(candidates)
|
||||||
|
mockInsert({ id: 'prod-5', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
const res = await app.request('/simulations', {
|
const res = await app.request('/simulations', {
|
||||||
|
|
@ -304,7 +324,7 @@ describe('GET /simulations/:id', () => {
|
||||||
exercices: ['exo 1'],
|
exercices: ['exo 1'],
|
||||||
}
|
}
|
||||||
|
|
||||||
it('OK : rapport trouvé et appartenant à l\'utilisateur → 200 avec simulation_id', async () => {
|
it('OK (avec sujet) : rapport trouvé, appartenant à l\'utilisateur → 200', async () => {
|
||||||
const profile = buildProfile({ id: 'user-123', plan: 'standard' })
|
const profile = buildProfile({ id: 'user-123', plan: 'standard' })
|
||||||
mockAuth(profile)
|
mockAuth(profile)
|
||||||
mockProductionSelect({
|
mockProductionSelect({
|
||||||
|
|
@ -312,11 +332,12 @@ describe('GET /simulations/:id', () => {
|
||||||
user_id: 'user-123',
|
user_id: 'user-123',
|
||||||
tache: 'EE_T1',
|
tache: 'EE_T1',
|
||||||
mode: 'entrainement',
|
mode: 'entrainement',
|
||||||
score: 14,
|
contenu: 'Mon texte en cours.',
|
||||||
nclc: 8,
|
sujet_id: 'sujet-1',
|
||||||
rapport: JSON.stringify(VALID_RAPPORT),
|
rapport: JSON.stringify(VALID_RAPPORT),
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
})
|
})
|
||||||
|
mockSujetById(MOCK_SUJET_EE_T1)
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
const res = await app.request('/simulations/prod-42', {
|
const res = await app.request('/simulations/prod-42', {
|
||||||
|
|
@ -329,14 +350,65 @@ describe('GET /simulations/:id', () => {
|
||||||
expect(body.tache).toBe('EE_T1')
|
expect(body.tache).toBe('EE_T1')
|
||||||
expect(body.mode).toBe('entrainement')
|
expect(body.mode).toBe('entrainement')
|
||||||
expect(body.created_at).toBe('2024-01-01T00:00:00Z')
|
expect(body.created_at).toBe('2024-01-01T00:00:00Z')
|
||||||
expect(body.score).toBe(14)
|
expect(body.contenu).toBe('Mon texte en cours.')
|
||||||
expect(body.nclc).toBe(8)
|
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
|
||||||
expect(body.feedback_court).toBe('Bonne production générale.')
|
expect(body.rapport).toEqual(VALID_RAPPORT)
|
||||||
expect(body.criteres).toHaveLength(4)
|
})
|
||||||
expect(body.erreurs).toEqual(['erreur 1'])
|
|
||||||
expect(body.modele).toBe('Texte modèle.')
|
it('OK (sans sujet_id) : production sans sujet → sujet: null', async () => {
|
||||||
expect(body.idees).toEqual(['idée 1'])
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
expect(body.exercices).toEqual(['exo 1'])
|
mockAuth(profile)
|
||||||
|
mockProductionSelect({
|
||||||
|
id: 'prod-42',
|
||||||
|
user_id: 'user-123',
|
||||||
|
tache: 'EO_T2_LIVE',
|
||||||
|
mode: 'entrainement',
|
||||||
|
contenu: null,
|
||||||
|
sujet_id: null,
|
||||||
|
rapport: JSON.stringify(VALID_RAPPORT),
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42', {
|
||||||
|
headers: { Authorization: 'Bearer token' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.sujet).toBeNull()
|
||||||
|
expect(body.contenu).toBeNull()
|
||||||
|
expect(body.rapport).toEqual(VALID_RAPPORT)
|
||||||
|
// Pas d'appel from('sujets') : sujet_id null
|
||||||
|
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FTD-21 — simulation en cours : rapport=null retourné avec contenu + sujet (resume)', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
mockProductionSelect({
|
||||||
|
id: 'prod-42',
|
||||||
|
user_id: 'user-123',
|
||||||
|
tache: 'EE_T1',
|
||||||
|
mode: 'entrainement',
|
||||||
|
contenu: 'Brouillon en cours.',
|
||||||
|
sujet_id: 'sujet-1',
|
||||||
|
rapport: null,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
mockSujetById(MOCK_SUJET_EE_T1)
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42', {
|
||||||
|
headers: { Authorization: 'Bearer token' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.simulation_id).toBe('prod-42')
|
||||||
|
expect(body.contenu).toBe('Brouillon en cours.')
|
||||||
|
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
|
||||||
|
expect(body.rapport).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => {
|
it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => {
|
||||||
|
|
@ -363,8 +435,8 @@ describe('GET /simulations/:id', () => {
|
||||||
user_id: 'another-user-456',
|
user_id: 'another-user-456',
|
||||||
tache: 'EE_T1',
|
tache: 'EE_T1',
|
||||||
mode: 'entrainement',
|
mode: 'entrainement',
|
||||||
score: 14,
|
contenu: null,
|
||||||
nclc: 8,
|
sujet_id: null,
|
||||||
rapport: JSON.stringify(VALID_RAPPORT),
|
rapport: JSON.stringify(VALID_RAPPORT),
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
})
|
})
|
||||||
|
|
@ -379,29 +451,188 @@ describe('GET /simulations/:id', () => {
|
||||||
expect(body.error).toBe(true)
|
expect(body.error).toBe(true)
|
||||||
expect(body.code).toBe('AUTH_REQUIRED')
|
expect(body.code).toBe('AUTH_REQUIRED')
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('REPORT_NOT_READY : rapport === null → 404', async () => {
|
describe('PATCH /simulations/:id/contenu — FTD-21 autosave', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('succès : contenu sauvegardé → 200 { ok: true }', async () => {
|
||||||
const profile = buildProfile({ id: 'user-123' })
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
mockAuth(profile)
|
mockAuth(profile)
|
||||||
mockProductionSelect({
|
mockProductionSelect({ user_id: 'user-123', rapport: null })
|
||||||
id: 'prod-42',
|
mockUpdate()
|
||||||
user_id: 'user-123',
|
|
||||||
tache: 'EE_T1',
|
|
||||||
mode: 'entrainement',
|
|
||||||
score: null,
|
|
||||||
nclc: null,
|
|
||||||
rapport: null,
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
const res = await app.request('/simulations/prod-42', {
|
const res = await app.request('/simulations/prod-42/contenu', {
|
||||||
headers: { Authorization: 'Bearer token' },
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contenu: 'Mon brouillon.' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.ok).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AUTH_REQUIRED : simulation appartient à un autre user → 401', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
mockProductionSelect({ user_id: 'another-user', rapport: null })
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42/contenu', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contenu: 'Texte.' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.code).toBe('AUTH_REQUIRED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
mockProductionSelect(null, { message: 'No rows' })
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42/contenu', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contenu: 'Texte.' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
const body = await res.json()
|
const body = await res.json()
|
||||||
expect(body.error).toBe(true)
|
expect(body.code).toBe('SIMULATION_NOT_FOUND')
|
||||||
expect(body.code).toBe('REPORT_NOT_READY')
|
})
|
||||||
|
|
||||||
|
it('VALIDATION_ERROR : contenu > 5000 caractères → 400', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
// Pas besoin de mocks supabase : la validation bloque avant tout accès DB
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42/contenu', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contenu: 'a'.repeat(5001) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.code).toBe('VALIDATION_ERROR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('VALIDATION_ERROR : simulation déjà corrigée → 400', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
mockProductionSelect({ user_id: 'user-123', rapport: '{"score":14}' })
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42/contenu', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contenu: 'Texte.' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.code).toBe('VALIDATION_ERROR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('VALIDATION_ERROR : body.contenu manquant → 400', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42/contenu', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.code).toBe('VALIDATION_ERROR')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PATCH /simulations/:id/sujet — FTD-21 changement de sujet', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('succès : sujet mis à jour → 200 avec sujet complet', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
mockSujetById(MOCK_SUJET_EE_T1)
|
||||||
|
mockProductionSelect({ user_id: 'user-123', rapport: null })
|
||||||
|
mockUpdate()
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42/sujet', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sujet_id: 'sujet-1' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('SUJET_NOT_FOUND : sujet_id inexistant → 404', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
mockSujetById(null, { message: 'No rows' })
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42/sujet', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sujet_id: 'does-not-exist' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.code).toBe('SUJET_NOT_FOUND')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AUTH_REQUIRED : simulation appartient à un autre user → 401', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
mockSujetById(MOCK_SUJET_EE_T1)
|
||||||
|
mockProductionSelect({ user_id: 'another-user', rapport: null })
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42/sujet', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sujet_id: 'sujet-1' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.code).toBe('AUTH_REQUIRED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('VALIDATION_ERROR : sujet_id manquant → 400', async () => {
|
||||||
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
|
mockAuth(profile)
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const res = await app.request('/simulations/prod-42/sujet', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.code).toBe('VALIDATION_ERROR')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -75,29 +75,8 @@ export async function create(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Insérer dans productions
|
// 2. Fetch un sujet aléatoire AVANT l'insert pour persister sujet_id en une seule requête.
|
||||||
const { data, error } = await supabase
|
// (non bloquant — sujet: null si introuvable).
|
||||||
.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. 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)
|
const sujetParams = mapTacheToSujetParams(body.tache)
|
||||||
let sujet: SujetData | null = null
|
let sujet: SujetData | null = null
|
||||||
if (sujetParams) {
|
if (sujetParams) {
|
||||||
|
|
@ -113,6 +92,28 @@ export async function create(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Insérer dans productions avec sujet_id (FTD-21 — persistance pour resume).
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('productions')
|
||||||
|
.insert({
|
||||||
|
user_id: profile.id,
|
||||||
|
tache: body.tache,
|
||||||
|
mode: body.mode,
|
||||||
|
contenu: body.contenu ?? null,
|
||||||
|
sujet_id: sujet?.id ?? 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
|
|
@ -126,14 +127,22 @@ export async function create(
|
||||||
|
|
||||||
// EERapport et EORapport ont la même structure depuis l'étape A —
|
// EERapport et EORapport ont la même structure depuis l'étape A —
|
||||||
// on utilise EERapport comme représentation canonique du rapport parsé.
|
// on utilise EERapport comme représentation canonique du rapport parsé.
|
||||||
export type GetByIdResult = EERapport & {
|
//
|
||||||
|
// FTD-21 : rapport peut être null (simulation en cours, pas encore corrigée).
|
||||||
|
// Le frontend distingue :
|
||||||
|
// - rapport !== null → RapportPage affiche la correction
|
||||||
|
// - rapport === null → SimulationFlowProvider restaure la session (resume)
|
||||||
|
export interface GetByIdResult {
|
||||||
simulation_id: string
|
simulation_id: string
|
||||||
tache: Tache
|
tache: Tache
|
||||||
mode: Mode
|
mode: Mode
|
||||||
created_at: string
|
created_at: string
|
||||||
|
contenu: string | null
|
||||||
|
sujet: SujetData | null
|
||||||
|
rapport: EERapport | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetByIdError = {
|
type ControllerError = {
|
||||||
error: true
|
error: true
|
||||||
code: string
|
code: string
|
||||||
message: string
|
message: string
|
||||||
|
|
@ -143,10 +152,10 @@ type GetByIdError = {
|
||||||
export async function getById(
|
export async function getById(
|
||||||
id: string,
|
id: string,
|
||||||
profile: AuthProfile
|
profile: AuthProfile
|
||||||
): Promise<{ data: GetByIdResult } | GetByIdError> {
|
): Promise<{ data: GetByIdResult } | ControllerError> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('productions')
|
.from('productions')
|
||||||
.select('id, user_id, tache, mode, score, nclc, rapport, created_at')
|
.select('id, user_id, tache, mode, contenu, sujet_id, rapport, created_at')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
|
@ -168,24 +177,170 @@ export async function getById(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.rapport === null) {
|
// Charger le sujet si présent (FTD-21 — restore complet de la session).
|
||||||
return {
|
let sujet: SujetData | null = null
|
||||||
error: true,
|
if (data.sujet_id) {
|
||||||
code: 'REPORT_NOT_READY',
|
const { data: sujetRow } = await supabase
|
||||||
message: "Le rapport n'est pas encore disponible pour cette simulation.",
|
.from('sujets')
|
||||||
status: 404,
|
.select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte')
|
||||||
}
|
.eq('id', data.sujet_id)
|
||||||
|
.single()
|
||||||
|
if (sujetRow) sujet = sujetRow as SujetData
|
||||||
}
|
}
|
||||||
|
|
||||||
const rapport = JSON.parse(data.rapport) as EERapport
|
const rapport = data.rapport ? (JSON.parse(data.rapport) as EERapport) : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
...rapport,
|
|
||||||
simulation_id: data.id,
|
simulation_id: data.id,
|
||||||
tache: data.tache as Tache,
|
tache: data.tache as Tache,
|
||||||
mode: data.mode as Mode,
|
mode: data.mode as Mode,
|
||||||
created_at: data.created_at,
|
created_at: data.created_at,
|
||||||
|
contenu: data.contenu ?? null,
|
||||||
|
sujet,
|
||||||
|
rapport,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FTD-21 — autosave du contenu d'une simulation en cours.
|
||||||
|
* Refuse si la simulation est déjà corrigée (rapport !== null).
|
||||||
|
*/
|
||||||
|
export async function autosaveContenu(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
contenu: string
|
||||||
|
): Promise<{ data: { ok: true } } | ControllerError> {
|
||||||
|
if (contenu.length > 5000) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Le texte ne doit pas dépasser 5 000 caractères.',
|
||||||
|
status: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: prod, error } = await supabase
|
||||||
|
.from('productions')
|
||||||
|
.select('user_id, rapport')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error || !prod) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'SIMULATION_NOT_FOUND',
|
||||||
|
message: 'Simulation introuvable.',
|
||||||
|
status: 404,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prod.user_id !== userId) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'AUTH_REQUIRED',
|
||||||
|
message: 'Cette simulation ne vous appartient pas.',
|
||||||
|
status: 401,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prod.rapport !== null) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Cette simulation a déjà été corrigée.',
|
||||||
|
status: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('productions')
|
||||||
|
.update({ contenu })
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: 'Sauvegarde impossible. Réessayez dans quelques instants.',
|
||||||
|
status: 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: { ok: true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FTD-21 — met à jour le sujet d'une simulation en cours.
|
||||||
|
* Vérifie que le sujet existe et que la simulation n'est pas corrigée.
|
||||||
|
*/
|
||||||
|
export async function updateSujet(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
sujetId: string
|
||||||
|
): Promise<{ data: { sujet: SujetData } } | ControllerError> {
|
||||||
|
const { data: sujetRow, error: sujetError } = await supabase
|
||||||
|
.from('sujets')
|
||||||
|
.select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte')
|
||||||
|
.eq('id', sujetId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (sujetError || !sujetRow) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'SUJET_NOT_FOUND',
|
||||||
|
message: 'Sujet introuvable.',
|
||||||
|
status: 404,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: prod, error } = await supabase
|
||||||
|
.from('productions')
|
||||||
|
.select('user_id, rapport')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error || !prod) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'SIMULATION_NOT_FOUND',
|
||||||
|
message: 'Simulation introuvable.',
|
||||||
|
status: 404,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prod.user_id !== userId) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'AUTH_REQUIRED',
|
||||||
|
message: 'Cette simulation ne vous appartient pas.',
|
||||||
|
status: 401,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prod.rapport !== null) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Cette simulation a déjà été corrigée.',
|
||||||
|
status: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('productions')
|
||||||
|
.update({ sujet_id: sujetId })
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: 'Mise à jour impossible. Réessayez dans quelques instants.',
|
||||||
|
status: 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: { sujet: sujetRow as SujetData } }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,4 +92,74 @@ simulations.get('/:id', authMiddleware, async (c) => {
|
||||||
return c.json(result.data, 200)
|
return c.json(result.data, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// FTD-21 — autosave du contenu d'une simulation en cours.
|
||||||
|
simulations.patch('/:id/contenu', authMiddleware, async (c) => {
|
||||||
|
const id = c.req.param('id')!
|
||||||
|
|
||||||
|
let body: { 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.contenu !== 'string') {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Le champ `contenu` est requis et doit être une chaîne.',
|
||||||
|
},
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = c.get('profile')
|
||||||
|
const result = await simulationController.autosaveContenu(id, profile.id, body.contenu)
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
return c.json(result, result.status as 400 | 401 | 404 | 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result.data, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// FTD-21 — met à jour le sujet d'une simulation en cours.
|
||||||
|
simulations.patch('/:id/sujet', authMiddleware, async (c) => {
|
||||||
|
const id = c.req.param('id')!
|
||||||
|
|
||||||
|
let body: { sujet_id?: unknown }
|
||||||
|
try {
|
||||||
|
body = await c.req.json()
|
||||||
|
} catch {
|
||||||
|
return c.json(
|
||||||
|
{ error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' },
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.sujet_id || typeof body.sujet_id !== 'string') {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Le champ `sujet_id` est requis et doit être une chaîne.',
|
||||||
|
},
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = c.get('profile')
|
||||||
|
const result = await simulationController.updateSujet(id, profile.id, body.sujet_id)
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
return c.json(result, result.status as 400 | 401 | 404 | 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result.data, 200)
|
||||||
|
})
|
||||||
|
|
||||||
export default simulations
|
export default simulations
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue