Commit f7ce428c authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Un double clic sur « Enregistrer » créait deux tiers au dossier

Le pendant du filet d'erreurs : celui-ci rattrape ce qui échoue, ce garde
empêche ce qui réussit deux fois.

Trente-et-un des cinquante-sept fichiers qui écrivent n'avaient aucune
protection contre le double envoi. Sur le réseau d'une crise — celui qu'on a
quand le système d'information est chiffré — un bouton qui ne répond pas se
reclique. Le doublon qui en résulte ne se découvre pas le jour même : il se
découvre six mois plus tard, en appelant un prestataire dont on a deux fiches
qui ne disent pas la même chose.

── La règle

Tant qu'une écriture est en vol, une écriture identique — même méthode, même
chemin, même corps — ne repart pas : elle reçoit la promesse de la première. Un
appel réseau, un événement au journal, et les deux appelants voient le même
résultat.

Dans le client d'API et non dans les trente-et-un écrans, pour la raison qui a
valu pour le filet : trente-et-une retouches, ce sont trente-et-une occasions
d'en oublier une, et une de plus au prochain écran écrit.

── Ce que le garde ne fait pas

Il ne dédoublonne pas deux gestes séparés par une réponse : refaire sciemment la
même chose reste permis, et le serveur garde ses propres verrous là où cela
compte — la fenêtre anti-doublon des alertes et des mobilisations. Il ne touche
pas aux lectures, que react-query regroupe déjà. Il laisse passer les dépôts de
fichier : le corps est binaire, l'empreinte coûterait une relecture de plusieurs
mégaoctets, et un dépôt part d'un sélecteur de fichier, pas d'un bouton qu'on
martèle.

Un mot sur les bascules, parce que c'est là qu'un tel garde se trompe
d'ordinaire. Celles du produit portent l'état voulu dans leur corps
(« ajoutee: true »), non l'ordre de l'inverser : deux clics rapides envoient
deux fois le même corps, et n'en faire qu'un ne change pas le résultat. Un
« /basculer » sans corps serait replié à tort — le commentaire du fichier le dit
à qui en écrirait un.

── Six garanties, de comportement et non de forme

J'ai failli n'écrire qu'un contrôle textuel : « le garde est présent dans le
source ». Il aurait été vert sans jamais dire que le garde est juste — cette
session m'a déjà montré une garantie verte au-dessus d'une fonction cassée.

Le module de l'interface est donc chargé et exercé, avec un « fetch » qui compte
les appels et ne répond que lorsqu'on le lui demande : c'est la seule façon de
tenir deux écritures en vol en même temps, c'est-à-dire de reproduire le double
clic. On vérifie que deux écritures identiques ne partent qu'une fois et
partagent leur réponse, qu'un corps ou un chemin différent n'est pas replié,
qu'une écriture aboutie puis refaite repart, qu'un refus libère la clé — sans
quoi on ne pourrait plus réessayer — et que les lectures ne sont pas touchées.

Le chargement passe par un spécificateur calculé : ce fichier appartient au
projet de l'interface, et le compiler dans celui de l'API le priverait de la
bibliothèque du navigateur.

Éprouvé aussi à l'écran : trois clics sur « Ajouter » dans les étiquettes du
dossier, un seul POST, une seule ligne en base.

Co-Authored-By: Claude (RCA)
parent 623db568
Loading
Loading
Loading
Loading
Loading
+100 −0
Changes for apps/api/src/verification/interface.ts: 100 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -164,6 +164,19 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  r.verifie("EX-19", "le filet couvre le tunnel d'entrée comme la crise",
    racine.indexOf("<FiletEchec />") > racine.indexOf("<Application />"));

  // ── Deux fois le même geste ne fait pas deux fois la chose (EX-19) ───────
  //
  // Trente-et-un des cinquante-sept fichiers qui écrivent n'avaient aucun garde
  // contre le double envoi : un double clic sur « Enregistrer » créait deux
  // tiers au dossier. Le garde est dans le client d'API, et c'est donc son
  // comportement qu'on vérifie — pas sa présence dans le source. Un contrôle
  // textuel dirait « la carte est là » sans jamais dire qu'elle est juste.
  //
  // Le module est chargé par un spécificateur calculé : il appartient au projet
  // de l'interface, et l'inclure dans celui-ci le ferait compiler sans la
  // bibliothèque du navigateur.
  await verifierGardeEcriture(r);

  r.verifie("EX-21", "une clé absente retombe sur le français, pas sur un identifiant",
    noyau.includes("EN[fr] ?? fr"));
  r.verifie("EX-21", "la langue du document suit la langue choisie",
@@ -177,3 +190,90 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  r.verifie("EX-17", "le routeur refuse lui-même un chemin d'API",
    routeur.includes('vers.startsWith("/api/")'));
}

