Passe critique sur les zones non vérifiées. BUG — la correction manuelle d'un lieu n'atteignait pas la moitié du site bands.lat/lon/geom est une dénormalisation du lieu d'origine, maintenue par sync_bands_primary() dans geocoder/worker.py. La saisie manuelle de coordonnées (PATCH /admin/api/locations/:id, ajoutée récemment) écrivait band_locations sans jamais la déclencher : la correction apparaissait sur la carte — qui lit band_locations — mais jamais dans la liste, les statistiques ni la heatmap, qui lisent bands. Le groupe restait affiché au mauvais endroit indéfiniment. La synchronisation est répliquée en SQL dans la même transaction, avec la même règle de tri (step_order ASC, id ASC) que le worker Python. BUG — course entre réplicas au démarrage (migrate.js) Le script s'exécute au démarrage de CHAQUE conteneur API. Deux réplicas démarrant ensemble lisaient tous deux schema_migrations vide et appliquaient les mêmes fichiers en parallèle : au mieux une violation de clé primaire qui faisait échouer le démarrage, au pire deux ALTER concurrents. Verrou consultatif pg_advisory_lock, relâché explicitement. migrate.js n'exécute plus au chargement s'il est importé (nécessaire pour le tester). CODE MORT — redis Signalé au tout début, jamais retiré : un conteneur redis + un volume persistant dans les DEUX composes, sans une seule référence dans le code. TESTS AJOUTÉS - Intégration migrations (7 tests) : application sur base vierge, rejouabilité, ordre lexicographique, relâchement du verrou, échec bruyant sur migration invalide, et surtout DEUX MIGRATIONS SIMULTANÉES sur une base vierge — le cas qui motivait le verrou. - Tests par PROPRIÉTÉS (fast-check, 21 tests) : batterie qui manquait. Les tests par l'exemple ne couvrent que les cas auxquels on a pensé. L'invariant central : toute entrée arbitraire produit soit une valeur normalisée valide, soit une ValidationError — jamais une autre exception, jamais NaN. C'est ce qui garantit un 400 plutôt qu'un 500. Vérifie aussi la cohérence offset = (page-1) × pageSize, le domaine des coordonnées, et qu'aucun caractère de contrôle ne survit à la validation. Tests : 601 JS + 61 intégration, 63 Python, 89 Playwright Co-Authored-By: Claude <noreply@anthropic.com>
216 lines
8.3 KiB
JavaScript
216 lines
8.3 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
import fc from "fast-check";
|
|
import {
|
|
ValidationError, sanitizeSearchString, parseLimitOffset, pagination,
|
|
parseBbox, parseZoom, cellSizeForZoom, parseCsvList, parseYear,
|
|
parseLat, parseLon, parseMaId,
|
|
} from "../src/validate.js";
|
|
|
|
/**
|
|
* Tests par PROPRIÉTÉS.
|
|
*
|
|
* Les tests par l'exemple ne couvrent que les cas auxquels on a pensé. Ceux-ci
|
|
* énoncent des invariants et laissent fast-check chercher des contre-exemples
|
|
* sur des milliers d'entrées, y compris celles qu'on n'aurait pas imaginées
|
|
* (chaînes Unicode, -0, 1e21, espaces exotiques…).
|
|
*
|
|
* L'invariant qui compte pour toute cette couche : une entrée arbitraire
|
|
* produit soit une valeur normalisée valide, soit une ValidationError — jamais
|
|
* une exception d'un autre type, jamais NaN. C'est ce qui garantit un 400 et
|
|
* non un 500.
|
|
*/
|
|
|
|
/** Aucune entrée ne doit produire autre chose qu'un résultat ou ValidationError. */
|
|
function totality(fn) {
|
|
return (input) => {
|
|
try {
|
|
return { ok: true, value: fn(input) };
|
|
} catch (err) {
|
|
if (!(err instanceof ValidationError)) {
|
|
throw new Error(`exception inattendue (${err.constructor.name}: ${err.message})`);
|
|
}
|
|
return { ok: false };
|
|
}
|
|
};
|
|
}
|
|
|
|
describe("totalité : jamais d'exception hors ValidationError", () => {
|
|
const anything = fc.oneof(
|
|
fc.string(), fc.integer(), fc.double(), fc.constant(null),
|
|
fc.constant(undefined), fc.constant(""), fc.boolean(),
|
|
fc.string({ unit: "grapheme" }),
|
|
);
|
|
|
|
it.each([
|
|
["sanitizeSearchString", (v) => sanitizeSearchString(v)],
|
|
["parseYear", (v) => parseYear(v)],
|
|
["parseLat", (v) => parseLat(v)],
|
|
["parseLon", (v) => parseLon(v)],
|
|
["parseMaId", (v) => parseMaId(v)],
|
|
["parseZoom", (v) => parseZoom(v)],
|
|
["parseBbox", (v) => parseBbox(v)],
|
|
])("%s", (_n, fn) => {
|
|
fc.assert(fc.property(anything, (v) => { totality(fn)(v); }), { numRuns: 400 });
|
|
});
|
|
|
|
it("parseLimitOffset", () => {
|
|
fc.assert(fc.property(anything, anything, (a, b) => {
|
|
totality(() => parseLimitOffset(a, b))(null);
|
|
}), { numRuns: 400 });
|
|
});
|
|
|
|
it("pagination ne lève jamais", () => {
|
|
fc.assert(fc.property(
|
|
fc.record({ page: fc.option(anything), pageSize: fc.option(anything) }),
|
|
(q) => { pagination(q); }
|
|
), { numRuns: 400 });
|
|
});
|
|
});
|
|
|
|
describe("bornes : ce qui sort respecte toujours le contrat", () => {
|
|
it("pagination reste dans ses bornes et l'offset est cohérent", () => {
|
|
fc.assert(fc.property(
|
|
fc.record({ page: fc.oneof(fc.integer(), fc.string()), pageSize: fc.oneof(fc.integer(), fc.string()) }),
|
|
(q) => {
|
|
const { page, pageSize, offset } = pagination(q);
|
|
expect(page).toBeGreaterThanOrEqual(1);
|
|
expect(pageSize).toBeGreaterThanOrEqual(1);
|
|
expect(pageSize).toBeLessThanOrEqual(200);
|
|
// Invariant liant les trois : c'est lui qui garantit une pagination
|
|
// sans trou ni doublon.
|
|
expect(offset).toBe((page - 1) * pageSize);
|
|
expect(Number.isInteger(offset)).toBe(true);
|
|
}
|
|
), { numRuns: 500 });
|
|
});
|
|
|
|
it("parseLimitOffset ne renvoie jamais NaN et respecte le plafond", () => {
|
|
fc.assert(fc.property(
|
|
fc.oneof(fc.integer(), fc.double(), fc.string()),
|
|
fc.oneof(fc.integer(), fc.double(), fc.string()),
|
|
(l, o) => {
|
|
let r;
|
|
try { r = parseLimitOffset(l, o, { maxLimit: 150000 }); }
|
|
catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
|
|
// Un NaN ici partait dans « LIMIT $n » et donnait un 500.
|
|
expect(Number.isInteger(r.limit)).toBe(true);
|
|
expect(Number.isInteger(r.offset)).toBe(true);
|
|
expect(r.limit).toBeGreaterThanOrEqual(1);
|
|
expect(r.limit).toBeLessThanOrEqual(150000);
|
|
expect(r.offset).toBeGreaterThanOrEqual(0);
|
|
}
|
|
), { numRuns: 500 });
|
|
});
|
|
|
|
it("les coordonnées acceptées sont toujours dans leur domaine", () => {
|
|
fc.assert(fc.property(fc.double({ noNaN: false }), (v) => {
|
|
/** @type {[(v:any)=>number|null, number][]} */
|
|
const cases = [[parseLat, 90], [parseLon, 180]];
|
|
for (const [fn, max] of cases) {
|
|
let r;
|
|
try { r = fn(v); } catch (e) { expect(e).toBeInstanceOf(ValidationError); continue; }
|
|
if (r !== null) {
|
|
expect(Number.isFinite(r)).toBe(true);
|
|
expect(Math.abs(r)).toBeLessThanOrEqual(max);
|
|
}
|
|
}
|
|
}), { numRuns: 500 });
|
|
});
|
|
|
|
it("une année acceptée est toujours dans [1800, 2100]", () => {
|
|
fc.assert(fc.property(fc.oneof(fc.integer(), fc.string()), (v) => {
|
|
let r;
|
|
try { r = parseYear(v); } catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
|
|
if (r !== null) {
|
|
expect(r).toBeGreaterThanOrEqual(1800);
|
|
expect(r).toBeLessThanOrEqual(2100);
|
|
}
|
|
}), { numRuns: 500 });
|
|
});
|
|
|
|
it("un ma_id accepté est un entier positif sûr", () => {
|
|
fc.assert(fc.property(fc.oneof(fc.integer(), fc.double(), fc.string()), (v) => {
|
|
let r;
|
|
try { r = parseMaId(v); } catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
|
|
expect(Number.isSafeInteger(r)).toBe(true);
|
|
expect(r).toBeGreaterThanOrEqual(0);
|
|
}), { numRuns: 500 });
|
|
});
|
|
|
|
it("une bbox acceptée est toujours géographiquement cohérente", () => {
|
|
fc.assert(fc.property(
|
|
fc.tuple(fc.double(), fc.double(), fc.double(), fc.double()),
|
|
(parts) => {
|
|
let r;
|
|
try { r = parseBbox(parts.join(",")); }
|
|
catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
|
|
expect(r.minLon).toBeLessThan(r.maxLon);
|
|
expect(r.minLat).toBeLessThan(r.maxLat);
|
|
expect(r.minLat).toBeGreaterThanOrEqual(-90);
|
|
expect(r.maxLat).toBeLessThanOrEqual(90);
|
|
}
|
|
), { numRuns: 500 });
|
|
});
|
|
|
|
it("la taille de cellule décroît avec le zoom et garde son plancher", () => {
|
|
fc.assert(fc.property(fc.integer({ min: 0, max: 22 }), fc.integer({ min: 0, max: 22 }), (a, b) => {
|
|
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
|
expect(cellSizeForZoom(lo)).toBeGreaterThanOrEqual(cellSizeForZoom(hi));
|
|
expect(cellSizeForZoom(hi)).toBeGreaterThanOrEqual(0.01);
|
|
}), { numRuns: 300 });
|
|
});
|
|
});
|
|
|
|
describe("propriétés de la recherche", () => {
|
|
it("une chaîne acceptée fait entre 2 et 100 caractères et est trimée", () => {
|
|
fc.assert(fc.property(fc.string({ unit: "grapheme" }), (v) => {
|
|
let r;
|
|
try { r = sanitizeSearchString(v); }
|
|
catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
|
|
expect(r.length).toBeGreaterThanOrEqual(2);
|
|
expect(r.length).toBeLessThanOrEqual(100);
|
|
expect(r).toBe(r.trim());
|
|
}), { numRuns: 600 });
|
|
});
|
|
|
|
// Ce qui est rejeté doit le rester quelle que soit l'enveloppe d'espaces :
|
|
// sinon un motif pathologique passerait en le préfixant d'un espace.
|
|
it("les espaces autour ne changent jamais la décision", () => {
|
|
fc.assert(fc.property(
|
|
fc.string({ minLength: 1, maxLength: 60 }),
|
|
fc.stringMatching(/^[ \t]{0,5}$/),
|
|
(core, pad) => {
|
|
const decide = (s) => { try { sanitizeSearchString(s); return true; } catch { return false; } };
|
|
expect(decide(core)).toBe(decide(`${pad}${core}${pad}`));
|
|
}
|
|
), { numRuns: 400 });
|
|
});
|
|
|
|
it("aucun caractère de contrôle ne survit à la validation", () => {
|
|
fc.assert(fc.property(fc.string({ unit: "grapheme", minLength: 2 }), (v) => {
|
|
let r;
|
|
try { r = sanitizeSearchString(v); } catch { return; }
|
|
// eslint-disable-next-line no-control-regex
|
|
expect(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(r)).toBe(false);
|
|
}), { numRuns: 600 });
|
|
});
|
|
});
|
|
|
|
describe("propriétés des listes CSV", () => {
|
|
it("aucun élément vide, et le maximum est respecté", () => {
|
|
fc.assert(fc.property(fc.array(fc.string(), { maxLength: 60 }), (items) => {
|
|
let r;
|
|
try { r = parseCsvList(items.join(","), { max: 50, label: "test" }); }
|
|
catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
|
|
expect(r.every((x) => x.length > 0)).toBe(true);
|
|
expect(r.length).toBeLessThanOrEqual(50);
|
|
}), { numRuns: 400 });
|
|
});
|
|
|
|
it("la transformation est appliquée à chaque élément", () => {
|
|
fc.assert(fc.property(fc.array(fc.string({ minLength: 1 }), { maxLength: 20 }), (items) => {
|
|
const r = parseCsvList(items.join(","), { transform: (s) => s.toUpperCase() });
|
|
expect(r.every((x) => x === x.toUpperCase())).toBe(true);
|
|
}), { numRuns: 300 });
|
|
});
|
|
});
|