diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js index c349f9e..c968cc0 100644 --- a/apps/api/src/adminRoutes.js +++ b/apps/api/src/adminRoutes.js @@ -781,23 +781,71 @@ 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(` - UPDATE band_locations - SET lat = $2, lon = $3, - geocode_status = 'done', - geocode_provider = 'admin', - geocode_confidence = 1.0, - geocode_error = NULL, - updated_at = now() - WHERE id = $1 - RETURNING * - `, [id, latN, lonN]); + // 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"); - await writeAuditLog( - pool, req.adminUsername, "set_location_coords", "band_locations", id, - before.rows[0], r.rows[0] - ); - return { ok: true, item: r.rows[0] }; + const r = await client.query(` + UPDATE band_locations + SET lat = $2, lon = $3, + geocode_status = 'done', + geocode_provider = 'admin', + geocode_confidence = 1.0, + geocode_error = NULL, + updated_at = now() + WHERE id = $1 + RETURNING * + `, [id, latN, lonN]); + updated = 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)] + ); + + 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" }); diff --git a/apps/api/src/migrate.js b/apps/api/src/migrate.js index 8694139..ed54764 100644 --- a/apps/api/src/migrate.js +++ b/apps/api/src/migrate.js @@ -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(); } -run().catch(err => { - console.error('[migrate] fatal:', err.message); - process.exit(1); -}); +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); + }); +} diff --git a/apps/api/test/integration/migrate.integration.test.js b/apps/api/test/integration/migrate.integration.test.js new file mode 100644 index 0000000..2a0b292 --- /dev/null +++ b/apps/api/test/integration/migrate.integration.test.js @@ -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); +}); diff --git a/apps/api/test/locations.test.js b/apps/api/test/locations.test.js index 04ef9da..b755d7c 100644 --- a/apps/api/test/locations.test.js +++ b/apps/api/test/locations.test.js @@ -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 } }); diff --git a/apps/api/test/sqlSyntax.test.js b/apps/api/test/sqlSyntax.test.js index 8113502..a20e61c 100644 --- a/apps/api/test/sqlSyntax.test.js +++ b/apps/api/test/sqlSyntax.test.js @@ -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)$/, ]; diff --git a/apps/api/test/validate.property.test.js b/apps/api/test/validate.property.test.js new file mode 100644 index 0000000..48f2db8 --- /dev/null +++ b/apps/api/test/validate.property.test.js @@ -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 }); + }); +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b1d2cb8..7775cbc 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index ea31132..9046d1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/package-lock.json b/package-lock.json index 528ccae..1eeb6e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e5f36f4..0feba7e 100644 --- a/package.json +++ b/package.json @@ -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",