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>
145 lines
5.4 KiB
JavaScript
145 lines
5.4 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
import { makeFakePool, rows } from "./helpers/fakePool.js";
|
|
|
|
/**
|
|
* Méta-tests du harnais.
|
|
*
|
|
* Presque toutes les assertions du projet s'appuient sur `makeFakePool` :
|
|
* si ce double se met à tout avaler en silence (un `find` qui renvoie toujours
|
|
* undefined, un `throws` ignoré, un compteur qui ne s'incrémente pas), des
|
|
* dizaines de tests passeraient au vert sans plus rien vérifier.
|
|
*
|
|
* Ces tests vérifient donc l'outil de mesure lui-même.
|
|
*/
|
|
describe("makeFakePool — enregistrement", () => {
|
|
it("enregistre chaque requête avec son SQL et ses paramètres", async () => {
|
|
const pool = makeFakePool();
|
|
await pool.query("SELECT 1 FROM bands WHERE ma_id = $1", [42]);
|
|
expect(pool.calls).toHaveLength(1);
|
|
expect(pool.calls[0].sql).toContain("FROM bands");
|
|
expect(pool.calls[0].values).toEqual([42]);
|
|
});
|
|
|
|
it("normalise l'absence de paramètres en tableau vide", async () => {
|
|
const pool = makeFakePool();
|
|
await pool.query("SELECT 1");
|
|
expect(pool.calls[0].values).toEqual([]);
|
|
});
|
|
|
|
it("accepte la forme objet { text }", async () => {
|
|
const pool = makeFakePool();
|
|
await pool.query({ text: "SELECT 1 FROM bands" }, []);
|
|
expect(pool.calls[0].sql).toBe("SELECT 1 FROM bands");
|
|
});
|
|
});
|
|
|
|
describe("makeFakePool — recherche", () => {
|
|
it("find() trouve par sous-chaîne ET renvoie undefined quand rien ne matche", async () => {
|
|
const pool = makeFakePool();
|
|
await pool.query("UPDATE bands SET name = $1", ["x"]);
|
|
|
|
expect(pool.find("UPDATE bands")).toBeDefined();
|
|
// Le cas qui compte : un find() qui renverrait toujours undefined ferait
|
|
// passer au vert tous les `expect(pool.find(...)).toBeUndefined()`.
|
|
expect(pool.find("DELETE FROM bands")).toBeUndefined();
|
|
});
|
|
|
|
it("find() accepte une RegExp", async () => {
|
|
const pool = makeFakePool();
|
|
await pool.query("SELECT a, b FROM bands");
|
|
expect(pool.find(/SELECT\s+a,\s*b/)).toBeDefined();
|
|
expect(pool.find(/SELECT\s+z/)).toBeUndefined();
|
|
});
|
|
|
|
it("findAll() renvoie toutes les occurrences, pas seulement la première", async () => {
|
|
const pool = makeFakePool();
|
|
await pool.query("INSERT INTO bands VALUES (1)");
|
|
await pool.query("INSERT INTO bands VALUES (2)");
|
|
await pool.query("SELECT 1");
|
|
expect(pool.findAll("INSERT INTO bands")).toHaveLength(2);
|
|
expect(pool.findAll("DELETE")).toHaveLength(0);
|
|
});
|
|
|
|
it("sequence() reflète l'ordre réel d'exécution", async () => {
|
|
const pool = makeFakePool();
|
|
await pool.query("BEGIN");
|
|
await pool.query("UPDATE bands SET x = 1");
|
|
await pool.query("COMMIT");
|
|
expect(pool.sequence(["COMMIT", "BEGIN", "UPDATE bands"]))
|
|
.toEqual(["BEGIN", "UPDATE bands", "COMMIT"]);
|
|
});
|
|
});
|
|
|
|
describe("makeFakePool — handlers", () => {
|
|
it("renvoie le résultat du premier handler qui matche", async () => {
|
|
const pool = makeFakePool([
|
|
{ match: "FROM bands", result: rows({ ma_id: 1 }) },
|
|
{ match: "FROM bands", result: rows({ ma_id: 2 }) },
|
|
]);
|
|
expect((await pool.query("SELECT * FROM bands")).rows[0].ma_id).toBe(1);
|
|
});
|
|
|
|
it("renvoie un résultat vide quand aucun handler ne matche", async () => {
|
|
const pool = makeFakePool([{ match: "FROM autre", result: rows({ x: 1 }) }]);
|
|
expect(await pool.query("SELECT * FROM bands")).toEqual({ rows: [], rowCount: 0 });
|
|
});
|
|
|
|
// Si `throws` était ignoré, tous les tests de chemin d'erreur (rollback,
|
|
// 500 sans fuite d'information) passeraient sans rien exercer.
|
|
it("throws fait bien lever la requête", async () => {
|
|
const pool = makeFakePool([{ match: "FROM bands", throws: new Error("boum") }]);
|
|
await expect(pool.query("SELECT * FROM bands")).rejects.toThrow("boum");
|
|
});
|
|
|
|
it("result peut être une fonction du SQL et des paramètres", async () => {
|
|
const pool = makeFakePool([
|
|
{ match: "FROM bands", result: (_sql, values) => rows({ echo: values[0] }) },
|
|
]);
|
|
expect((await pool.query("SELECT * FROM bands WHERE id=$1", [7])).rows[0].echo).toBe(7);
|
|
});
|
|
|
|
it("rows() produit un rowCount cohérent", () => {
|
|
expect(rows()).toEqual({ rows: [], rowCount: 0 });
|
|
expect(rows({ a: 1 }, { a: 2 }).rowCount).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("makeFakePool — transactions", () => {
|
|
it("connect() renvoie un client qui partage le journal des requêtes", async () => {
|
|
const pool = makeFakePool();
|
|
const client = await pool.connect();
|
|
await client.query("SELECT 1 FROM bands");
|
|
expect(pool.find("FROM bands")).toBeDefined();
|
|
});
|
|
|
|
it("détecte COMMIT et ROLLBACK séparément", async () => {
|
|
const committed = makeFakePool();
|
|
const c1 = await committed.connect();
|
|
await c1.query("COMMIT");
|
|
expect(committed.committed).toBe(true);
|
|
expect(committed.rolledBack).toBe(false);
|
|
|
|
const rolled = makeFakePool();
|
|
const c2 = await rolled.connect();
|
|
await c2.query("ROLLBACK");
|
|
expect(rolled.rolledBack).toBe(true);
|
|
expect(rolled.committed).toBe(false);
|
|
});
|
|
|
|
it("compte les connexions ouvertes et libérées", async () => {
|
|
const pool = makeFakePool();
|
|
const client = await pool.connect();
|
|
expect(pool.connections).toBe(1);
|
|
expect(pool.released).toBe(0);
|
|
client.release();
|
|
expect(pool.released).toBe(1);
|
|
});
|
|
|
|
it("un pool neuf ne rapporte ni commit ni rollback", () => {
|
|
const pool = makeFakePool();
|
|
expect(pool.committed).toBe(false);
|
|
expect(pool.rolledBack).toBe(false);
|
|
expect(pool.connections).toBe(0);
|
|
expect(pool.released).toBe(0);
|
|
});
|
|
});
|