metalfrom.eu/apps/api/test/helpers/fakePool.js
Nicolas Fryder 60074fb015
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(qualité): outillage de test complet, CI locale, annulation réelle des runs
Le dépôt n'avait aucun test, aucun linter, aucune vérification de types.

Outillage
- ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python
- tsc --checkJs sur l'API (pas de TypeScript, juste la vérification)
- Vitest : 401 tests JS ; pytest : 43 tests Python
- Tests de mutation (Stryker), deux profils : logique pure et API complète
- Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook,
  c'est donc la seule porte de qualité avant la mise en ligne
- Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré)

Sécurité
- Injection SQL authentifiée dans resolve-conflict : `field` était interpolé
  dans le SET sans allowlist
- timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401)
- setErrorHandler écrasait tous les 4xx en 500
- .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part
  alors que leur absence casse toute connexion admin

Annulation réelle des crawl_run (migration 014)
- L'API posait status='error' sans que le crawler en sache rien : le process
  continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur)
- Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler
  écrit lui-même status='cancelled'

Cohérence géographique (migration 014)
- Le trigger 013 supprimait les band_locations sans purger le point dénormalisé
- L'édition admin de lat/lon n'atteignait jamais band_locations : la carte
  ignorait la correction. Override step_order = -1, dans une transaction

Corrections
- limit/offset NaN → 500 au lieu de 400
- OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie)
- listen() sans catch, cast ::text en dur sur les colonnes numériques
- /admin/api/logs ne renvoyait pas sa pagination
- a11y : sélecteur de langue annoncé comme liste vide (role=option manquant)

Nettoyage
- apps/web/quizz-site supprimé (sans rapport avec le projet)
- Code mort : openModal(), LANG_NAMES, double import, variables inutilisées
- .dockerignore ajoutés ; node_modules racine n'était pas gitignoré

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 10:05:40 +02:00

110 lines
3.6 KiB
JavaScript

/**
* Faux `pg.Pool` pour les tests de routes.
*
* Objectif : tester le routage, l'authentification, la validation d'entrée et
* la *forme* du SQL généré (allowlists de colonnes, casts, transactions) — sans
* démarrer de conteneur Postgres. Un test qui monte un conteneur met des
* dizaines de secondes ; celui-ci tourne en millisecondes.
*
* `handlers` associe un fragment de SQL (sous-chaîne ou RegExp) à un résultat.
* Toutes les requêtes exécutées sont enregistrées dans `pool.calls`.
*
* Le faux client rend les transactions observables : `pool.committed` et
* `pool.rolledBack` permettent d'affirmer qu'une route a bien annulé son travail
* sur erreur, et `pool.released` qu'elle n'a pas fui de connexion.
*/
/**
* @typedef {object} Handler
* @property {string|RegExp} match fragment de SQL à reconnaître
* @property {any} [result] résultat pg, ou fonction (sql, values) => résultat
* @property {Error|string} [throws] fait échouer la requête (chemins d'erreur)
*
* @param {Handler[]} [handlers]
*/
export function makeFakePool(handlers = []) {
const calls = [];
const state = { committed: false, rolledBack: false, released: 0, connections: 0 };
let active = handlers;
async function query(text, values) {
const sql = typeof text === "string" ? text : text.text;
calls.push({ sql, values: values ?? [] });
if (sql === "COMMIT") state.committed = true;
if (sql === "ROLLBACK") state.rolledBack = true;
for (const { match, result, throws } of active) {
const hit = match instanceof RegExp ? match.test(sql) : sql.includes(match);
if (hit) {
if (throws) throw throws instanceof Error ? throws : new Error(String(throws));
return typeof result === "function" ? result(sql, values) : result;
}
}
return { rows: [], rowCount: 0 };
}
const pool = {
calls,
query,
/**
* Reconfigure le pool sans en recréer un.
*
* Permet de construire l'app Fastify UNE fois par fichier de test (14 ms
* par construction, multipliées par ~12 000 exécutions pendant un run de
* mutation) au lieu d'une fois par cas de test.
*/
reset(newHandlers = []) {
active = newHandlers;
calls.length = 0;
state.committed = false;
state.rolledBack = false;
state.released = 0;
state.connections = 0;
return pool;
},
async connect() {
state.connections++;
return {
query,
release() {
state.released++;
},
};
},
get committed() { return state.committed; },
get rolledBack() { return state.rolledBack; },
get released() { return state.released; },
get connections() { return state.connections; },
/** Retrouve la première requête exécutée contenant `fragment`. */
find(fragment) {
return calls.find((c) =>
fragment instanceof RegExp ? fragment.test(c.sql) : c.sql.includes(fragment)
);
},
/** Toutes les requêtes contenant `fragment`. */
findAll(fragment) {
return calls.filter((c) =>
fragment instanceof RegExp ? fragment.test(c.sql) : c.sql.includes(fragment)
);
},
/** Ordre d'exécution des requêtes correspondant à l'un des fragments. */
sequence(fragments) {
return calls
.map((c) => fragments.find((f) => (f instanceof RegExp ? f.test(c.sql) : c.sql.includes(f))))
.filter(Boolean);
},
};
return pool;
}
/** Raccourci : un résultat pg minimal. */
export function rows(...items) {
return { rows: items, rowCount: items.length };
}