Files
IliesAllaliandClaude Opus 5.5 0132ecb342 Envois de la famille sans doublon : en-tête Idempotency-Key
Sur un réseau faible, une proposition, un souvenir, une photo posée sur l'arbre ou un moment pouvait être
créé deux fois : la réponse se perdait (délai dépassé), la personne renvoyait, le serveur recréait tout.

- Navigateur (api/client.js) : un envoi qui n'a pas abouti garde sa clé, et le même envoi refait (même
  adresse, même contenu) la réutilise. Une réussite libère la clé.
- Serveur (lib/idempotency.ts) : sur les quatre routes de création, une clé déjà vue par la même personne
  renvoie la réponse du premier envoi ; deux essais simultanés n'en traitent qu'un ; un échec n'est pas gardé.
  En mémoire, 24 h, 5 000 clés au plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:14:32 +01:00

88 lines
2.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Fastify, { type FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { registerIdempotency, resetIdempotency } from '../src/lib/idempotency.js'
// Un envoi rejoué avec la même clé (réponse perdue sur un réseau faible) ne crée rien une seconde fois
let app: FastifyInstance
let created = 0
let failNext = false
const KEY = 'a1b2c3d4-e5f6-4789-9abc-def012345678'
const send = (headers: Record<string, string> = {}, url = '/trees/t1/contributions/sessions') =>
app.inject({ method: 'POST', url, headers: { authorization: 'Bearer famille', ...headers }, payload: { title: 'x' } })
beforeAll(async () => {
app = Fastify()
registerIdempotency(app)
app.post('/trees/:id/contributions/sessions', async (_request, reply) => {
await new Promise((resolve) => setTimeout(resolve, 30))
if (failNext) {
failNext = false
return reply.code(503).send({ error: 'busy' })
}
created += 1
return reply.code(201).send({ session: { id: `s${created}` } })
})
app.post('/trees/:id/autre', async () => {
created += 1
return { ok: created }
})
await app.ready()
})
afterAll(async () => {
await app.close()
})
beforeEach(() => {
created = 0
failNext = false
resetIdempotency()
})
describe('Idempotency-Key', () => {
it('rejoue la première réponse sans recréer', async () => {
const first = await send({ 'idempotency-key': KEY })
const second = await send({ 'idempotency-key': KEY })
expect(created).toBe(1)
expect(second.statusCode).toBe(201)
expect(second.json()).toEqual(first.json())
expect(second.headers['idempotent-replayed']).toBe('true')
})
it('deux essais simultanés : un seul est traité', async () => {
const [a, b] = await Promise.all([send({ 'idempotency-key': KEY }), send({ 'idempotency-key': KEY })])
expect(created).toBe(1)
expect(a.json()).toEqual(b.json())
})
it('un échec n’est pas gardé : l’essai suivant refait la requête', async () => {
failNext = true
const first = await send({ 'idempotency-key': KEY })
const second = await send({ 'idempotency-key': KEY })
expect(first.statusCode).toBe(503)
expect(second.statusCode).toBe(201)
expect(created).toBe(1)
})
it('la clé d’une autre personne ne rejoue rien', async () => {
await send({ 'idempotency-key': KEY })
await send({ 'idempotency-key': KEY, authorization: 'Bearer quelquun-dautre' })
expect(created).toBe(2)
})
it('sans clé, ou avec une clé mal formée, chaque envoi compte', async () => {
await send()
await send()
await send({ 'idempotency-key': 'court' })
await send({ 'idempotency-key': 'court' })
expect(created).toBe(4)
})
it('les autres routes ne sont pas concernées', async () => {
await send({ 'idempotency-key': KEY }, '/trees/t1/autre')
await send({ 'idempotency-key': KEY }, '/trees/t1/autre')
expect(created).toBe(2)
})
})