feat(simulations): GET /simulations — liste paginée des productions (Sprint 3.7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-22 20:54:36 +03:00
parent 14d8d73991
commit a394ce8429
3 changed files with 357 additions and 0 deletions

View file

@ -725,3 +725,247 @@ describe('PATCH /simulations/:id/sujet — FTD-21 changement de sujet', () => {
expect(body.code).toBe('VALIDATION_ERROR')
})
})
// ─── GET /simulations (Sprint 3.7) ────────────────────────────────────────────
/**
* Mock du chain Supabase pour `list` :
* from('productions').select(cols, {count:'exact'}).eq(...).order(...).range(...)
* retourne { data, error, count }.
*/
function mockProductionsList(params: {
data: unknown[]
count: number | null
error?: unknown
}) {
const { data, count, error = null } = params
const rangeFn = vi.fn(() => ({ data, error, count }))
const orderFn = vi.fn(() => ({ range: rangeFn }))
const eqFn = vi.fn(() => ({ order: orderFn }))
const selectFn = vi.fn(() => ({ eq: eqFn }))
vi.mocked(supabase.from).mockReturnValueOnce({ select: selectFn } as any)
return { selectFn, eqFn, orderFn, rangeFn }
}
describe('GET /simulations', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('liste vide → 200 avec data=[] et total=0', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionsList({ data: [], count: 0 })
const app = createApp()
const res = await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toEqual([])
expect(body.pagination).toEqual({ page: 1, limit: 20, total: 0 })
})
it('liste avec items : renvoie les 3 items projetés aux champs autorisés', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionsList({
data: [
{
id: 'p1',
tache: 'EE_T1',
mode: 'entrainement',
score: 14,
nclc: 9,
nclc_cible: 9,
created_at: '2026-04-22T12:00:00Z',
},
{
id: 'p2',
tache: 'EE_T2',
mode: 'examen',
score: 16,
nclc: 10,
nclc_cible: 10,
created_at: '2026-04-22T11:00:00Z',
},
{
id: 'p3',
tache: 'EE_T3',
mode: 'entrainement',
score: null,
nclc: null,
nclc_cible: null,
created_at: '2026-04-22T10:00:00Z',
},
],
count: 3,
})
const app = createApp()
const res = await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(3)
expect(body.pagination.total).toBe(3)
expect(body.data[0]).toEqual({
id: 'p1',
tache: 'EE_T1',
mode: 'entrainement',
score: 14,
nclc: 9,
nclc_cible: 9,
created_at: '2026-04-22T12:00:00Z',
})
// Champs exclus : contenu, rapport, exercices, modele, etc. — pas de fuite
expect(body.data[0]).not.toHaveProperty('contenu')
expect(body.data[0]).not.toHaveProperty('rapport')
expect(body.data[0]).not.toHaveProperty('exercices')
expect(body.data[0]).not.toHaveProperty('modele')
})
it('pagination par défaut : page=1, limit=20 → range(0, 19)', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 0 })
const app = createApp()
await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(mocks.rangeFn).toHaveBeenCalledWith(0, 19)
})
it('?page=2&limit=10 → range(10, 19), pagination reflète les params', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 42 })
const app = createApp()
const res = await app.request('/simulations?page=2&limit=10', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(mocks.rangeFn).toHaveBeenCalledWith(10, 19)
expect(body.pagination).toEqual({ page: 2, limit: 10, total: 42 })
})
it('tri `created_at DESC` appliqué côté Supabase', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 0 })
const app = createApp()
await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(mocks.orderFn).toHaveBeenCalledWith('created_at', { ascending: false })
})
it('filtre `user_id = profile.id` appliqué', async () => {
const profile = buildProfile({ id: 'user-XYZ' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 0 })
const app = createApp()
await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(mocks.eqFn).toHaveBeenCalledWith('user_id', 'user-XYZ')
})
it('select projette uniquement les champs autorisés + count exact', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 0 })
const app = createApp()
await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(mocks.selectFn).toHaveBeenCalledWith(
'id, tache, mode, score, nclc, nclc_cible, created_at',
{ count: 'exact' },
)
})
it('?limit=100 (>50) → 400 VALIDATION_ERROR', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const app = createApp()
const res = await app.request('/simulations?limit=100', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
expect(body.message).toMatch(/limit/i)
})
it('?page=abc (non-numérique) → 400 VALIDATION_ERROR', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const app = createApp()
const res = await app.request('/simulations?page=abc', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
expect(body.message).toMatch(/page/i)
})
it('?page=0 → 400 VALIDATION_ERROR', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const app = createApp()
const res = await app.request('/simulations?page=0', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
it('sans JWT → 401 AUTH_REQUIRED', async () => {
// Pas de mockAuth — le middleware refuse.
const app = createApp()
const res = await app.request('/simulations')
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe('AUTH_REQUIRED')
})
it('erreur Supabase sur la requête → 500 INTERNAL_ERROR', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionsList({ data: [], count: null, error: { message: 'db down' } })
const app = createApp()
const res = await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(500)
const body = await res.json()
expect(body.code).toBe('INTERNAL_ERROR')
})
})

