fix: sync du point principal + verrou de migration, redis mort retiré, tests par propriétés
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>
This commit is contained in:
parent
7b53d47d71
commit
43d6527172
10 changed files with 530 additions and 38 deletions
|
|
@ -781,7 +781,14 @@ export default async function adminRoutes(fastify, opts) {
|
|||
const before = await pool.query(`SELECT * FROM band_locations WHERE id = $1`, [id]);
|
||||
if (!before.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
|
||||
|
||||
const r = await pool.query(`
|
||||
// Transaction : la localisation et le point principal du groupe doivent
|
||||
// rester cohérents, sinon la carte et la liste divergent.
|
||||
const client = await pool.connect();
|
||||
let updated;
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const r = await client.query(`
|
||||
UPDATE band_locations
|
||||
SET lat = $2, lon = $3,
|
||||
geocode_status = 'done',
|
||||
|
|
@ -792,12 +799,53 @@ export default async function adminRoutes(fastify, opts) {
|
|||
WHERE id = $1
|
||||
RETURNING *
|
||||
`, [id, latN, lonN]);
|
||||
updated = r.rows[0];
|
||||
|
||||
await writeAuditLog(
|
||||
pool, req.adminUsername, "set_location_coords", "band_locations", id,
|
||||
before.rows[0], r.rows[0]
|
||||
// Réplique de sync_bands_primary() (geocoder/worker.py) : bands.lat/lon
|
||||
// est une dénormalisation du lieu d'ORIGINE, celui de step_order le plus
|
||||
// bas. Sans cet appel, la correction atteignait la carte — qui lit
|
||||
// band_locations — mais jamais la liste, les statistiques ni la
|
||||
// heatmap, qui lisent bands. Le groupe restait affiché au mauvais
|
||||
// endroit indéfiniment.
|
||||
//
|
||||
// Le worker Python fait la même chose après chaque géocodage ; la règle
|
||||
// de tri doit rester identique aux deux endroits.
|
||||
await client.query(`
|
||||
UPDATE bands b
|
||||
SET lat = src.lat,
|
||||
lon = src.lon,
|
||||
geom = ST_SetSRID(ST_MakePoint(src.lon, src.lat), 4326)::geography,
|
||||
geocoded_at = now(),
|
||||
geocode_provider = 'band_locations',
|
||||
geocode_error = NULL,
|
||||
geocode_error_at = NULL
|
||||
FROM (
|
||||
SELECT lat, lon FROM band_locations
|
||||
WHERE ma_id = $1
|
||||
AND geocode_status IN ('done', 'country_only')
|
||||
AND lat IS NOT NULL AND lon IS NOT NULL
|
||||
ORDER BY step_order ASC, id ASC
|
||||
LIMIT 1
|
||||
) AS src
|
||||
WHERE b.ma_id = $1
|
||||
`, [updated.ma_id]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO admin_audit_log (admin_username, action, target_table, target_id, before_data, after_data)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[req.adminUsername, "set_location_coords", "band_locations", String(id),
|
||||
JSON.stringify(before.rows[0]), JSON.stringify(updated)]
|
||||
);
|
||||
return { ok: true, item: r.rows[0] };
|
||||
|
||||
await client.query("COMMIT");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return { ok: true, item: updated };
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.code(500).send({ ok: false, error: "Erreur mise à jour localisation" });
|
||||
|
|
|
|||
|
|
@ -22,9 +22,22 @@ async function connectWithRetry(connStr, maxAttempts = 12) {
|
|||
}
|
||||
}
|
||||
|
||||
// Identifiant arbitraire mais STABLE du verrou consultatif : deux processus qui
|
||||
// migrent la même base doivent choisir le même nombre.
|
||||
const MIGRATION_LOCK_ID = 4_073_219_001;
|
||||
|
||||
async function run() {
|
||||
const client = await connectWithRetry(process.env.DATABASE_URL);
|
||||
|
||||
// Verrou consultatif : le conteneur API exécute ce script à CHAQUE démarrage.
|
||||
// Deux réplicas qui démarrent ensemble lisaient tous deux schema_migrations
|
||||
// vide et appliquaient le même fichier en parallèle — au mieux une erreur au
|
||||
// second (clé primaire dupliquée) qui faisait échouer le démarrage, au pire
|
||||
// deux ALTER concurrents. Le verrou sérialise ; il est relâché à la
|
||||
// fermeture de la connexion, y compris si le process est tué.
|
||||
console.log('[migrate] acquisition du verrou…');
|
||||
await client.query('SELECT pg_advisory_lock($1)', [MIGRATION_LOCK_ID]);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
|
|
@ -65,10 +78,22 @@ async function run() {
|
|||
}
|
||||
|
||||
if (count === 0) console.log('[migrate] nothing to apply');
|
||||
// Relâché explicitement : la fermeture suffirait, mais l'expliciter rend le
|
||||
// verrou visible dans les logs et évite de le garder si end() traîne.
|
||||
await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK_ID]);
|
||||
await client.end();
|
||||
}
|
||||
|
||||
export { MIGRATION_LOCK_ID, run };
|
||||
|
||||
// N'exécute la migration que si le fichier est lancé directement : importé par
|
||||
// un test, il ne doit pas migrer la base au chargement.
|
||||
const invokedDirectly = process.argv[1]
|
||||
&& path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (invokedDirectly) {
|
||||
run().catch(err => {
|
||||
console.error('[migrate] fatal:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
139
apps/api/test/integration/migrate.integration.test.js
Normal file
139
apps/api/test/integration/migrate.integration.test.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import path from "node:path";
|
||||
import pg from "pg";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Migrations : rejouabilité et concurrence.
|
||||
*
|
||||
* `migrate.js` s'exécute au démarrage de CHAQUE conteneur API. C'est donc du
|
||||
* code de production critique — un échec ici empêche l'API de démarrer — et il
|
||||
* n'était couvert par aucun test.
|
||||
*/
|
||||
const BASE = process.env.INTEGRATION_DB_URL || "postgres://bm:bm@127.0.0.1:55432/bm_test";
|
||||
const DB = "bm_migrate_test";
|
||||
const DB_URL = BASE.replace(/\/[^/]+$/, `/${DB}`);
|
||||
const REPO = path.resolve(import.meta.dirname, "../../../..");
|
||||
|
||||
const migrate = () =>
|
||||
run(process.execPath, ["apps/api/src/migrate.js"], {
|
||||
cwd: REPO,
|
||||
env: { ...process.env, DATABASE_URL: DB_URL },
|
||||
});
|
||||
|
||||
let admin, client;
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = new pg.Client({ connectionString: BASE, connectionTimeoutMillis: 4000 });
|
||||
await admin.connect();
|
||||
// Base dédiée : les autres tests d'intégration ne doivent pas être perturbés.
|
||||
await admin.query(`DROP DATABASE IF EXISTS ${DB}`);
|
||||
await admin.query(`CREATE DATABASE ${DB}`);
|
||||
client = new pg.Client({ connectionString: DB_URL });
|
||||
await client.connect();
|
||||
await client.query("CREATE EXTENSION IF NOT EXISTS postgis");
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await client?.end().catch(() => {});
|
||||
await admin?.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {});
|
||||
await admin?.end().catch(() => {});
|
||||
});
|
||||
|
||||
describe("migrate.js", () => {
|
||||
it("applique toutes les migrations sur une base vierge", async () => {
|
||||
const { stdout } = await migrate();
|
||||
expect(stdout).toMatch(/applying 001_initial_schema\.sql/);
|
||||
expect(stdout).toMatch(/applying 015_service_health\.sql/);
|
||||
|
||||
const r = await client.query("SELECT count(*)::int AS n FROM schema_migrations");
|
||||
expect(r.rows[0].n).toBeGreaterThanOrEqual(15);
|
||||
}, 60000);
|
||||
|
||||
it("est rejouable : un second passage n'applique rien", async () => {
|
||||
const { stdout } = await migrate();
|
||||
expect(stdout).toMatch(/nothing to apply/);
|
||||
expect(stdout).not.toMatch(/applying/);
|
||||
}, 60000);
|
||||
|
||||
it("enregistre chaque fichier une seule fois", async () => {
|
||||
const r = await client.query(`
|
||||
SELECT version, count(*)::int AS n FROM schema_migrations
|
||||
GROUP BY version HAVING count(*) > 1
|
||||
`);
|
||||
expect(r.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("applique les fichiers dans l'ordre lexicographique", async () => {
|
||||
const r = await client.query("SELECT version FROM schema_migrations ORDER BY applied_at, version");
|
||||
const versions = r.rows.map((x) => x.version);
|
||||
expect(versions).toEqual([...versions].sort());
|
||||
});
|
||||
|
||||
/**
|
||||
* Le cas qui motivait le verrou consultatif : deux conteneurs API qui
|
||||
* démarrent en même temps lisaient tous deux schema_migrations vide et
|
||||
* appliquaient les mêmes fichiers en parallèle.
|
||||
*/
|
||||
it("deux migrations simultanées sur une base vierge n'entrent pas en conflit", async () => {
|
||||
await admin.query(`DROP DATABASE IF EXISTS ${DB}_par`);
|
||||
await admin.query(`CREATE DATABASE ${DB}_par`);
|
||||
const parUrl = `${DB_URL}_par`;
|
||||
const c = new pg.Client({ connectionString: parUrl });
|
||||
await c.connect();
|
||||
await c.query("CREATE EXTENSION IF NOT EXISTS postgis");
|
||||
|
||||
const runPar = () =>
|
||||
run(process.execPath, ["apps/api/src/migrate.js"], {
|
||||
cwd: REPO, env: { ...process.env, DATABASE_URL: parUrl },
|
||||
});
|
||||
|
||||
// Les deux doivent réussir : le verrou sérialise, le second constate que
|
||||
// tout est déjà appliqué.
|
||||
const [a, b] = await Promise.all([runPar(), runPar()]);
|
||||
const outputs = [a.stdout, b.stdout];
|
||||
|
||||
expect(outputs.some((o) => /applying 001_initial_schema/.test(o))).toBe(true);
|
||||
expect(outputs.some((o) => /nothing to apply/.test(o))).toBe(true);
|
||||
|
||||
const r = await c.query(`
|
||||
SELECT version, count(*)::int AS n FROM schema_migrations
|
||||
GROUP BY version HAVING count(*) > 1
|
||||
`);
|
||||
expect(r.rows).toEqual([]);
|
||||
|
||||
await c.end();
|
||||
await admin.query(`DROP DATABASE IF EXISTS ${DB}_par`);
|
||||
}, 120000);
|
||||
|
||||
it("le verrou est relâché à la fin (une migration ultérieure n'attend pas)", async () => {
|
||||
const held = await client.query(
|
||||
"SELECT count(*)::int AS n FROM pg_locks WHERE locktype = 'advisory'"
|
||||
);
|
||||
expect(held.rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Une migration invalide doit faire échouer le démarrage bruyamment, avec un
|
||||
* code de sortie non nul : Docker/Coolify s'en servent pour redémarrer, et un
|
||||
* schéma à moitié appliqué est pire qu'un conteneur qui refuse de démarrer.
|
||||
*/
|
||||
it("échoue avec un code non nul si une migration est invalide", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const bad = path.join(REPO, "apps/api/migrations/999_temoin_invalide.sql");
|
||||
fs.writeFileSync(bad, "CECI N'EST PAS DU SQL;\n");
|
||||
try {
|
||||
await expect(migrate()).rejects.toMatchObject({ code: 1 });
|
||||
// La migration fautive ne doit pas être enregistrée comme appliquée.
|
||||
const r = await client.query(
|
||||
"SELECT 1 FROM schema_migrations WHERE version = '999_temoin_invalide.sql'"
|
||||
);
|
||||
expect(r.rows).toEqual([]);
|
||||
} finally {
|
||||
fs.unlinkSync(bad);
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
|
@ -227,6 +227,43 @@ describe("PATCH /admin/api/locations/:id — coordonnées manuelles", () => {
|
|||
expect(sql).toContain("geocode_provider = 'admin'");
|
||||
});
|
||||
|
||||
/**
|
||||
* bands.lat/lon est une dénormalisation du lieu d'origine (voir
|
||||
* sync_bands_primary dans geocoder/worker.py). Sans cette synchronisation, la
|
||||
* correction atteignait la carte — qui lit band_locations — mais jamais la
|
||||
* liste, les statistiques ni la heatmap, qui lisent bands.
|
||||
*/
|
||||
it("propage la correction au point principal du groupe", async () => {
|
||||
const a = buildApp(okHandlers());
|
||||
await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 59.9, lon: 10.7 } });
|
||||
|
||||
const sync = pool.find("UPDATE bands b");
|
||||
expect(sync).toBeDefined();
|
||||
// Même règle de tri que le worker Python : le lieu d'ORIGINE gagne.
|
||||
expect(sync.sql).toContain("ORDER BY step_order ASC, id ASC");
|
||||
expect(sync.sql).toContain("geocode_status IN ('done', 'country_only')");
|
||||
expect(sync.sql).toContain("ST_SetSRID(ST_MakePoint(src.lon, src.lat), 4326)");
|
||||
});
|
||||
|
||||
it("l'écriture et la synchronisation sont dans une même transaction", async () => {
|
||||
const a = buildApp(okHandlers());
|
||||
await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
|
||||
expect(pool.sequence(["BEGIN", "UPDATE band_locations", "UPDATE bands b", "COMMIT"]))
|
||||
.toEqual(["BEGIN", "UPDATE band_locations", "UPDATE bands b", "COMMIT"]);
|
||||
});
|
||||
|
||||
it("annule tout si la synchronisation échoue", async () => {
|
||||
const a = buildApp([
|
||||
{ match: "SELECT * FROM band_locations", result: rows({ id: 9 }) },
|
||||
{ match: "UPDATE band_locations", result: rows({ id: 9, ma_id: 1, lat: 1, lon: 2 }) },
|
||||
{ match: "UPDATE bands b", throws: new Error("deadlock") },
|
||||
]);
|
||||
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(pool.rolledBack).toBe(true);
|
||||
expect(pool.committed).toBe(false);
|
||||
});
|
||||
|
||||
it("accepte le point (0, 0), qui est une coordonnée valide", async () => {
|
||||
const a = buildApp(okHandlers());
|
||||
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 0, lon: 0 } });
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ const UNSUPPORTED = [
|
|||
/make_interval\(/,
|
||||
// bands[1:5] : découpage de tableau propre à Postgres
|
||||
/\[1:5\]/,
|
||||
// `UPDATE ... FROM (sous-requête) AS src` : syntaxe Postgres non modélisée.
|
||||
// Celle-ci EST validée par la suite d'intégration, contre un vrai serveur.
|
||||
/UPDATE bands b\s+SET/,
|
||||
// Requêtes de contrôle de transaction
|
||||
/^(BEGIN|COMMIT|ROLLBACK)$/,
|
||||
];
|
||||
|
|
|
|||
216
apps/api/test/validate.property.test.js
Normal file
216
apps/api/test/validate.property.test.js
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,4 @@
|
|||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- bm_dev_redis_data:/data
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
flaresolverr:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
environment:
|
||||
|
|
@ -167,5 +159,4 @@ networks:
|
|||
name: coolify
|
||||
|
||||
volumes:
|
||||
bm_dev_redis_data:
|
||||
bm_dev_pgadmin_data:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- bm_redis_data:/data
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
geocoder-enqueue:
|
||||
build:
|
||||
context: apps/geocoder
|
||||
|
|
@ -139,5 +131,4 @@ networks:
|
|||
name: coolify
|
||||
|
||||
volumes:
|
||||
bm_redis_data:
|
||||
pgadmin_data:
|
||||
|
|
|
|||
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
|||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"axe-core": "^4.10.2",
|
||||
"eslint": "^9.17.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"node-sql-parser": "^5.4.0",
|
||||
|
|
@ -3687,6 +3688,29 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz",
|
||||
"integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-content-type-parse": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
||||
|
|
@ -5510,6 +5534,23 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"axe-core": "^4.10.2",
|
||||
"eslint": "^9.17.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"node-sql-parser": "^5.4.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue