feat(simulations): GET /simulations/:id — lecture rapport avec auth + REPORT_NOT_READY

This commit is contained in:
Hermann_Kitio 2026-04-20 04:41:24 +03:00
parent 6ca2412304
commit 0680a6382f
3 changed files with 215 additions and 0 deletions

View file

@ -64,6 +64,17 @@ function mockUpdate() {
} as any)
}
/** Mock from('productions').select(...).eq(...).single() pour getById */
function mockProductionSelect(data: unknown, error: unknown = null) {
vi.mocked(supabase.from).mockReturnValueOnce({
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(() => ({ data, error })),
})),
})),
} as any)
}
function createApp() {
const app = new Hono<{ Variables: AppVariables }>()
app.route('/simulations', simulationsRoutes)
@ -193,3 +204,126 @@ describe('POST /simulations', () => {
expect(body.code).toBe('VALIDATION_ERROR')
})
})
describe('GET /simulations/:id', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const VALID_RAPPORT = {
score: 14,
nclc: 8,
feedback_court: 'Bonne production générale.',
criteres: [
{ nom: 'Coherence et cohesion', score: 4, commentaire: 'OK' },
{ nom: 'Lexique', score: 3, commentaire: 'OK' },
{ nom: 'Morphosyntaxe', score: 4, commentaire: 'OK' },
{ nom: 'Pertinence', score: 3, commentaire: 'OK' },
],
erreurs: ['erreur 1'],
modele: 'Texte modèle.',
idees: ['idée 1'],
exercices: ['exo 1'],
}
it('OK : rapport trouvé et appartenant à l\'utilisateur → 200 avec simulation_id', async () => {
const profile = buildProfile({ id: 'user-123', plan: 'standard' })
mockAuth(profile)
mockProductionSelect({
id: 'prod-42',
user_id: 'user-123',
tache: 'EE_T1',
mode: 'entrainement',
score: 14,
nclc: 8,
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.simulation_id).toBe('prod-42')
expect(body.tache).toBe('EE_T1')
expect(body.mode).toBe('entrainement')
expect(body.created_at).toBe('2024-01-01T00:00:00Z')
expect(body.score).toBe(14)
expect(body.nclc).toBe(8)
expect(body.feedback_court).toBe('Bonne production générale.')
expect(body.criteres).toHaveLength(4)
expect(body.erreurs).toEqual(['erreur 1'])
expect(body.modele).toBe('Texte modèle.')
expect(body.idees).toEqual(['idée 1'])
expect(body.exercices).toEqual(['exo 1'])
})
it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect(null, { message: 'No rows returned' })
const app = createApp()
const res = await app.request('/simulations/does-not-exist', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error).toBe(true)
expect(body.code).toBe('SIMULATION_NOT_FOUND')
})
it('AUTH_REQUIRED : user_id !== profile.id → 401', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect({
id: 'prod-42',
user_id: 'another-user-456',
tache: 'EE_T1',
mode: 'entrainement',
score: 14,
nclc: 8,
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(401)
const body = await res.json()
expect(body.error).toBe(true)
expect(body.code).toBe('AUTH_REQUIRED')
})
it('REPORT_NOT_READY : rapport === null → 404', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect({
id: 'prod-42',
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 res = await app.request('/simulations/prod-42', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error).toBe(true)
expect(body.code).toBe('REPORT_NOT_READY')
})
})

View file

@ -1,6 +1,7 @@
import { supabase } from '../lib/supabase.js'
import { canUserSimulate, getPlanPermissions } from '../lib/access.js'
import type { Plan } from '../lib/access.js'
import type { EERapport } from '../lib/deepseek.js'
import type { AuthProfile } from '../middleware/auth.js'
export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3' | 'EO_T2_LIVE'
@ -74,3 +75,69 @@ export async function create(
return { data: data as CreateResult }
}
// EERapport et EORapport ont la même structure depuis l'étape A —
// on utilise EERapport comme représentation canonique du rapport parsé.
export type GetByIdResult = EERapport & {
simulation_id: string
tache: Tache
mode: Mode
created_at: string
}
type GetByIdError = {
error: true
code: string
message: string
status: number
}
export async function getById(
id: string,
profile: AuthProfile
): Promise<{ data: GetByIdResult } | GetByIdError> {
const { data, error } = await supabase
.from('productions')
.select('id, user_id, tache, mode, score, nclc, rapport, created_at')
.eq('id', id)
.single()
if (error || !data) {
return {
error: true,
code: 'SIMULATION_NOT_FOUND',
message: 'Simulation introuvable.',
status: 404,
}
}
if (data.user_id !== profile.id) {
return {
error: true,
code: 'AUTH_REQUIRED',
message: 'Cette simulation ne vous appartient pas.',
status: 401,
}
}
if (data.rapport === null) {
return {
error: true,
code: 'REPORT_NOT_READY',
message: "Le rapport n'est pas encore disponible pour cette simulation.",
status: 404,
}
}
const rapport = JSON.parse(data.rapport) as EERapport
return {
data: {
...rapport,
simulation_id: data.id,
tache: data.tache as Tache,
mode: data.mode as Mode,
created_at: data.created_at,
},
}
}

View file

@ -78,4 +78,18 @@ simulations.post('/', authMiddleware, async (c) => {
return c.json(result.data, 201)
})
simulations.get('/:id', authMiddleware, async (c) => {
// `:id` est garanti présent par le pattern de route Hono
const id = c.req.param('id')!
const profile = c.get('profile')
const result = await simulationController.getById(id, profile)
if ('error' in result) {
return c.json(result, result.status as 401 | 404 | 500)
}
return c.json(result.data, 200)
})
export default simulations