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:
parent
14d8d73991
commit
a394ce8429
3 changed files with 357 additions and 0 deletions
|
|
@ -725,3 +725,247 @@ describe('PATCH /simulations/:id/sujet — FTD-21 changement de sujet', () => {
|
||||||
expect(body.code).toBe('VALIDATION_ERROR')
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
// Sprint 3.6a — structure enrichie (revelation, diagnostic, conseil_nclc,
|
||||||
// erreurs_codes) + statuts des jobs asynchrones (modele, exercices).
|
// erreurs_codes) + statuts des jobs asynchrones (modele, exercices).
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,47 @@ simulations.patch('/:id/sujet', authMiddleware, async (c) => {
|
||||||
return c.json(result.data, 200)
|
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.
|
// GET /:id en dernier — route catch-all par id, ne doit pas masquer les routes plus spécifiques.
|
||||||
simulations.get('/:id', authMiddleware, async (c) => {
|
simulations.get('/:id', authMiddleware, async (c) => {
|
||||||
// `:id` est garanti présent par le pattern de route Hono
|
// `:id` est garanti présent par le pattern de route Hono
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue