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 }); }); });