import { describe, it, expect } from "vitest"; import { ValidationError, sanitizeSearchString, parseLimitOffset, pagination, parseBbox, parseZoom, cellSizeForZoom, parseCsvList, parseYear, parseLat, parseLon, parseMaId, } from "../src/validate.js"; describe("sanitizeSearchString", () => { it("trim et accepte une recherche normale", () => { expect(sanitizeSearchString(" mayhem ")).toBe("mayhem"); }); it.each([ ["trop courte", "a"], ["vide", " "], ["trop longue", "x".repeat(101)], ])("rejette une chaîne %s", (_label, input) => { expect(() => sanitizeSearchString(input)).toThrow(ValidationError); }); it("accepte pile 2 et 100 caractères (bornes incluses)", () => { expect(sanitizeSearchString("ab")).toBe("ab"); expect(sanitizeSearchString("y".repeat(100))).toHaveLength(100); }); it("rejette les caractères de contrôle", () => { expect(() => sanitizeSearchString("may\x00hem")).toThrow(/caractères invalides/); expect(() => sanitizeSearchString("may\x7Fhem")).toThrow(/caractères invalides/); }); it("laisse passer tab et newline (hors plage de contrôle bloquée)", () => { expect(sanitizeSearchString("a\tb")).toBe("a\tb"); }); it("rejette les patterns ILIKE pathologiques", () => { expect(() => sanitizeSearchString("%%%")).toThrow(/patterns invalides/); expect(() => sanitizeSearchString("_".repeat(10))).toThrow(/patterns invalides/); expect(() => sanitizeSearchString("%".repeat(10))).toThrow(/patterns invalides/); }); it("autorise un % isolé (recherche partielle légitime)", () => { expect(sanitizeSearchString("100%")).toBe("100%"); }); it("utilise le nom de champ dans le message", () => { expect(() => sanitizeSearchString("a", "Genre")).toThrow(/^Genre /); }); }); describe("parseLimitOffset", () => { it("applique les valeurs par défaut", () => { expect(parseLimitOffset(undefined, undefined)).toEqual({ limit: 1000, offset: 0 }); expect(parseLimitOffset("", "")).toEqual({ limit: 1000, offset: 0 }); }); it("borne le limit au maximum", () => { expect(parseLimitOffset("999999", "0", { maxLimit: 150000 }).limit).toBe(150000); }); it("tronque les valeurs fractionnaires", () => { expect(parseLimitOffset("10.9", "5.9")).toEqual({ limit: 10, offset: 5 }); }); // Régression : `Number("abc")` donnait NaN, propagé jusqu'au LIMIT $n // → erreur pg → 500 au lieu d'un 400 explicite. it("rejette un limit non numérique au lieu de propager NaN", () => { expect(() => parseLimitOffset("abc", "0")).toThrow(ValidationError); expect(() => parseLimitOffset("0", "0")).toThrow(/limit invalide/); expect(() => parseLimitOffset("-5", "0")).toThrow(/limit invalide/); }); it("rejette un offset non numérique ou négatif", () => { expect(() => parseLimitOffset("10", "abc")).toThrow(/offset invalide/); expect(() => parseLimitOffset("10", "-1")).toThrow(/offset invalide/); }); it("rejette un offset au-delà du million", () => { expect(() => parseLimitOffset("10", "1000001")).toThrow(/Offset trop grand/); expect(parseLimitOffset("10", "1000000").offset).toBe(1000000); }); }); describe("pagination", () => { it("valeurs par défaut", () => { expect(pagination({})).toEqual({ page: 1, pageSize: 50, offset: 0 }); }); it("calcule l'offset", () => { expect(pagination({ page: "3", pageSize: "20" })).toEqual({ page: 3, pageSize: 20, offset: 40 }); }); it("borne pageSize à 200 et page à 1 minimum", () => { expect(pagination({ pageSize: "10000" }).pageSize).toBe(200); expect(pagination({ page: "-4" }).page).toBe(1); expect(pagination({ page: "abc" }).page).toBe(1); }); }); describe("parseBbox", () => { it("parse une bbox valide", () => { expect(parseBbox("-5,40,10,55")).toEqual({ minLon: -5, minLat: 40, maxLon: 10, maxLat: 55 }); }); it.each([ ["absente", ""], ["3 valeurs", "1,2,3"], ["5 valeurs", "1,2,3,4,5"], ["non numérique", "a,b,c,d"], ["hors bornes lat", "-5,40,10,95"], ["hors bornes lon", "-181,40,10,55"], ["minLon >= maxLon", "10,40,10,55"], ["minLat >= maxLat", "-5,55,10,40"], ])("rejette une bbox %s", (_label, input) => { expect(() => parseBbox(input)).toThrow(ValidationError); }); }); describe("parseZoom / cellSizeForZoom", () => { it("accepte les bornes 0 et 22", () => { expect(parseZoom("0")).toBe(0); expect(parseZoom("22")).toBe(22); }); it("rejette hors bornes et non numérique", () => { expect(() => parseZoom("-1")).toThrow(/between 0 and 22/); expect(() => parseZoom("23")).toThrow(/between 0 and 22/); expect(() => parseZoom("abc")).toThrow(ValidationError); }); it("la taille de cellule décroît avec le zoom et a un plancher", () => { expect(cellSizeForZoom(0)).toBe(180); expect(cellSizeForZoom(4)).toBeCloseTo(11.25); expect(cellSizeForZoom(22)).toBe(0.01); expect(cellSizeForZoom(11)).toBeGreaterThan(cellSizeForZoom(12)); }); }); describe("parseCsvList", () => { it("découpe, trim et filtre les vides", () => { expect(parseCsvList("fr, de ,, be")).toEqual(["fr", "de", "be"]); }); it("applique la transformation", () => { expect(parseCsvList("fr,de", { transform: (s) => s.toUpperCase() })).toEqual(["FR", "DE"]); }); it("rejette au-delà du maximum", () => { const many = Array.from({ length: 101 }, (_, i) => `c${i}`).join(","); expect(() => parseCsvList(many, { max: 100, label: "pays" })).toThrow(/Max 100 pays/); expect(parseCsvList(many, { max: 101, label: "pays" })).toHaveLength(101); }); }); describe("parseYear", () => { it("renvoie null pour une entrée vide", () => { expect(parseYear("")).toBeNull(); expect(parseYear(null)).toBeNull(); expect(parseYear(undefined)).toBeNull(); }); it("accepte les bornes 1800 et 2100", () => { expect(parseYear("1800")).toBe(1800); expect(parseYear("2100")).toBe(2100); }); it("rejette hors bornes et non numérique", () => { expect(() => parseYear("1799")).toThrow(ValidationError); expect(() => parseYear("2101")).toThrow(ValidationError); expect(() => parseYear("abc", "year_min")).toThrow(/^year_min invalide/); }); }); describe("parseLat / parseLon", () => { it("accepte les bornes", () => { expect(parseLat(-90)).toBe(-90); expect(parseLat(90)).toBe(90); expect(parseLon(-180)).toBe(-180); expect(parseLon(180)).toBe(180); }); it("null efface la valeur", () => { expect(parseLat(null)).toBeNull(); expect(parseLon("")).toBeNull(); }); it("rejette hors bornes", () => { expect(() => parseLat(90.1)).toThrow(/lat invalide/); expect(() => parseLon(-180.1)).toThrow(/lon invalide/); expect(() => parseLat("nord")).toThrow(/lat invalide/); }); // 0 est une coordonnée valide (golfe de Guinée) : ne doit pas être traité // comme une valeur vide par un test de falsiness. it("accepte 0", () => { expect(parseLat(0)).toBe(0); expect(parseLon(0)).toBe(0); }); }); describe("parseMaId", () => { it("accepte un entier positif, y compris au-delà de 2^31", () => { expect(parseMaId("3500000000")).toBe(3500000000); expect(parseMaId(0)).toBe(0); }); it("rejette négatif, décimal et non numérique", () => { expect(() => parseMaId("-1")).toThrow(/bad ma_id/); expect(() => parseMaId("1.5")).toThrow(/bad ma_id/); expect(() => parseMaId("abc")).toThrow(/bad ma_id/); }); });