View file

@ -131,6 +131,78 @@ export async function create(
}
}
// Sprint 3.7 — liste paginée des productions de l'utilisateur connecté.
// Renvoie uniquement les champs utiles à l'affichage en liste (pas de contenu,
// rapport, exercices, modele — trop lourds).
export interface ListOptions {
page: number
limit: number
}
export interface ListItem {
id: string
tache: Tache
mode: Mode
score: number | null
nclc: number | null
nclc_cible: 9 | 10 | null
created_at: string
}
export interface ListResult {
data: ListItem[]
pagination: {
page: number
limit: number
total: number
}
}
type ListError = ControllerError
export async function list(
options: ListOptions,
profile: AuthProfile,
): Promise<{ data: ListResult } | ListError> {
const { page, limit } = options
const offset = (page - 1) * limit
const { data, error, count } = await supabase
.from('productions')
.select('id, tache, mode, score, nclc, nclc_cible, created_at', { count: 'exact' })
.eq('user_id', profile.id)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (error) {
return {
error: true,
code: 'INTERNAL_ERROR',
message: 'Impossible de charger les simulations.',
status: 500,
}
}
const items: ListItem[] = (data ?? []).map((row) => ({
id: row.id as string,
tache: row.tache as Tache,
mode: row.mode as Mode,
score: (row.score as number | null) ?? null,
nclc: (row.nclc as number | null) ?? null,
nclc_cible:
row.nclc_cible === 9 || row.nclc_cible === 10 ? (row.nclc_cible as 9 | 10) : null,
created_at: row.created_at as string,
}))
return {
data: {
data: items,
pagination: { page, limit, total: count ?? 0 },
},
}
}
// Sprint 3.6a — structure enrichie (revelation, diagnostic, conseil_nclc,
// erreurs_codes) + statuts des jobs asynchrones (modele, exercices).
//

View file

@ -151,6 +151,47 @@ simulations.patch('/:id/sujet', authMiddleware, async (c) => {
return c.json(result.data, 200)
})
// Sprint 3.7 — liste paginée des productions de l'utilisateur connecté.
// `GET /` est distinct de `GET /:id` côté routeur Hono (match par (méthode, chemin)).
simulations.get('/', authMiddleware, async (c) => {
const rawPage = c.req.query('page')
const rawLimit = c.req.query('limit')
const page = rawPage === undefined ? 1 : Number(rawPage)
const limit = rawLimit === undefined ? 20 : Number(rawLimit)
if (!Number.isInteger(page) || page < 1) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: '`page` doit être un entier supérieur ou égal à 1.',
},
400
)
}
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: '`limit` doit être un entier entre 1 et 50.',
},
400
)
}
const profile = c.get('profile')
const result = await simulationController.list({ page, limit }, profile)
if ('error' in result) {
return c.json(result, result.status as 500)
}
return c.json(result.data, 200)
})
// GET /:id en dernier — route catch-all par id, ne doit pas masquer les routes plus spécifiques.
simulations.get('/:id', authMiddleware, async (c) => {
// `:id` est garanti présent par le pattern de route Hono