/**
 * Le garde anti-double-envoi, éprouvé plutôt que constaté.
 *
 * On remplace « fetch » par un stub qui compte les appels et ne répond que
 * lorsqu'on le lui demande : c'est la seule manière de tenir deux écritures en
 * vol en même temps, c'est-à-dire de reproduire le double clic.
 */
async function verifierGardeEcriture(r: Rapport): Promise<void> {
  const chemin: string = new URL("../../../web/src/api.js", import.meta.url).href;
  const module = await import(chemin) as {
    api: {
      lire: (c: string) => Promise<unknown>;
      poster: (c: string, corps?: unknown) => Promise<unknown>;
    };
  };
  const api = module.api;

  let appels = 0;
  let attentes: (() => void)[] = [];
  const vrai = globalThis.fetch;
  globalThis.fetch = (() => new Promise<Response>((resoudre) => {
    appels += 1;
    attentes.push(() => resoudre(new Response(null, { status: 204 })));
  })) as unknown as typeof fetch;

  const repartir = () => { appels = 0; attentes = []; };
  const repondre = () => { for (const a of attentes) a(); attentes = []; };

  try {
    // Le double clic : même méthode, même chemin, même corps, en vol ensemble.
    const un = api.poster("/api/essai", { valeur: 1 });
    const deux = api.poster("/api/essai", { valeur: 1 });
    r.verifie("EX-19", "deux écritures identiques en vol ne partent qu'une fois",
      appels === 1, `${appels} appel(s)`);
    r.verifie("EX-19", "et les deux appelants reçoivent la même réponse", un === deux);
    repondre();
    await Promise.all([un, deux]);

    // Deux gestes différents restent deux gestes.
    repartir();
    const a = api.poster("/api/essai", { valeur: 1 });
    const b = api.poster("/api/essai", { valeur: 2 });
    const c = api.poster("/api/autre", { valeur: 1 });
    r.verifie("EX-19", "un corps ou un chemin différent n'est pas replié",
      appels === 3, `${appels} appel(s)`);
    repondre();
    await Promise.all([a, b, c]);

    // Refaire sciemment la même chose reste permis.
    repartir();
    const premier = api.poster("/api/essai", { valeur: 1 });
    repondre();
    await premier;
    const second = api.poster("/api/essai", { valeur: 1 });
    r.verifie("EX-19", "la même écriture, une fois la première aboutie, repart",
      appels === 2, `${appels} appel(s)`);
    repondre();
    await second;

    // Un refus libère la clé : sans quoi on ne pourrait plus réessayer.
    repartir();
    globalThis.fetch = (() => Promise.reject(new Error("réseau coupé"))) as unknown as typeof fetch;
    await api.poster("/api/essai", { valeur: 9 }).catch(() => undefined);
    globalThis.fetch = (() => new Promise<Response>((resoudre) => {
      appels += 1;
      attentes.push(() => resoudre(new Response(null, { status: 204 })));
    })) as unknown as typeof fetch;
    const reprise = api.poster("/api/essai", { valeur: 9 });
    r.verifie("EX-19", "une écriture refusée n'interdit pas de réessayer",
      appels === 1, `${appels} appel(s)`);
    repondre();
    await reprise;

    // Les lectures ne passent pas par le garde : react-query les regroupe déjà,
    // et deux écrans qui lisent la même chose doivent pouvoir le faire.
    repartir();
    const l1 = api.lire("/api/essai");
    const l2 = api.lire("/api/essai");
    r.verifie("EX-19", "les lectures ne sont pas repliées par le garde",
      appels === 2, `${appels} appel(s)`);
    repondre();
    await Promise.all([l1, l2]);
  } finally {
    globalThis.fetch = vrai;
  }
}
+53 −4
Changes for apps/web/src/api.ts: 53 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -56,12 +56,61 @@ async function requete<T>(
  return reponse.json() as Promise<T>;
}

/**
 * Le garde : deux fois le même geste ne fait pas deux fois la chose.
 *
 * Trente-et-un des cinquante-sept fichiers qui écrivent n'ont aucun garde
 * contre le double envoi. Un double clic sur « Enregistrer », sur le réseau
 * d'une crise, crée deux tiers identiques au dossier — et c'est le genre de
 * doublon qu'on découvre en appelant, six mois plus tard, un prestataire dont
 * on a deux fiches contradictoires.
 *
 * Le garde tient dans une règle : tant qu'une écriture est en vol, une écriture
 * identique — même méthode, même chemin, même corps — ne repart pas ; elle
 * reçoit la promesse de la première. Un seul appel réseau, un seul événement au
 * journal, et les deux appelants voient le même résultat.
 *
 * Posé ici plutôt que dans chaque écran, pour la même raison que le filet
 * d'erreurs : trente-et-un écrans à retoucher, c'est trente-et-une occasions
 * d'en oublier un, et un de plus au prochain écran écrit.
 *
 * Ce que le garde ne fait pas, et qu'il ne faut pas lui demander :
 *
 *   — Il ne dédoublonne pas deux gestes séparés par une réponse. Refaire
 *     sciemment la même chose reste permis : c'est parfois exactement ce qu'on
 *     veut, et le serveur a ses propres garde-fous là où cela compte — la
 *     fenêtre anti-doublon des alertes et des mobilisations.
 *   — Il ne touche pas aux lectures : react-query les regroupe déjà.
 *   — Il laisse passer les dépôts de fichier. Le corps est binaire, l'empreinte
 *     coûterait le prix d'une relecture de plusieurs mégaoctets, et un dépôt
 *     part d'un sélecteur de fichier — pas d'un bouton qu'on martèle.
 *
 * Une remarque sur les bascules, parce que c'est là qu'un tel garde se trompe
 * d'ordinaire : celles du produit portent l'état voulu dans leur corps
 * (« ajoutee: true »), non l'ordre de l'inverser. Deux clics rapides envoient
 * donc deux fois le même corps, et n'en faire qu'un ne change pas le résultat.
 * Un « /basculer » sans corps, lui, serait replié à tort — s'il en apparaît un,
 * c'est ce commentaire qu'il faudra relire.
 */
const enVol = new Map<string, Promise<unknown>>();

function ecrire<T>(methode: string, chemin: string, corps?: unknown): Promise<T> {
  const cle = `${methode} ${chemin} ${corps === undefined ? "" : JSON.stringify(corps)}`;
  const deja = enVol.get(cle);
  if (deja !== undefined) return deja as Promise<T>;
  // « finally » plutôt que deux « then » : la clé se libère que l'écriture ait
  // abouti ou échoué, sans quoi un refus interdirait de réessayer.
  const envoi = requete<T>(methode, chemin, corps).finally(() => { enVol.delete(cle); });
  enVol.set(cle, envoi);
  return envoi;
}

export const api = {
  lire: <T>(chemin: string) => requete<T>("GET", chemin),
  poster: <T>(chemin: string, corps?: unknown) => requete<T>("POST", chemin, corps),
  modifier: <T>(chemin: string, corps?: unknown) => requete<T>("PATCH", chemin, corps),
  remplacer: <T>(chemin: string, corps?: unknown) => requete<T>("PUT", chemin, corps),
  supprimer: <T>(chemin: string) => requete<T>("DELETE", chemin),
  poster: <T>(chemin: string, corps?: unknown) => ecrire<T>("POST", chemin, corps),
  modifier: <T>(chemin: string, corps?: unknown) => ecrire<T>("PATCH", chemin, corps),
  remplacer: <T>(chemin: string, corps?: unknown) => ecrire<T>("PUT", chemin, corps),
  supprimer: <T>(chemin: string) => ecrire<T>("DELETE", chemin),
  deposer: <T>(chemin: string, octets: Uint8Array) => requete<T>("POST", chemin, octets),
};