diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 0000000..e2da28d
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,75 @@
+# CI informative.
+#
+# Le déploiement reste piloté par le webhook Coolify (push → redeploy), CE
+# WORKFLOW NE BLOQUE RIEN : il marque simplement le commit vert ou rouge dans
+# Forgejo. La vraie porte de qualité est le hook `.githooks/pre-push` côté
+# poste de dev (voir README-CI.md).
+#
+# ⚠ Prérequis : un runner Forgejo Actions (act_runner) enregistré sur le VPS.
+# Sans runner, ce fichier est inerte — aucun job ne démarre et aucun statut
+# n'apparaît. C'est le cas aujourd'hui : ce workflow est là pour le jour où
+# un runner sera ajouté.
+name: CI
+
+on:
+ push:
+ branches: [dev, main]
+ pull_request:
+ branches: [dev, main]
+ workflow_dispatch:
+
+jobs:
+ javascript:
+ runs-on: docker
+ container:
+ image: node:22-alpine
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Installer les dépendances
+ run: npm ci
+
+ - name: ESLint
+ run: npm run lint
+
+ - name: Vérification de types (tsc --checkJs)
+ run: npm run typecheck
+
+ - name: Tests unitaires + a11y
+ run: npm run test
+
+ - name: Audit des dépendances
+ run: npm run audit:js
+ continue-on-error: true # informatif : ne doit pas masquer un échec de test
+
+ python:
+ runs-on: docker
+ container:
+ image: python:3.12-slim
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Installer l'outillage
+ run: pip install --no-cache-dir ruff pytest pip-audit
+
+ - name: Ruff (lint + règles de sécurité bandit)
+ run: ruff check .
+
+ - name: Tests du parseur de géocodage
+ run: cd apps/geocoder && python -m pytest tests -q
+
+ - name: Audit des dépendances
+ run: pip-audit -r apps/crawler/requirements.txt -r apps/geocoder/requirements.txt
+ continue-on-error: true
+
+ # Les tests de mutation prennent ~1 min : trop lents pour chaque push, mais
+ # utiles en revue. Déclenchement manuel uniquement.
+ mutation:
+ runs-on: docker
+ container:
+ image: node:22-alpine
+ if: github.event_name == 'workflow_dispatch'
+ steps:
+ - uses: actions/checkout@v4
+ - run: npm ci
+ - run: npm run test:mutation
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100644
index 0000000..b2fda5c
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,32 @@
+#!/bin/sh
+#
+# Porte de qualité locale — c'est ICI que la CI de ce projet se joue.
+#
+# Le déploiement Coolify se déclenche sur webhook à chaque push : rien ne
+# s'interpose entre `git push` et la mise en ligne. Ce hook est donc le dernier
+# filet avant que du code cassé parte en dev.
+#
+# Installation (une fois par clone) : npm run hooks:install
+# Contournement ponctuel : git push --no-verify
+#
+set -e
+
+echo "▶ pre-push : porte de qualité (lint, types, tests)…"
+
+start=$(date +%s)
+
+if ! npm run --silent check; then
+ cat >&2 <<'MSG'
+
+✗ La porte de qualité a échoué — push interrompu.
+
+ Corriger, ou pousser quand même en connaissance de cause :
+ git push --no-verify
+
+ Correction automatique de la plupart des problèmes de style :
+ npm run lint:fix && npm run lint:py:fix
+MSG
+ exit 1
+fi
+
+echo "✓ pre-push : tout est vert ($(( $(date +%s) - start ))s)"
diff --git a/.gitignore b/.gitignore
index 9057a64..95bc819 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,24 @@
infra/.venv/
infra/temp/
infra/geocode-enqueue.log
+infra/.env
+
+# Node
+node_modules/
apps/api/node_modules/
+
+# Python
__pycache__/
*.pyc
*.pyo
-infra/.env
+.pytest_cache/
+.ruff_cache/
+
+# Sorties d'outillage qualité
+coverage/
+reports/
+.stryker-tmp/
+
+# Session Claude Code (contient des secrets)
.claude/
CLAUDE.md
diff --git a/README-CI.md b/README-CI.md
new file mode 100644
index 0000000..379d17b
--- /dev/null
+++ b/README-CI.md
@@ -0,0 +1,134 @@
+# Qualité, tests et CI/CD
+
+## Le modèle en une phrase
+
+Coolify redéploie sur webhook à **chaque** push (`dev` → dev.metalfrom.eu,
+`main` → metalfrom.eu). Rien ne s'interpose. La porte de qualité est donc
+**locale**, dans le hook `pre-push`.
+
+```
+git push ──▶ [hook pre-push : npm run check] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy
+ ↑ ~15 s, bloquant
+```
+
+## Installation (une fois par clone)
+
+```bash
+npm install
+npm run hooks:install # active .githooks/pre-push
+pip install -r requirements-dev.txt
+```
+
+## Commandes et temps mesurés
+
+| Commande | Ce que ça fait | Froid | Incrémental |
+|---|---|---|---|
+| `npm run check` | **La porte** : ESLint + Ruff + tsc + tous les tests | ~15 s | — |
+| `npm test` | 401 tests JS | 3,5 s | — |
+| `npm run test:py` | 43 tests Python | 1 s | — |
+| `npm run lint` / `lint:py` | ESLint / Ruff | 3 s / 1 s | — |
+| `npm run typecheck` | `tsc --checkJs` sur l'API | 6 s | — |
+| `npm run test:mutation` | Mutation, logique pure (352 mutants) | 34 s | ~5 s |
+| `npm run test:mutation:full` | Mutation, toute l'API (1511 mutants) | 5 min | 13 s |
+| `npm run check:full` | `check` + audits de dépendances + mutation complète | — | ~1 min |
+
+### Où passe le temps, et pourquoi c'est acceptable
+
+La commande de la boucle de développement, c'est `npm run check` : **17 s**,
+et elle inclut déjà les 401 tests, ESLint, Ruff et tsc. C'est elle qui tourne
+cent fois par jour.
+
+La mutation n'est pas une commande de boucle courte. Son coût se lit ainsi :
+
+- `test:mutation` (352 mutants) : 34 s à froid, ~5 s ensuite. Assez rapide pour
+ être lancée avant chaque commit qui touche la validation ou l'auth.
+- `test:mutation:full` (1511 mutants) : 5 min **une seule fois** (clone neuf ou
+ après avoir modifié les quatre fichiers mutés d'un coup). Après une édition
+ normale d'un seul fichier : **13 s**, mesuré.
+
+Le fichier incrémental vit dans `reports/`, qui est gitignoré : un clone neuf
+paie donc les 5 minutes une fois. C'est assumé — c'est un audit, pas un test.
+
+**Ce qui n'a délibérément pas été fait pour aller plus vite** : réduire encore
+le périmètre muté. Descendre sous ~1500 mutants sur l'API reviendrait à ne plus
+mesurer grand-chose, et un score de mutation flatteur obtenu en retirant les
+mutants gênants est pire qu'une absence de score.
+
+## Ce que couvrent les tests
+
+| Batterie | Où | Approche |
+|---|---|---|
+| **Unitaires** | `apps/api/test/validate.test.js` | Validation pure : bbox, limit/offset, coordonnées, années, recherche |
+| **Routes / intégration** | `publicRoutes*.test.js`, `adminRoutes*.test.js` | `fastify.inject()` + faux pool. Gating d'auth, allowlists de colonnes, forme du SQL, transactions |
+| **Sécurité** | `adminAuth.test.js`, `rateLimit.test.js` | JWT forgé, `alg:none`, expiration, anti-énumération, verrouillage de compte, plafonds de débit par IP et par jeton |
+| **Annulation** | `cancellation.test.js` + `apps/crawler/tests/test_cancellation.py` | Les deux moitiés du protocole coopératif |
+| **Accessibilité** | `apps/web/test/a11y.test.js` | axe-core + jsdom sur le HTML statique |
+| **Frontend** | `apps/web/test/pure.test.js` | Filtrage, tri, échappement HTML |
+| **Méta** | `apps/api/test/harness.test.js` | Vérifie le faux pool lui-même |
+| **Python** | `apps/geocoder/tests/`, `apps/crawler/tests/` | Parseur de localisations, annulation |
+| **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 65 % (API complète) |
+
+## Performance : les décisions et leurs mesures
+
+Le principe : **aucun test ne monte de conteneur, ne compile, ni ne touche le réseau.**
+
+Quatre optimisations, toutes mesurées (les intuitions non vérifiées se sont
+révélées fausses au moins une fois) :
+
+1. **Une app Fastify par fichier de test, pas par cas.**
+ `buildServer()` coûte 14 ms. À raison d'une construction par `it()`, ces
+ 14 ms étaient multipliés par ~12 000 exécutions pendant un run de mutation.
+ Les tests partagent une app et reprogramment un pool via `pool.reset()`
+ (`test/helpers/testApp.js`).
+
+2. **Config Vitest dédiée à la mutation.**
+ Stryker recharge les fichiers de test à chaque mutant. En ne déclarant que
+ ceux qui couvrent le code muté (et surtout pas le test a11y, qui initialise
+ jsdom en ~2 s), le profil pur est passé de **1 min 23 à 34 s**.
+
+3. **Mutants `StringLiteral` exclus.**
+ Les handlers sont à ~80 % du SQL en template literals. Muter le contenu
+ d'une chaîne SQL ne mesure rien. Les retirer a fait passer le profil complet
+ de 7 min 50 à 5 min — et le score de 42 % à 65 %, parce que le bruit
+ disparaissait du dénominateur.
+
+4. **Plafonds de débit paramétrables.**
+ Conséquence du point 1 : une app partagée accumule l'état du limiteur. Les
+ valeurs réelles de production sont vérifiées séparément dans
+ `rateLimit.test.js`, pour que la paramétrisation ne crée pas d'angle mort.
+
+Ce qui a été **essayé et rejeté sur mesure** : le pool Vitest `threads` avec
+`isolate: false`, réputé plus rapide, donne **18,8 s contre 3,5 s** ici (la
+mise en place de jsdom est pénalisée). Le défaut (`forks`) est conservé.
+
+## Pourquoi deux profils de mutation
+
+La mutation prouve qu'un test **échoue** quand le code casse — la couverture ne
+dit que « cette ligne a été exécutée ».
+
+- `test:mutation` (validate.js, adminAuth.js) : **82 %**. Logique pure, score
+ interprétable, assez rapide pour être lancé souvent.
+- `test:mutation:full` (+ adminRoutes.js, app.js) : **65 %**. Les handlers de
+ route plafonnent structurellement plus bas — beaucoup de mutants portent sur
+ des messages de log ou des branches `catch` dont l'observable exact n'a pas
+ d'importance. Utile en audit, pas en boucle courte.
+
+Les seuils d'échec (70 % et 55 %) sont des cliquets anti-régression, pas des
+objectifs.
+
+### Ce qui n'est pas couvert
+
+- Règles a11y exigeant un moteur de rendu (contraste, cibles tactiles) : jsdom
+ ne calcule pas de styles. À compléter par un audit Lighthouse manuel.
+- `migrate.js` : s'exécute au démarrage du conteneur et appelle `process.exit`.
+- Crawler et workers de géocodage : seuls le parseur et l'annulation sont
+ couverts, le reste est de l'I/O réseau et base.
+- `apps/web/site/pure.js` est testé mais **exclu de la mutation** : il est
+ chargé via `fs` + `vm` (comme le fait le navigateur), donc l'instrumentation
+ Stryker ne l'atteindrait pas et afficherait un score faussement parfait.
+
+## Forgejo Actions
+
+`.forgejo/workflows/ci.yml` existe mais **ne tourne pas** : aucun runner
+`act_runner` n'est enregistré sur le VPS. Le fichier est prêt si tu en ajoutes
+un. Il est volontairement informatif — il ne bloque pas le déploiement.
diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js
index 518c69b..fb1f702 100644
--- a/apps/admin/site/app.js
+++ b/apps/admin/site/app.js
@@ -168,7 +168,7 @@ function splitGenreElements(genreRows) {
for (const { genre, total } of genreRows) {
if (!genre) continue;
const cleaned = genre.replace(/\([^)]*\)/g, "");
- const tokens = cleaned.split(/[\/;,]+/);
+ const tokens = cleaned.split(/[/;,]+/);
for (const tok of tokens) {
const kw = tok.trim();
if (kw.length < 3) continue;
@@ -255,15 +255,23 @@ async function loadOverviewLive() {
const runsHtml = live.active_runs.length
? live.active_runs.map(r => {
const elapsed = Math.round((Date.now() - new Date(r.started_at)) / 60000);
+ // L'annulation est coopérative : le crawler ne s'arrête qu'au prochain
+ // point de contrôle. Tant qu'il n'a pas basculé le statut à
+ // 'cancelled', on affiche l'état intermédiaire au lieu de laisser
+ // croire que c'est déjà fait.
+ const pendingCancel = r.cancel_requested === true;
return `
-
▶ ${esc(r.run_type)}
+
▶ ${esc(r.run_type)}
${elapsed}min · ${r.bands_seen} vus · ${r.bands_new} nouveaux · ${r.bands_enriched} enrichis
+ ${pendingCancel ? `
⏳ Arrêt demandé${r.cancel_requested_by ? ` par ${esc(r.cancel_requested_by)}` : ""} — le crawler s'arrêtera au prochain lot
` : ""}
${r.error ? `
${esc(r.error)}
` : ""}
-
✕ Annuler
+ ${pendingCancel
+ ? `
⏳ Arrêt… `
+ : `
✕ Annuler `}
`;
}).join("")
: `Aucun run actif
`;
@@ -341,7 +349,7 @@ async function loadOverviewLive() {
}
async function adminCancelRun(id) {
- if (!confirm(`Annuler le run #${id} ?`)) return;
+ if (!confirm(`Demander l'arrêt du run #${id} ?\n\nLe crawler s'arrêtera à son prochain point de contrôle (quelques secondes à une minute selon le job).`)) return;
try {
await api(`/admin/api/crawl-runs/${id}/cancel`, { method: "POST", body: "{}" });
await loadOverviewLive();
@@ -356,6 +364,12 @@ async function adminCancelJob(id) {
} catch (e) { alert(`Erreur : ${e.message}`); }
}
+// Ces deux handlers sont appelés depuis des attributs onclick= générés dans
+// les templates ci-dessus : l'exposition sur `window` est explicite pour que le
+// couplage reste visible (et que le linter ne les prenne pas pour du code mort).
+window.adminCancelRun = adminCancelRun;
+window.adminCancelJob = adminCancelJob;
+
// ------------------------------------------------------------------
// Groupes (Bands + Conflits fondus)
// ------------------------------------------------------------------
diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore
new file mode 100644
index 0000000..536131c
--- /dev/null
+++ b/apps/api/.dockerignore
@@ -0,0 +1,4 @@
+node_modules/
+test/
+coverage/
+.stryker-tmp/
diff --git a/apps/api/migrations/014_run_cancellation_and_geo_sync.sql b/apps/api/migrations/014_run_cancellation_and_geo_sync.sql
new file mode 100644
index 0000000..f0473f1
--- /dev/null
+++ b/apps/api/migrations/014_run_cancellation_and_geo_sync.sql
@@ -0,0 +1,78 @@
+-- 014_run_cancellation_and_geo_sync.sql
+--
+-- Deux corrections d'invariants qui rendaient des fonctionnalités cosmétiques.
+--
+-- A) Annulation réelle des crawl_run
+-- Avant : « Annuler » écrivait status='error' en base. Le crawler Python ne
+-- lisait jamais cette colonne et continuait sa boucle jusqu'au bout. Pire,
+-- son UPDATE final `WHERE id=%s AND status='running'` ne matchait plus, donc
+-- un run mené à terme restait affiché en erreur. Le bouton ne faisait rien
+-- d'autre que mentir.
+-- Après : l'admin pose un DRAPEAU (cancel_requested) sans toucher au statut.
+-- Le crawler le lit à chaque itération et s'arrête proprement, en écrivant
+-- lui-même status='cancelled'. Le statut reflète donc toujours la réalité du
+-- process.
+--
+-- B) Désynchronisation du point principal
+-- bands.lat/lon/geom est une dénormalisation du PREMIER lieu géocodé de
+-- band_locations (voir _sync_band_point dans geocoder/worker.py). Le trigger
+-- de la migration 013 supprime les band_locations quand location_text change,
+-- mais laissait l'ancien point sur bands : la carte (qui lit band_locations)
+-- n'affichait plus rien tandis que la liste et la heatmap (qui lisent bands)
+-- continuaient de montrer l'ancienne ville, parfois pendant des jours.
+
+-- ------------------------------------------------------------------
+-- A) Annulation coopérative
+-- ------------------------------------------------------------------
+
+ALTER TABLE crawl_run ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE;
+ALTER TABLE crawl_run ADD COLUMN IF NOT EXISTS cancel_requested_at TIMESTAMPTZ;
+ALTER TABLE crawl_run ADD COLUMN IF NOT EXISTS cancel_requested_by TEXT;
+
+-- Le crawler interroge ce drapeau en boucle : index partiel pour que le
+-- lookup reste un index-only scan même avec un historique de runs volumineux.
+CREATE INDEX IF NOT EXISTS idx_crawl_run_cancel
+ ON crawl_run (id)
+ WHERE status = 'running' AND cancel_requested = TRUE;
+
+-- Note : on n'ajoute délibérément PAS de cancel_requested sur job_triggers.
+-- Un job 'pending' se retire de la file (c'est ce que fait la route existante) ;
+-- une fois réclamé par le crawler il n'existe plus qu'à travers le crawl_run
+-- qu'il a créé, et c'est CE run qu'on annule. Deux drapeaux pour un seul état
+-- réel, c'est exactement le genre de colonne qui finit morte.
+
+-- ------------------------------------------------------------------
+-- B) Le point principal suit la suppression des localisations
+-- ------------------------------------------------------------------
+
+CREATE OR REPLACE FUNCTION bands_geocode_dirty() RETURNS trigger AS $$
+BEGIN
+ IF TG_OP = 'UPDATE' AND OLD.location_text IS DISTINCT FROM NEW.location_text THEN
+ DELETE FROM band_locations WHERE ma_id = NEW.ma_id;
+
+ -- Purge aussi le point dénormalisé : il décrivait l'ancienne localisation.
+ -- Cet UPDATE ne touche pas location_text, donc il ne redéclenche pas ce
+ -- trigger (défini AFTER UPDATE OF location_text) — pas de récursion.
+ UPDATE bands
+ SET lat = NULL,
+ lon = NULL,
+ geocoded_at = NULL,
+ geocode_provider = NULL,
+ geocode_query = NULL
+ WHERE ma_id = NEW.ma_id
+ AND (lat IS NOT NULL OR lon IS NOT NULL);
+ -- geom est recalculé (à NULL) par le trigger BEFORE bands_set_geom.
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- ------------------------------------------------------------------
+-- C) Localisation posée manuellement par un admin
+-- ------------------------------------------------------------------
+-- Éditer lat/lon depuis le dashboard n'écrivait que sur bands : la carte, qui
+-- clusterise depuis band_locations, ignorait totalement la correction. On
+-- réserve step_order = -1 à l'override manuel, qui passe donc avant tous les
+-- steps issus du parsing (tri step_order ASC) et devient le point principal.
+COMMENT ON COLUMN band_locations.step_order IS
+ 'Ordre de l''étape dans location_text (0 = première). -1 = override manuel admin, prioritaire.';
diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js
index 67002f0..eddcab5 100644
--- a/apps/api/src/adminRoutes.js
+++ b/apps/api/src/adminRoutes.js
@@ -1,4 +1,5 @@
import { requireAdminSession, writeAuditLog } from "./adminAuth.js";
+import { pagination } from "./validate.js";
const BAND_SORT_COLUMNS = new Set([
"ma_id", "name", "country", "status", "genre",
@@ -10,11 +11,8 @@ const BAND_EDITABLE_FIELDS = [
"themes", "location_text", "lat", "lon",
];
-function pagination(query) {
- const page = Math.max(1, Number(query.page) || 1);
- const pageSize = Math.min(200, Math.max(1, Number(query.pageSize) || 50));
- return { page, pageSize, offset: (page - 1) * pageSize };
-}
+/** Sous-ensemble de BAND_EDITABLE_FIELDS stocke en colonne numerique. */
+const NUMERIC_BAND_FIELDS = new Set(["formed_year", "lat", "lon"]);
export default async function adminRoutes(fastify, opts) {
const { pool } = opts;
@@ -268,34 +266,83 @@ export default async function adminRoutes(fastify, opts) {
updates.lon = v;
}
- const before = await pool.query(`SELECT * FROM bands WHERE ma_id = $1 LIMIT 1`, [id]);
- if (!before.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
+ // Transaction : l'edition, la pose de l'override de localisation et
+ // l'ecriture d'audit doivent reussir ou echouer ensemble. Sans ca, une
+ // coupure entre les deux laissait une modification sans trace d'audit.
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
- const setCols = Object.keys(updates);
- // Marque les champs édités comme verrouillés (ne seront plus écrasés par le crawler)
- const newLocked = Object.fromEntries(setCols.map((c) => [c, true]));
- const setSql = setCols.map((c, idx) => `${c} = $${idx + 2}`).join(", ");
- const vals = [id, ...setCols.map((c) => updates[c])];
+ const before = await client.query(`SELECT * FROM bands WHERE ma_id = $1 LIMIT 1`, [id]);
+ if (!before.rows.length) {
+ await client.query("ROLLBACK");
+ return reply.code(404).send({ ok: false, error: "not found" });
+ }
- const after = await pool.query(
- `UPDATE bands SET ${setSql},
- locked_fields = locked_fields || $${vals.length + 1}::jsonb,
- crawler_pending = (
- SELECT jsonb_strip_nulls(jsonb_build_object(${
- setCols.map((c) => `'${c}', crawler_pending->'${c}'`).join(", ")
- }))
- FROM bands WHERE ma_id = $1
- )
- WHERE ma_id = $1 RETURNING *`,
- [...vals, JSON.stringify(newLocked)]
- );
+ const setCols = Object.keys(updates);
+ // Marque les champs edites comme verrouilles (plus ecrases par le crawler)
+ const newLocked = Object.fromEntries(setCols.map((c) => [c, true]));
+ const setSql = setCols.map((c, idx) => `${c} = $${idx + 2}`).join(", ");
+ const vals = [id, ...setCols.map((c) => updates[c])];
- await writeAuditLog(
- pool, req.adminUsername, "update", "bands", id,
- before.rows[0], after.rows[0]
- );
+ const after = await client.query(
+ `UPDATE bands SET ${setSql},
+ locked_fields = locked_fields || $${vals.length + 1}::jsonb,
+ crawler_pending = (
+ SELECT jsonb_strip_nulls(jsonb_build_object(${
+ setCols.map((c) => `'${c}', crawler_pending->'${c}'`).join(", ")
+ }))
+ FROM bands WHERE ma_id = $1
+ )
+ WHERE ma_id = $1 RETURNING *`,
+ [...vals, JSON.stringify(newLocked)]
+ );
- return { ok: true, item: after.rows[0] };
+ // La carte clusterise depuis band_locations, jamais depuis bands.
+ // Corriger lat/lon ici ne deplacait donc aucun point : il faut poser un
+ // override dans band_locations. step_order = -1 le place avant tous les
+ // steps issus du parsing, ce qui en fait aussi le point principal.
+ const row = after.rows[0];
+ if ("lat" in updates || "lon" in updates) {
+ if (row.lat != null && row.lon != null) {
+ await client.query(
+ `INSERT INTO band_locations
+ (ma_id, step_order, step_label, location_raw, is_country_only,
+ lat, lon, geocode_status, geocode_provider, geocode_confidence, updated_at)
+ VALUES ($1, -1, 'override admin', $2, FALSE, $3, $4, 'done', 'admin', 1.0, now())
+ ON CONFLICT (ma_id, step_order, location_raw) DO UPDATE SET
+ lat = EXCLUDED.lat,
+ lon = EXCLUDED.lon,
+ geocode_status = 'done',
+ geocode_provider = 'admin',
+ geocode_confidence = 1.0,
+ geocode_error = NULL,
+ updated_at = now()`,
+ [id, row.location_text || "(coordonnees saisies manuellement)", row.lat, row.lon]
+ );
+ } else {
+ // lat/lon effaces -> l'override n'a plus lieu d'etre.
+ await client.query(
+ `DELETE FROM band_locations WHERE ma_id = $1 AND step_order = -1`, [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, "update", "bands", String(id),
+ JSON.stringify(before.rows[0]), JSON.stringify(row)]
+ );
+
+ await client.query("COMMIT");
+ return { ok: true, item: row };
+ } catch (err) {
+ await client.query("ROLLBACK").catch(() => {});
+ throw err;
+ } finally {
+ client.release();
+ }
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur mise à jour band" });
@@ -341,7 +388,9 @@ export default async function adminRoutes(fastify, opts) {
LIMIT $${i} OFFSET $${i + 1}
`, vals);
- return { ok: true, items: r.rows };
+ // page/pageSize etaient calcules mais jamais renvoyes : le client n'avait
+ // aucun moyen de savoir s'il restait des logs a charger.
+ return { ok: true, items: r.rows, page, pageSize };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur logs" });
@@ -385,6 +434,12 @@ export default async function adminRoutes(fastify, opts) {
if (!field || !["keep_mine", "accept_crawler"].includes(action)) {
return reply.code(400).send({ ok: false, error: "field et action requis" });
}
+ // `field` est interpole dans le SET plus bas (impossible de parametrer un
+ // nom de colonne) : sans cette allowlist, un admin authentifie pouvait
+ // ecrire du SQL arbitraire via le corps de la requete.
+ if (!BAND_EDITABLE_FIELDS.includes(field)) {
+ return reply.code(400).send({ ok: false, error: `field non modifiable: ${field}` });
+ }
const before = await pool.query(`SELECT * FROM bands WHERE ma_id = $1`, [id]);
if (!before.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
@@ -396,7 +451,12 @@ export default async function adminRoutes(fastify, opts) {
} else {
// Applique la valeur du crawler, déverrouille le champ
const crawlerVal = before.rows[0].crawler_pending?.[field];
- sql = `UPDATE bands SET ${field} = $2::text, locked_fields = locked_fields - $3, crawler_pending = crawler_pending - $3 WHERE ma_id = $1`;
+ // Le cast depend du type reel de la colonne : `::text` en dur cassait
+ // formed_year / lat / lon (erreur Postgres 42804).
+ const cast = NUMERIC_BAND_FIELDS.has(field)
+ ? (field === "formed_year" ? "::int" : "::double precision")
+ : "::text";
+ sql = `UPDATE bands SET ${field} = $2${cast}, locked_fields = locked_fields - $3, crawler_pending = crawler_pending - $3 WHERE ma_id = $1`;
vals = [id, crawlerVal, field];
}
const after = await pool.query(sql + " RETURNING *", vals);
@@ -663,7 +723,8 @@ export default async function adminRoutes(fastify, opts) {
const [active_runs, pending_jobs, geo_queue, geo_processing, checkpoints] = await Promise.all([
pool.query(`
SELECT id, run_type, countries, status, started_at,
- bands_seen, bands_new, bands_updated, bands_enriched, error
+ bands_seen, bands_new, bands_updated, bands_enriched, error,
+ cancel_requested, cancel_requested_at, cancel_requested_by
FROM crawl_run WHERE status = 'running' ORDER BY started_at DESC
`),
pool.query(`
@@ -700,16 +761,40 @@ export default async function adminRoutes(fastify, opts) {
try {
const id = Number(req.params.id);
if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad id" });
+ // Annulation COOPERATIVE : on pose un drapeau et on laisse le statut a
+ // 'running'. Le crawler le lit a chaque lot et ecrit lui-meme
+ // status='cancelled' quand il s'est reellement arrete.
+ //
+ // Ecrire directement status='error' ici (l'ancien comportement) mentait
+ // deux fois : le process continuait a tourner, et son UPDATE final
+ // `WHERE status='running'` ne matchait plus.
const r = await pool.query(`
- UPDATE crawl_run SET status='error', finished_at=now(), error='annulé manuellement par admin'
- WHERE id=$1 AND status='running' RETURNING id, run_type
- `, [id]);
- if (!r.rows.length) return reply.code(404).send({ ok: false, error: "Run non trouvé ou déjà terminé" });
+ UPDATE crawl_run
+ SET cancel_requested = TRUE,
+ cancel_requested_at = now(),
+ cancel_requested_by = $2
+ WHERE id = $1 AND status = 'running' AND cancel_requested = FALSE
+ RETURNING id, run_type
+ `, [id, req.adminUsername]);
+
+ if (!r.rows.length) {
+ // Distinguer << deja demande >> de << run inexistant/termine >> : sinon
+ // double-cliquer sur Annuler affiche une erreur alarmante a tort.
+ const existing = await pool.query(
+ `SELECT status, cancel_requested FROM crawl_run WHERE id = $1`, [id]
+ );
+ if (existing.rows.length && existing.rows[0].cancel_requested) {
+ return { ok: true, id, already_requested: true };
+ }
+ return reply.code(404).send({ ok: false, error: "Run non trouvé ou déjà terminé" });
+ }
+
await pool.query(
`INSERT INTO crawl_log (level, message) VALUES ($1, $2)`,
- ["warning", `[admin:${req.adminUsername}] run #${id} (${r.rows[0].run_type}) annulé manuellement`]
+ ["warning", `[admin:${req.adminUsername}] annulation demandee pour le run #${id} (${r.rows[0].run_type})`]
).catch(() => {});
- return { ok: true, id };
+ await writeAuditLog(pool, req.adminUsername, "cancel_run", "crawl_run", id, null, { run_type: r.rows[0].run_type });
+ return { ok: true, id, cancel_requested: true };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur annulation run" });
@@ -727,7 +812,15 @@ export default async function adminRoutes(fastify, opts) {
UPDATE job_triggers SET status='cancelled', finished_at=now()
WHERE id=$1 AND status='pending' RETURNING id, job_type
`, [id]);
- if (!r.rows.length) return reply.code(404).send({ ok: false, error: "Job non trouvé ou déjà traité" });
+ if (!r.rows.length) {
+ // Un job deja reclame par le crawler n'est plus annulable en tant que
+ // job : il n'existe plus que sous la forme du crawl_run qu'il a cree.
+ const existing = await pool.query(`SELECT status FROM job_triggers WHERE id = $1`, [id]);
+ const msg = existing.rows[0]?.status === "running"
+ ? "Job déjà démarré — annuler le run correspondant dans Vue d'ensemble"
+ : "Job non trouvé ou déjà traité";
+ return reply.code(409).send({ ok: false, error: msg });
+ }
await pool.query(
`INSERT INTO crawl_log (level, message) VALUES ($1, $2)`,
["warning", `[admin:${req.adminUsername}] job trigger #${id} (${r.rows[0].job_type}) annulé`]
diff --git a/apps/api/src/app.js b/apps/api/src/app.js
new file mode 100644
index 0000000..46a5985
--- /dev/null
+++ b/apps/api/src/app.js
@@ -0,0 +1,843 @@
+/**
+ * Construction de l'application Fastify.
+ *
+ * Séparé de server.js (qui se contente d'écouter) pour que les tests puissent
+ * instancier l'app avec un faux pool et l'interroger via `fastify.inject()`,
+ * sans ouvrir de port ni de connexion Postgres.
+ */
+import Fastify from "fastify";
+import rateLimit from "@fastify/rate-limit";
+import helmet from "@fastify/helmet";
+import cookie from "@fastify/cookie";
+import { timingSafeEqual } from "crypto";
+import {
+ ADMIN_COOKIE_NAME,
+ seedAdminUser,
+ signAdminSession,
+ isLockedOut,
+ recordLoginAttempt,
+ verifyPassword,
+ markLoginSuccess,
+ requireAdminSession,
+} from "./adminAuth.js";
+import adminApiRoutes from "./adminRoutes.js";
+import { ValidationError, sanitizeSearchString, parseLimitOffset } from "./validate.js";
+
+/**
+ * @param {object} [opts]
+ * @param {object|null} [opts.pool] pool pg (ou un double de test)
+ * @param {boolean} [opts.logger]
+ * @param {string} [opts.importToken] jeton Bearer de /admin/import
+ * @param {string[]} [opts.corsOrigins]
+ * @param {boolean} [opts.seedAdmin] false en test (évite un INSERT parasite)
+ * @param {number} [opts.globalRateLimitMax] requêtes/min, toutes routes
+ * @param {number} [opts.adminRateLimitMax] requêtes/min sur /admin/import
+ * @param {number} [opts.authRateLimitMax] requêtes/min sur /admin/auth/login
+ */
+export async function buildServer(opts = {}) {
+ const {
+ pool = null,
+ logger = false,
+ importToken = (process.env.BM_IMPORT_TOKEN || "").trim(),
+ corsOrigins = process.env.CORS_ORIGINS
+ ? process.env.CORS_ORIGINS.split(",").map((s) => s.trim())
+ : ["https://metalfrom.eu", "https://www.metalfrom.eu"],
+ seedAdmin = true,
+ // Plafonds de débit paramétrables : les tests partagent une même instance
+ // d'app entre les cas (construire une app coûte ~14 ms, multipliées par des
+ // milliers d'exécutions en tests de mutation). Sans ça, l'état du limiteur
+ // s'accumulerait d'un test à l'autre et ferait échouer le 11e. Les valeurs
+ // par défaut restent celles de production, et sont testées explicitement
+ // dans apps/api/test/rateLimit.test.js.
+ globalRateLimitMax = 1000,
+ adminRateLimitMax = 10,
+ authRateLimitMax = 10,
+ } = opts;
+
+ const fastify = Fastify({
+ logger,
+ bodyLimit: 10485760, // 10 MB max pour éviter DoS mémoire
+ trustProxy: true // derrière Traefik (+ nginx pour /admin/*) : lit X-Forwarded-For
+ });
+
+ // Headers de sécurité
+ await fastify.register(helmet, {
+ contentSecurityPolicy: false,
+ crossOriginEmbedderPolicy: false
+ });
+
+ await fastify.register(cookie);
+
+ // CORS middleware
+ fastify.addHook('onRequest', async (request, reply) => {
+ const origin = request.headers.origin;
+ const allowedOrigins = corsOrigins;
+
+ if (allowedOrigins.includes(origin)) {
+ reply.header('Access-Control-Allow-Origin', origin);
+ }
+
+ reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
+ reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
+ reply.header('Access-Control-Allow-Credentials', 'true');
+
+ if (request.method === 'OPTIONS') {
+ // `return reply` est obligatoire : sans ça Fastify poursuit le cycle de vie
+ // et route quand même la requête OPTIONS vers un handler (ou un 404).
+ return reply.status(204).send();
+ }
+ });
+
+ // Rate limiting global
+ await fastify.register(rateLimit, {
+ max: globalRateLimitMax,
+ timeWindow: '1 minute',
+ cache: 10000
+ });
+
+ // Gestion d'erreurs globale
+ fastify.setErrorHandler((error, request, reply) => {
+ // Les erreurs 4xx (validation de schéma, rate-limit, payload trop gros…) ont
+ // un statusCode utile : l'écraser en 500 masquait la cause côté client et
+ // faisait passer des erreurs d'entrée pour des pannes serveur.
+ const status = Number(error.statusCode);
+ if (Number.isInteger(status) && status >= 400 && status < 500) {
+ return reply.status(status).send({ ok: false, error: error.message });
+ }
+ fastify.log.error(error);
+ return reply.status(500).send({
+ ok: false,
+ error: 'Une erreur est survenue'
+ });
+ });
+
+ if (pool && seedAdmin) {
+ seedAdminUser(pool).catch((err) => fastify.log.error({ err }, "[admin] seed failed"));
+ }
+
+ function requirePool() {
+ if (!pool) throw new Error("DATABASE_URL not set");
+ return pool;
+ }
+
+ function authBearer(req) {
+ const h = req.headers.authorization || "";
+ const m = h.match(/^Bearer\s+(.+)$/i);
+ return m ? m[1] : null;
+ }
+
+ function requireAdmin(req, reply) {
+ const provided = authBearer(req);
+
+ if (!importToken || !provided) {
+ reply.code(401).send({ ok: false, error: "unauthorized" });
+ return false;
+ }
+
+ // Comparer les longueurs en OCTETS, pas en caractères : timingSafeEqual
+ // lève si les deux buffers diffèrent en taille, et un jeton multi-octets
+ // ("é" = 2 octets) passait le test de longueur de chaîne tout en produisant
+ // des buffers de tailles différentes → exception → 500 au lieu de 401.
+ const providedBuf = Buffer.from(provided, "utf8");
+ const expectedBuf = Buffer.from(importToken, "utf8");
+ if (providedBuf.length !== expectedBuf.length) {
+ reply.code(401).send({ ok: false, error: "unauthorized" });
+ return false;
+ }
+
+ const valid = timingSafeEqual(providedBuf, expectedBuf);
+
+ if (!valid) {
+ reply.code(401).send({ ok: false, error: "unauthorized" });
+ return false;
+ }
+
+ return true;
+ }
+
+ fastify.get("/", async () => {
+ return { ok: true, service: "BM API" };
+ });
+
+ fastify.get("/api/health", async () => ({ ok: true }));
+
+ fastify.get("/api/db", async (req, reply) => {
+ try {
+ const p = requirePool();
+ const r = await p.query("SELECT now() as now, current_database() as db");
+ return { ok: true, ...r.rows[0] };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur de connexion à la base de données' });
+ }
+ });
+
+ fastify.get("/api/stats", async (req, reply) => {
+ try {
+ const p = requirePool();
+ const q = `
+ SELECT
+ count(*)::int as total,
+ count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded,
+ count(*) FILTER (WHERE geom IS NULL)::int as no_location,
+ count(*) FILTER (WHERE enriched = true)::int as enriched
+ FROM bands;
+ `;
+ const r = await p.query(q);
+ return { ok: true, ...r.rows[0] };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des stats' });
+ }
+ });
+
+ fastify.get("/api/countries", async (req, reply) => {
+ try {
+ const p = requirePool();
+ const q = `
+ SELECT
+ country,
+ count(*)::int as total,
+ count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded,
+ count(*) FILTER (WHERE enriched = true)::int as enriched
+ FROM bands
+ GROUP BY country
+ ORDER BY total DESC;
+ `;
+ const r = await p.query(q);
+ return { ok: true, items: r.rows };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des pays' });
+ }
+ });
+
+ fastify.get("/api/statuses", async (req, reply) => {
+ try {
+ const p = requirePool();
+ const q = `
+ SELECT
+ COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
+ count(*)::int as total,
+ count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded
+ FROM bands
+ GROUP BY 1
+ ORDER BY total DESC;
+ `;
+ const r = await p.query(q);
+ return { ok: true, items: r.rows };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des statuts' });
+ }
+ });
+
+ fastify.get("/api/clusters", async (req, reply) => {
+ try {
+ const p = requirePool();
+ const {
+ bbox,
+ zoom,
+ countries,
+ status,
+ genre,
+ year_min,
+ year_max,
+ } = /** @type {Record} */ (req.query || {});
+
+ if (!bbox || !zoom) {
+ return reply.code(400).send({ ok: false, error: "bbox and zoom are required" });
+ }
+
+ const bboxParts = String(bbox).split(",").map(Number);
+ if (bboxParts.length !== 4) {
+ return reply.code(400).send({ ok: false, error: "bbox must have 4 values" });
+ }
+
+ const [minLon, minLat, maxLon, maxLat] = bboxParts;
+ const zoomLevel = Number(zoom);
+
+ if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) {
+ return reply.code(400).send({ ok: false, error: "Invalid bbox or zoom values" });
+ }
+
+ if (minLat < -90 || maxLat > 90 || minLon < -180 || maxLon > 180) {
+ return reply.code(400).send({ ok: false, error: "Coordinates out of range" });
+ }
+
+ if (minLon >= maxLon || minLat >= maxLat) {
+ return reply.code(400).send({ ok: false, error: "Invalid bbox bounds" });
+ }
+
+ if (zoomLevel < 0 || zoomLevel > 22) {
+ return reply.code(400).send({ ok: false, error: "Zoom level must be between 0 and 22" });
+ }
+
+ const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel));
+
+ // Source: band_locations (un point par localisation géocodée) JOIN bands.
+ const where = [
+ "bl.geom IS NOT NULL",
+ "bl.geocode_status IN ('done','country_only')",
+ ];
+ const vals = [];
+ let i = 1;
+
+ where.push(`bl.geom && ST_MakeEnvelope($${i}, $${i+1}, $${i+2}, $${i+3}, 4326)`);
+ vals.push(minLon, minLat, maxLon, maxLat);
+ i += 4;
+
+ if (countries) {
+ const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
+ if (cs.length > 100) {
+ return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" });
+ }
+ if (cs.length) {
+ where.push(`b.country = ANY($${i}::text[])`);
+ vals.push(cs);
+ i++;
+ }
+ }
+
+ if (status) {
+ const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
+ if (st.length > 50) {
+ return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" });
+ }
+ if (st.length) {
+ where.push(`COALESCE(NULLIF(trim(b.status), ''), 'Unknown') = ANY($${i}::text[])`);
+ vals.push(st);
+ i++;
+ }
+ }
+
+ if (genre) {
+ try {
+ const genreQuery = sanitizeSearchString(genre, 'Genre');
+ where.push(`b.genre ILIKE $${i}`);
+ vals.push(`%${genreQuery}%`);
+ i++;
+ } catch (err) {
+ return reply.code(400).send({ ok: false, error: err.message });
+ }
+ }
+
+ if (year_min) {
+ const yearMin = Number(year_min);
+ if (!Number.isFinite(yearMin) || yearMin < 1800 || yearMin > 2100) {
+ return reply.code(400).send({ ok: false, error: 'year_min invalide' });
+ }
+ where.push(`b.formed_year >= $${i}`);
+ vals.push(yearMin);
+ i++;
+ }
+
+ if (year_max) {
+ const yearMax = Number(year_max);
+ if (!Number.isFinite(yearMax) || yearMax < 1800 || yearMax > 2100) {
+ return reply.code(400).send({ ok: false, error: 'year_max invalide' });
+ }
+ where.push(`b.formed_year <= $${i}`);
+ vals.push(yearMax);
+ i++;
+ }
+
+ const whereSql = where.join(" AND ");
+
+ if (zoomLevel >= 12) {
+ const sql = `
+ SELECT
+ b.ma_id,
+ b.name,
+ b.country,
+ COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status,
+ b.genre,
+ b.location_text,
+ b.formed_year,
+ bl.lat,
+ bl.lon,
+ bl.location_raw,
+ bl.step_order,
+ bl.step_label,
+ bl.is_country_only
+ FROM band_locations bl
+ JOIN bands b ON b.ma_id = bl.ma_id
+ WHERE ${whereSql}
+ ORDER BY bl.ma_id ASC, bl.step_order ASC
+ LIMIT 2000;
+ `;
+ const r = await p.query(sql, vals);
+ return {
+ ok: true,
+ type: "bands",
+ items: r.rows,
+ count: r.rows.length
+ };
+ }
+
+ const sql = `
+ WITH grid_cells AS (
+ SELECT
+ floor(ST_X(bl.geom) / $${i})::int as cell_x,
+ floor(ST_Y(bl.geom) / $${i})::int as cell_y,
+ b.ma_id,
+ b.name,
+ b.country,
+ COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status,
+ b.genre,
+ b.location_text,
+ b.formed_year,
+ bl.lat,
+ bl.lon,
+ bl.location_raw,
+ bl.step_label
+ FROM band_locations bl
+ JOIN bands b ON b.ma_id = bl.ma_id
+ WHERE ${whereSql}
+ ),
+ clusters AS (
+ SELECT
+ cell_x,
+ cell_y,
+ count(*)::int as band_count,
+ avg(lat)::float8 as center_lat,
+ avg(lon)::float8 as center_lon,
+ array_agg(json_build_object(
+ 'ma_id', ma_id,
+ 'name', name,
+ 'country', country,
+ 'status', status,
+ 'genre', genre,
+ 'location_text', location_text,
+ 'formed_year', formed_year,
+ 'lat', lat,
+ 'lon', lon,
+ 'location_raw', location_raw,
+ 'step_label', step_label
+ ) ORDER BY name) as bands
+ FROM grid_cells
+ GROUP BY cell_x, cell_y
+ )
+ SELECT
+ center_lat as lat,
+ center_lon as lon,
+ band_count as count,
+ CASE
+ WHEN band_count <= 5 THEN bands
+ ELSE bands[1:5]
+ END as sample_bands
+ FROM clusters
+ ORDER BY band_count DESC
+ LIMIT 1000;
+ `;
+ vals.push(cellSize);
+
+ const r = await p.query(sql, vals);
+ return {
+ ok: true,
+ type: "clusters",
+ items: r.rows,
+ total_clusters: r.rows.length,
+ cell_size: cellSize,
+ zoom: zoomLevel
+ };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des clusters' });
+ }
+ });
+
+ fastify.get("/api/bands", async (req, reply) => {
+ try {
+ const p = requirePool();
+ const {
+ countries,
+ geocoded,
+ q,
+ status,
+ only_black,
+ limit,
+ offset,
+ } = /** @type {Record} */ (req.query || {});
+
+ let lim, off;
+ try {
+ ({ limit: lim, offset: off } = parseLimitOffset(limit, offset, {
+ defaultLimit: 1000,
+ maxLimit: 150000,
+ }));
+ } catch (err) {
+ if (err instanceof ValidationError) {
+ return reply.code(400).send({ ok: false, error: err.message });
+ }
+ throw err;
+ }
+
+ if (lim > 10000) {
+ fastify.log.warn(`Large query: ${lim} rows requested from ${req.ip}`);
+ }
+
+ const where = [];
+ const vals = [];
+ let i = 1;
+
+ if (countries) {
+ const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
+ if (cs.length > 100) {
+ return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" });
+ }
+ if (cs.length) {
+ where.push(`country = ANY($${i}::text[])`);
+ vals.push(cs);
+ i++;
+ }
+ }
+
+ if (geocoded === "1") where.push(`geom IS NOT NULL`);
+ if (geocoded === "0") where.push(`geom IS NULL`);
+
+ if (status) {
+ const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
+ if (st.length > 50) {
+ return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" });
+ }
+ if (st.length) {
+ where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
+ vals.push(st);
+ i++;
+ }
+ }
+
+ if (only_black === "1") {
+ where.push(`genre ILIKE '%black%'`);
+ }
+
+ if (q) {
+ try {
+ const query = sanitizeSearchString(q, 'Recherche');
+ const qq = `%${query}%`;
+ where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`);
+ vals.push(qq);
+ i++;
+ } catch (err) {
+ return reply.code(400).send({ ok: false, error: err.message });
+ }
+ }
+
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
+
+ const sql = `
+ SELECT
+ ma_id,
+ name,
+ country,
+ COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
+ genre,
+ location_text,
+ enriched,
+ formed_year,
+ themes,
+ lat,
+ lon
+ FROM bands
+ ${whereSql}
+ ORDER BY ma_id ASC
+ LIMIT $${i} OFFSET $${i+1};
+ `;
+ vals.push(lim, off);
+
+ const r = await p.query(sql, vals);
+ return {
+ ok: true,
+ items: r.rows,
+ limit: lim,
+ offset: off
+ };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur lors de la recherche' });
+ }
+ });
+
+ fastify.get("/api/facets", async (req, reply) => {
+ try {
+ const p = requirePool();
+
+ const [statusRes, countryRes, genreRes, yearRes] = await Promise.all([
+ p.query(`
+ SELECT
+ COALESCE(NULLIF(trim(status), ''), 'Unknown') as value,
+ count(*)::int as count
+ FROM bands
+ WHERE geom IS NOT NULL
+ GROUP BY 1
+ ORDER BY count DESC
+ `),
+ p.query(`
+ SELECT
+ COALESCE(country, '??') as value,
+ count(*)::int as count
+ FROM bands
+ WHERE geom IS NOT NULL
+ GROUP BY 1
+ ORDER BY count DESC
+ `),
+ p.query(`
+ SELECT
+ genre as value,
+ count(*)::int as count
+ FROM bands
+ WHERE geom IS NOT NULL
+ AND genre IS NOT NULL
+ AND genre != ''
+ GROUP BY 1
+ ORDER BY count DESC
+ LIMIT 500
+ `),
+ p.query(`
+ SELECT
+ MIN(formed_year)::int as min_year,
+ MAX(formed_year)::int as max_year
+ FROM bands
+ WHERE geom IS NOT NULL
+ AND formed_year >= 1900
+ AND formed_year <= extract(year from now())
+ `)
+ ]);
+
+ return {
+ ok: true,
+ statuses: statusRes.rows,
+ countries: countryRes.rows,
+ genres: genreRes.rows,
+ year_range: yearRes.rows[0] || { min_year: null, max_year: null }
+ };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des facettes' });
+ }
+ });
+
+ fastify.get("/api/band/:ma_id", async (req, reply) => {
+ try {
+ const p = requirePool();
+ const { ma_id } = /** @type {{ ma_id: string }} */ (req.params);
+ const id = Number(ma_id);
+
+ if (!Number.isFinite(id) || id < 0) {
+ return reply.code(400).send({ ok: false, error: "bad ma_id" });
+ }
+
+ const r = await p.query(
+ `SELECT
+ ma_id,
+ name,
+ country,
+ status,
+ genre,
+ location_text,
+ enriched,
+ data,
+ formed_year,
+ themes,
+ lat,
+ lon,
+ geocoded_at
+ FROM bands
+ WHERE ma_id = $1
+ LIMIT 1`,
+ [id]
+ );
+
+ if (!r.rows.length) {
+ return reply.code(404).send({ ok: false, error: "not found" });
+ }
+
+ return { ok: true, item: r.rows[0] };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération du groupe' });
+ }
+ });
+
+ fastify.register(async function(adminRoutes) {
+ await adminRoutes.register(rateLimit, {
+ max: adminRateLimitMax,
+ timeWindow: '1 minute',
+ keyGenerator: (req) => {
+ return authBearer(req) || req.ip;
+ }
+ });
+
+ adminRoutes.post("/admin/import", async (req, reply) => {
+ if (!requireAdmin(req, reply)) return;
+
+ try {
+ const body = /** @type {{ bands?: unknown[] }} */ (req.body);
+
+ if (!body || typeof body !== "object") {
+ return reply.code(400).send({ ok: false, error: "invalid body/json" });
+ }
+
+ const bands = Array.isArray(body.bands) ? body.bands : null;
+
+ if (!bands) {
+ return reply.code(400).send({ ok: false, error: "missing bands[]" });
+ }
+
+ if (bands.length > 1000) {
+ return reply.code(400).send({
+ ok: false,
+ error: "Max 1000 bands par import"
+ });
+ }
+
+ const p = requirePool();
+ let upserted = 0;
+
+ for (const raw of bands) {
+ if (!raw || typeof raw !== "object") continue;
+ const b = /** @type {Record} */ (raw);
+
+ const ma_id = Number(b.ma_id);
+ if (!Number.isFinite(ma_id)) continue;
+
+ const name = b.name ?? null;
+ const country = b.country ?? null;
+ const status = b.status ?? null;
+ const genre = b.genre ?? null;
+ const location_text = b.location_text ?? b.location ?? null;
+ const hasData = b.data && typeof b.data === "object";
+ const data = hasData ? b.data : null;
+ const enriched = hasData ? true : (b.enriched ?? null);
+
+ await p.query(
+ `
+ INSERT INTO bands (ma_id, name, country, status, genre, location_text, data, enriched)
+ VALUES ($1,$2,$3,$4,$5,$6,$7, COALESCE($8,false))
+ ON CONFLICT (ma_id) DO UPDATE SET
+ name = COALESCE(EXCLUDED.name, bands.name),
+ country = COALESCE(EXCLUDED.country, bands.country),
+ status = COALESCE(EXCLUDED.status, bands.status),
+ genre = COALESCE(EXCLUDED.genre, bands.genre),
+ location_text = COALESCE(EXCLUDED.location_text, bands.location_text),
+ data = COALESCE(EXCLUDED.data, bands.data),
+ enriched = COALESCE(EXCLUDED.enriched, bands.enriched)
+ `,
+ [ma_id, name, country, status, genre, location_text, data, enriched]
+ );
+ upserted++;
+ }
+
+ return reply.send({ ok: true, upserted });
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur lors de l\'import' });
+ }
+ });
+
+ adminRoutes.get("/admin/enrich/next", async (req, reply) => {
+ if (!requireAdmin(req, reply)) return;
+
+ try {
+ const p = requirePool();
+ const query = /** @type {Record} */ (req.query || {});
+ const limitRaw = Number(query.limit ?? 50);
+ const limit = Math.max(1, Math.min(Number.isFinite(limitRaw) ? limitRaw : 50, 500));
+ const country = (query.country ?? "").toString().trim().toUpperCase();
+
+ const params = [];
+ let where = "WHERE (data->'band_page') IS NULL";
+
+ if (country) {
+ params.push(country);
+ where += ` AND country = $${params.length}`;
+ }
+
+ params.push(limit);
+
+ const sql = `
+ SELECT
+ ma_id,
+ (data->>'url') AS url
+ FROM bands
+ ${where}
+ ORDER BY ma_id ASC
+ LIMIT $${params.length}
+ `;
+
+ const r = await p.query(sql, params);
+ const items = (r.rows || []).filter((x) => x.url);
+
+ return { ok: true, count: items.length, items };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des groupes à enrichir' });
+ }
+ });
+ });
+
+ // ------------------------------------------------------------------
+ // Auth dashboard admin (session cookie, distincte du token importToken)
+ // ------------------------------------------------------------------
+ fastify.register(async function (authRoutes) {
+ await authRoutes.register(rateLimit, {
+ max: authRateLimitMax,
+ timeWindow: "1 minute",
+ keyGenerator: (req) => req.ip,
+ });
+
+ authRoutes.post("/admin/auth/login", async (req, reply) => {
+ try {
+ const p = requirePool();
+ const { username, password } = /** @type {{ username?: unknown, password?: unknown }} */ (req.body || {});
+ if (typeof username !== "string" || typeof password !== "string" || !username || !password) {
+ return reply.code(400).send({ ok: false, error: "username et password requis" });
+ }
+ const uname = username.trim().slice(0, 100);
+ const ip = req.ip;
+
+ if (await isLockedOut(p, uname, ip)) {
+ return reply.code(429).send({ ok: false, error: "Trop de tentatives, réessayez dans quelques minutes" });
+ }
+
+ const valid = await verifyPassword(p, uname, password);
+ await recordLoginAttempt(p, uname, ip, valid);
+
+ if (!valid) {
+ return reply.code(401).send({ ok: false, error: "Identifiants invalides" });
+ }
+
+ await markLoginSuccess(p, uname);
+ const token = signAdminSession(uname);
+ reply.setCookie(ADMIN_COOKIE_NAME, token, {
+ httpOnly: true,
+ secure: true,
+ sameSite: "strict",
+ path: "/",
+ maxAge: 12 * 3600,
+ });
+ return { ok: true, username: uname };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: "Erreur de connexion" });
+ }
+ });
+
+ authRoutes.post("/admin/auth/logout", async (req, reply) => {
+ reply.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
+ return { ok: true };
+ });
+
+ authRoutes.get("/admin/auth/me", async (req, reply) => {
+ const username = requireAdminSession(req, reply);
+ if (!username) return;
+ return { ok: true, username };
+ });
+ });
+
+ await fastify.register(adminApiRoutes, { pool });
+
+ await fastify.ready();
+ return fastify;
+}
diff --git a/apps/api/src/server.js b/apps/api/src/server.js
index 33fa6e9..1b4d5ea 100644
--- a/apps/api/src/server.js
+++ b/apps/api/src/server.js
@@ -1,811 +1,25 @@
-import Fastify from "fastify";
+/**
+ * Point d'entrée du process. Toute la logique vit dans app.js pour rester
+ * testable ; ce fichier ne fait qu'ouvrir la connexion DB et écouter.
+ */
import pg from "pg";
-import rateLimit from "@fastify/rate-limit";
-import helmet from "@fastify/helmet";
-import cookie from "@fastify/cookie";
-import { timingSafeEqual } from "crypto";
-import {
- ADMIN_COOKIE_NAME,
- seedAdminUser,
- signAdminSession,
- isLockedOut,
- recordLoginAttempt,
- verifyPassword,
- markLoginSuccess,
- requireAdminSession,
-} from "./adminAuth.js";
-import adminApiRoutes from "./adminRoutes.js";
+import { buildServer } from "./app.js";
const { Pool } = pg;
-const fastify = Fastify({
- logger: true,
- bodyLimit: 10485760, // 10 MB max pour éviter DoS mémoire
- trustProxy: true // derrière Traefik (+ nginx pour /admin/*) : lit X-Forwarded-For
-});
-
-// Headers de sécurité
-await fastify.register(helmet, {
- contentSecurityPolicy: false,
- crossOriginEmbedderPolicy: false
-});
-
-await fastify.register(cookie);
-
-// CORS middleware
-fastify.addHook('onRequest', async (request, reply) => {
- const origin = request.headers.origin;
- const allowedOrigins = process.env.CORS_ORIGINS
- ? process.env.CORS_ORIGINS.split(',').map(s => s.trim())
- : ['https://metalfrom.eu', 'https://www.metalfrom.eu'];
-
- if (allowedOrigins.includes(origin)) {
- reply.header('Access-Control-Allow-Origin', origin);
- }
-
- reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
- reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
- reply.header('Access-Control-Allow-Credentials', 'true');
-
- if (request.method === 'OPTIONS') {
- reply.status(204).send();
- }
-});
-
-// Rate limiting global
-await fastify.register(rateLimit, {
- max: 1000,
- timeWindow: '1 minute',
- cache: 10000
-});
-
-// Gestion d'erreurs globale
-fastify.setErrorHandler((error, request, reply) => {
- fastify.log.error(error);
- reply.status(500).send({
- ok: false,
- error: 'Une erreur est survenue'
- });
-});
-
const PORT = Number(process.env.PORT || 3000);
const DATABASE_URL = process.env.DATABASE_URL;
-const BM_IMPORT_TOKEN = (process.env.BM_IMPORT_TOKEN || "").trim();
-let pool = null;
-if (DATABASE_URL) {
- pool = new Pool({
- connectionString: DATABASE_URL,
- statement_timeout: 60000
- });
- seedAdminUser(pool).catch((err) => fastify.log.error({ err }, "[admin] seed failed"));
-}
+const pool = DATABASE_URL
+ ? new Pool({ connectionString: DATABASE_URL, statement_timeout: 60000 })
+ : null;
-function requirePool() {
- if (!pool) throw new Error("DATABASE_URL not set");
- return pool;
-}
+const fastify = await buildServer({ pool, logger: true });
-function authBearer(req) {
- const h = req.headers.authorization || "";
- const m = h.match(/^Bearer\s+(.+)$/i);
- return m ? m[1] : null;
-}
-
-function requireAdmin(req, reply) {
- const provided = authBearer(req);
-
- if (!BM_IMPORT_TOKEN || !provided || provided.length !== BM_IMPORT_TOKEN.length) {
- reply.code(401).send({ ok: false, error: "unauthorized" });
- return false;
- }
-
- const valid = timingSafeEqual(
- Buffer.from(provided),
- Buffer.from(BM_IMPORT_TOKEN)
- );
-
- if (!valid) {
- reply.code(401).send({ ok: false, error: "unauthorized" });
- return false;
- }
-
- return true;
-}
-
-function sanitizeSearchString(str, fieldName = 'champ') {
- const trimmed = String(str).trim();
-
- if (trimmed.length < 2 || trimmed.length > 100) {
- throw new Error(`${fieldName} doit faire entre 2 et 100 caractères`);
- }
-
- if (/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(trimmed)) {
- throw new Error(`${fieldName} contient des caractères invalides`);
- }
-
- if (/%%%|_{10,}|%{10,}/.test(trimmed)) {
- throw new Error(`${fieldName} contient des patterns invalides`);
- }
-
- return trimmed;
-}
-
-fastify.get("/", async () => {
- return { ok: true, service: "BM API" };
+// `listen()` renvoie une promesse : sans catch, un port déjà pris produisait un
+// unhandled rejection et un process zombie au lieu d'un exit code non nul (que
+// Docker/Coolify utilisent pour redémarrer le conteneur).
+fastify.listen({ port: PORT, host: "0.0.0.0" }).catch((err) => {
+ fastify.log.error(err, "[api] listen failed");
+ process.exit(1);
});
-
-fastify.get("/api/health", async () => ({ ok: true }));
-
-fastify.get("/api/db", async (req, reply) => {
- try {
- const p = requirePool();
- const r = await p.query("SELECT now() as now, current_database() as db");
- return { ok: true, ...r.rows[0] };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur de connexion à la base de données' });
- }
-});
-
-fastify.get("/api/stats", async (req, reply) => {
- try {
- const p = requirePool();
- const q = `
- SELECT
- count(*)::int as total,
- count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded,
- count(*) FILTER (WHERE geom IS NULL)::int as no_location,
- count(*) FILTER (WHERE enriched = true)::int as enriched
- FROM bands;
- `;
- const r = await p.query(q);
- return { ok: true, ...r.rows[0] };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des stats' });
- }
-});
-
-fastify.get("/api/countries", async (req, reply) => {
- try {
- const p = requirePool();
- const q = `
- SELECT
- country,
- count(*)::int as total,
- count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded,
- count(*) FILTER (WHERE enriched = true)::int as enriched
- FROM bands
- GROUP BY country
- ORDER BY total DESC;
- `;
- const r = await p.query(q);
- return { ok: true, items: r.rows };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des pays' });
- }
-});
-
-fastify.get("/api/statuses", async (req, reply) => {
- try {
- const p = requirePool();
- const q = `
- SELECT
- COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
- count(*)::int as total,
- count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded
- FROM bands
- GROUP BY 1
- ORDER BY total DESC;
- `;
- const r = await p.query(q);
- return { ok: true, items: r.rows };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des statuts' });
- }
-});
-
-fastify.get("/api/clusters", async (req, reply) => {
- try {
- const p = requirePool();
- const {
- bbox,
- zoom,
- countries,
- status,
- genre,
- year_min,
- year_max,
- } = req.query || {};
-
- if (!bbox || !zoom) {
- return reply.code(400).send({ ok: false, error: "bbox and zoom are required" });
- }
-
- const bboxParts = String(bbox).split(",").map(Number);
- if (bboxParts.length !== 4) {
- return reply.code(400).send({ ok: false, error: "bbox must have 4 values" });
- }
-
- const [minLon, minLat, maxLon, maxLat] = bboxParts;
- const zoomLevel = Number(zoom);
-
- if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) {
- return reply.code(400).send({ ok: false, error: "Invalid bbox or zoom values" });
- }
-
- if (minLat < -90 || maxLat > 90 || minLon < -180 || maxLon > 180) {
- return reply.code(400).send({ ok: false, error: "Coordinates out of range" });
- }
-
- if (minLon >= maxLon || minLat >= maxLat) {
- return reply.code(400).send({ ok: false, error: "Invalid bbox bounds" });
- }
-
- if (zoomLevel < 0 || zoomLevel > 22) {
- return reply.code(400).send({ ok: false, error: "Zoom level must be between 0 and 22" });
- }
-
- const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel));
-
- // Source: band_locations (un point par localisation géocodée) JOIN bands.
- const where = [
- "bl.geom IS NOT NULL",
- "bl.geocode_status IN ('done','country_only')",
- ];
- const vals = [];
- let i = 1;
-
- where.push(`bl.geom && ST_MakeEnvelope($${i}, $${i+1}, $${i+2}, $${i+3}, 4326)`);
- vals.push(minLon, minLat, maxLon, maxLat);
- i += 4;
-
- if (countries) {
- const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
- if (cs.length > 100) {
- return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" });
- }
- if (cs.length) {
- where.push(`b.country = ANY($${i}::text[])`);
- vals.push(cs);
- i++;
- }
- }
-
- if (status) {
- const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
- if (st.length > 50) {
- return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" });
- }
- if (st.length) {
- where.push(`COALESCE(NULLIF(trim(b.status), ''), 'Unknown') = ANY($${i}::text[])`);
- vals.push(st);
- i++;
- }
- }
-
- if (genre) {
- try {
- const genreQuery = sanitizeSearchString(genre, 'Genre');
- where.push(`b.genre ILIKE $${i}`);
- vals.push(`%${genreQuery}%`);
- i++;
- } catch (err) {
- return reply.code(400).send({ ok: false, error: err.message });
- }
- }
-
- if (year_min) {
- const yearMin = Number(year_min);
- if (!Number.isFinite(yearMin) || yearMin < 1800 || yearMin > 2100) {
- return reply.code(400).send({ ok: false, error: 'year_min invalide' });
- }
- where.push(`b.formed_year >= $${i}`);
- vals.push(yearMin);
- i++;
- }
-
- if (year_max) {
- const yearMax = Number(year_max);
- if (!Number.isFinite(yearMax) || yearMax < 1800 || yearMax > 2100) {
- return reply.code(400).send({ ok: false, error: 'year_max invalide' });
- }
- where.push(`b.formed_year <= $${i}`);
- vals.push(yearMax);
- i++;
- }
-
- const whereSql = where.join(" AND ");
-
- if (zoomLevel >= 12) {
- const sql = `
- SELECT
- b.ma_id,
- b.name,
- b.country,
- COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status,
- b.genre,
- b.location_text,
- b.formed_year,
- bl.lat,
- bl.lon,
- bl.location_raw,
- bl.step_order,
- bl.step_label,
- bl.is_country_only
- FROM band_locations bl
- JOIN bands b ON b.ma_id = bl.ma_id
- WHERE ${whereSql}
- ORDER BY bl.ma_id ASC, bl.step_order ASC
- LIMIT 2000;
- `;
- const r = await p.query(sql, vals);
- return {
- ok: true,
- type: "bands",
- items: r.rows,
- count: r.rows.length
- };
- }
-
- const sql = `
- WITH grid_cells AS (
- SELECT
- floor(ST_X(bl.geom) / $${i})::int as cell_x,
- floor(ST_Y(bl.geom) / $${i})::int as cell_y,
- b.ma_id,
- b.name,
- b.country,
- COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status,
- b.genre,
- b.location_text,
- b.formed_year,
- bl.lat,
- bl.lon,
- bl.location_raw,
- bl.step_label
- FROM band_locations bl
- JOIN bands b ON b.ma_id = bl.ma_id
- WHERE ${whereSql}
- ),
- clusters AS (
- SELECT
- cell_x,
- cell_y,
- count(*)::int as band_count,
- avg(lat)::float8 as center_lat,
- avg(lon)::float8 as center_lon,
- array_agg(json_build_object(
- 'ma_id', ma_id,
- 'name', name,
- 'country', country,
- 'status', status,
- 'genre', genre,
- 'location_text', location_text,
- 'formed_year', formed_year,
- 'lat', lat,
- 'lon', lon,
- 'location_raw', location_raw,
- 'step_label', step_label
- ) ORDER BY name) as bands
- FROM grid_cells
- GROUP BY cell_x, cell_y
- )
- SELECT
- center_lat as lat,
- center_lon as lon,
- band_count as count,
- CASE
- WHEN band_count <= 5 THEN bands
- ELSE bands[1:5]
- END as sample_bands
- FROM clusters
- ORDER BY band_count DESC
- LIMIT 1000;
- `;
- vals.push(cellSize);
-
- const r = await p.query(sql, vals);
- return {
- ok: true,
- type: "clusters",
- items: r.rows,
- total_clusters: r.rows.length,
- cell_size: cellSize,
- zoom: zoomLevel
- };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des clusters' });
- }
-});
-
-fastify.get("/api/bands", async (req, reply) => {
- try {
- const p = requirePool();
- const {
- countries,
- geocoded,
- q,
- status,
- only_black,
- limit,
- offset,
- } = req.query || {};
-
- const lim = Math.min(Number(limit || 1000), 150000);
- const off = Math.max(Number(offset || 0), 0);
-
- if (off > 1000000) {
- return reply.code(400).send({
- ok: false,
- error: "Offset trop grand (max 1 million)"
- });
- }
-
- if (lim > 10000) {
- fastify.log.warn(`Large query: ${lim} rows requested from ${req.ip}`);
- }
-
- const where = [];
- const vals = [];
- let i = 1;
-
- if (countries) {
- const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
- if (cs.length > 100) {
- return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" });
- }
- if (cs.length) {
- where.push(`country = ANY($${i}::text[])`);
- vals.push(cs);
- i++;
- }
- }
-
- if (geocoded === "1") where.push(`geom IS NOT NULL`);
- if (geocoded === "0") where.push(`geom IS NULL`);
-
- if (status) {
- const st = String(status).split(",").map(s => s.trim()).filter(Boolean);
- if (st.length > 50) {
- return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" });
- }
- if (st.length) {
- where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
- vals.push(st);
- i++;
- }
- }
-
- if (only_black === "1") {
- where.push(`genre ILIKE '%black%'`);
- }
-
- if (q) {
- try {
- const query = sanitizeSearchString(q, 'Recherche');
- const qq = `%${query}%`;
- where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`);
- vals.push(qq);
- i++;
- } catch (err) {
- return reply.code(400).send({ ok: false, error: err.message });
- }
- }
-
- const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
-
- const sql = `
- SELECT
- ma_id,
- name,
- country,
- COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
- genre,
- location_text,
- enriched,
- formed_year,
- themes,
- lat,
- lon
- FROM bands
- ${whereSql}
- ORDER BY ma_id ASC
- LIMIT $${i} OFFSET $${i+1};
- `;
- vals.push(lim, off);
-
- const r = await p.query(sql, vals);
- return {
- ok: true,
- items: r.rows,
- limit: lim,
- offset: off
- };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur lors de la recherche' });
- }
-});
-
-fastify.get("/api/facets", async (req, reply) => {
- try {
- const p = requirePool();
-
- const [statusRes, countryRes, genreRes, yearRes] = await Promise.all([
- p.query(`
- SELECT
- COALESCE(NULLIF(trim(status), ''), 'Unknown') as value,
- count(*)::int as count
- FROM bands
- WHERE geom IS NOT NULL
- GROUP BY 1
- ORDER BY count DESC
- `),
- p.query(`
- SELECT
- COALESCE(country, '??') as value,
- count(*)::int as count
- FROM bands
- WHERE geom IS NOT NULL
- GROUP BY 1
- ORDER BY count DESC
- `),
- p.query(`
- SELECT
- genre as value,
- count(*)::int as count
- FROM bands
- WHERE geom IS NOT NULL
- AND genre IS NOT NULL
- AND genre != ''
- GROUP BY 1
- ORDER BY count DESC
- LIMIT 500
- `),
- p.query(`
- SELECT
- MIN(formed_year)::int as min_year,
- MAX(formed_year)::int as max_year
- FROM bands
- WHERE geom IS NOT NULL
- AND formed_year >= 1900
- AND formed_year <= extract(year from now())
- `)
- ]);
-
- return {
- ok: true,
- statuses: statusRes.rows,
- countries: countryRes.rows,
- genres: genreRes.rows,
- year_range: yearRes.rows[0] || { min_year: null, max_year: null }
- };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des facettes' });
- }
-});
-
-fastify.get("/api/band/:ma_id", async (req, reply) => {
- try {
- const p = requirePool();
- const id = Number(req.params.ma_id);
-
- if (!Number.isFinite(id) || id < 0) {
- return reply.code(400).send({ ok: false, error: "bad ma_id" });
- }
-
- const r = await p.query(
- `SELECT
- ma_id,
- name,
- country,
- status,
- genre,
- location_text,
- enriched,
- data,
- formed_year,
- themes,
- lat,
- lon,
- geocoded_at
- FROM bands
- WHERE ma_id = $1
- LIMIT 1`,
- [id]
- );
-
- if (!r.rows.length) {
- return reply.code(404).send({ ok: false, error: "not found" });
- }
-
- return { ok: true, item: r.rows[0] };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération du groupe' });
- }
-});
-
-fastify.register(async function(adminRoutes) {
- await adminRoutes.register(rateLimit, {
- max: 10,
- timeWindow: '1 minute',
- keyGenerator: (req) => {
- return authBearer(req) || req.ip;
- }
- });
-
- adminRoutes.post("/admin/import", async (req, reply) => {
- if (!requireAdmin(req, reply)) return;
-
- try {
- const body = req.body;
-
- if (!body || typeof body !== "object") {
- return reply.code(400).send({ ok: false, error: "invalid body/json" });
- }
-
- const bands = Array.isArray(body.bands) ? body.bands : null;
-
- if (!bands) {
- return reply.code(400).send({ ok: false, error: "missing bands[]" });
- }
-
- if (bands.length > 1000) {
- return reply.code(400).send({
- ok: false,
- error: "Max 1000 bands par import"
- });
- }
-
- const p = requirePool();
- let upserted = 0;
-
- for (const b of bands) {
- if (!b || typeof b !== "object") continue;
-
- const ma_id = Number(b.ma_id);
- if (!Number.isFinite(ma_id)) continue;
-
- const name = b.name ?? null;
- const country = b.country ?? null;
- const status = b.status ?? null;
- const genre = b.genre ?? null;
- const location_text = b.location_text ?? b.location ?? null;
- const hasData = b.data && typeof b.data === "object";
- const data = hasData ? b.data : null;
- const enriched = hasData ? true : (b.enriched ?? null);
-
- await p.query(
- `
- INSERT INTO bands (ma_id, name, country, status, genre, location_text, data, enriched)
- VALUES ($1,$2,$3,$4,$5,$6,$7, COALESCE($8,false))
- ON CONFLICT (ma_id) DO UPDATE SET
- name = COALESCE(EXCLUDED.name, bands.name),
- country = COALESCE(EXCLUDED.country, bands.country),
- status = COALESCE(EXCLUDED.status, bands.status),
- genre = COALESCE(EXCLUDED.genre, bands.genre),
- location_text = COALESCE(EXCLUDED.location_text, bands.location_text),
- data = COALESCE(EXCLUDED.data, bands.data),
- enriched = COALESCE(EXCLUDED.enriched, bands.enriched)
- `,
- [ma_id, name, country, status, genre, location_text, data, enriched]
- );
- upserted++;
- }
-
- return reply.send({ ok: true, upserted });
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur lors de l\'import' });
- }
- });
-
- adminRoutes.get("/admin/enrich/next", async (req, reply) => {
- if (!requireAdmin(req, reply)) return;
-
- try {
- const p = requirePool();
- const limitRaw = Number(req.query?.limit ?? 50);
- const limit = Math.max(1, Math.min(Number.isFinite(limitRaw) ? limitRaw : 50, 500));
- const country = (req.query?.country ?? "").toString().trim().toUpperCase();
-
- const params = [];
- let where = "WHERE (data->'band_page') IS NULL";
-
- if (country) {
- params.push(country);
- where += ` AND country = $${params.length}`;
- }
-
- params.push(limit);
-
- const sql = `
- SELECT
- ma_id,
- (data->>'url') AS url
- FROM bands
- ${where}
- ORDER BY ma_id ASC
- LIMIT $${params.length}
- `;
-
- const r = await p.query(sql, params);
- const items = (r.rows || []).filter((x) => x.url);
-
- return { ok: true, count: items.length, items };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des groupes à enrichir' });
- }
- });
-});
-
-// ------------------------------------------------------------------
-// Auth dashboard admin (session cookie, distincte du token BM_IMPORT_TOKEN)
-// ------------------------------------------------------------------
-fastify.register(async function (authRoutes) {
- await authRoutes.register(rateLimit, {
- max: 10,
- timeWindow: "1 minute",
- keyGenerator: (req) => req.ip,
- });
-
- authRoutes.post("/admin/auth/login", async (req, reply) => {
- try {
- const p = requirePool();
- const { username, password } = req.body || {};
- if (typeof username !== "string" || typeof password !== "string" || !username || !password) {
- return reply.code(400).send({ ok: false, error: "username et password requis" });
- }
- const uname = username.trim().slice(0, 100);
- const ip = req.ip;
-
- if (await isLockedOut(p, uname, ip)) {
- return reply.code(429).send({ ok: false, error: "Trop de tentatives, réessayez dans quelques minutes" });
- }
-
- const valid = await verifyPassword(p, uname, password);
- await recordLoginAttempt(p, uname, ip, valid);
-
- if (!valid) {
- return reply.code(401).send({ ok: false, error: "Identifiants invalides" });
- }
-
- await markLoginSuccess(p, uname);
- const token = signAdminSession(uname);
- reply.setCookie(ADMIN_COOKIE_NAME, token, {
- httpOnly: true,
- secure: true,
- sameSite: "strict",
- path: "/",
- maxAge: 12 * 3600,
- });
- return { ok: true, username: uname };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: "Erreur de connexion" });
- }
- });
-
- authRoutes.post("/admin/auth/logout", async (req, reply) => {
- reply.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
- return { ok: true };
- });
-
- authRoutes.get("/admin/auth/me", async (req, reply) => {
- const username = requireAdminSession(req, reply);
- if (!username) return;
- return { ok: true, username };
- });
-});
-
-await fastify.register(adminApiRoutes, { pool });
-
-fastify.listen({ port: PORT, host: "0.0.0.0" });
\ No newline at end of file
diff --git a/apps/api/src/validate.js b/apps/api/src/validate.js
new file mode 100644
index 0000000..e1219a3
--- /dev/null
+++ b/apps/api/src/validate.js
@@ -0,0 +1,161 @@
+/**
+ * Helpers de validation purs (aucune dépendance à Fastify / pg).
+ *
+ * Extraits de server.js / adminRoutes.js pour être testables unitairement sans
+ * démarrer de serveur ni de base de données.
+ *
+ * Convention : chaque helper renvoie soit la valeur normalisée, soit lève une
+ * ValidationError dont le `message` est directement affichable à l'utilisateur.
+ */
+
+export class ValidationError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "ValidationError";
+ }
+}
+
+/**
+ * Nettoie une chaîne de recherche destinée à un ILIKE.
+ * Rejette les caractères de contrôle et les patterns pathologiques qui font
+ * exploser le planner Postgres (backtracking sur `%%%`, `____________`, …).
+ */
+export function sanitizeSearchString(str, fieldName = "champ") {
+ const trimmed = String(str).trim();
+
+ if (trimmed.length < 2 || trimmed.length > 100) {
+ throw new ValidationError(`${fieldName} doit faire entre 2 et 100 caractères`);
+ }
+ // eslint-disable-next-line no-control-regex
+ if (/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(trimmed)) {
+ throw new ValidationError(`${fieldName} contient des caractères invalides`);
+ }
+ if (/%%%|_{10,}|%{10,}/.test(trimmed)) {
+ throw new ValidationError(`${fieldName} contient des patterns invalides`);
+ }
+
+ return trimmed;
+}
+
+/**
+ * Parse et valide un `limit`/`offset` de liste publique.
+ *
+ * Corrige un bug de l'implémentation initiale : `Number("abc")` donnait NaN,
+ * que `Math.min` propageait jusqu'au `LIMIT $n` de la requête, provoquant une
+ * erreur pg et un 500 au lieu d'un 400. On retombe désormais sur la valeur par
+ * défaut pour une entrée non numérique.
+ */
+export function parseLimitOffset(rawLimit, rawOffset, { defaultLimit = 1000, maxLimit = 10000 } = {}) {
+ const limitNum = rawLimit === undefined || rawLimit === null || rawLimit === "" ? defaultLimit : Number(rawLimit);
+ if (!Number.isFinite(limitNum) || limitNum < 1) {
+ throw new ValidationError("limit invalide");
+ }
+ const offsetNum = rawOffset === undefined || rawOffset === null || rawOffset === "" ? 0 : Number(rawOffset);
+ if (!Number.isFinite(offsetNum) || offsetNum < 0) {
+ throw new ValidationError("offset invalide");
+ }
+ if (offsetNum > 1_000_000) {
+ throw new ValidationError("Offset trop grand (max 1 million)");
+ }
+
+ return {
+ limit: Math.min(Math.floor(limitNum), maxLimit),
+ offset: Math.floor(offsetNum),
+ };
+}
+
+/** Pagination admin : page/pageSize bornés, offset dérivé. */
+export function pagination(query = {}) {
+ const page = Math.max(1, Number(query.page) || 1);
+ const pageSize = Math.min(200, Math.max(1, Number(query.pageSize) || 50));
+ return { page, pageSize, offset: (page - 1) * pageSize };
+}
+
+/**
+ * Parse "minLon,minLat,maxLon,maxLat" et valide la cohérence géographique.
+ */
+export function parseBbox(raw) {
+ if (!raw) throw new ValidationError("bbox and zoom are required");
+
+ const parts = String(raw).split(",").map(Number);
+ if (parts.length !== 4) throw new ValidationError("bbox must have 4 values");
+
+ const [minLon, minLat, maxLon, maxLat] = parts;
+ if (parts.some((v) => !Number.isFinite(v))) {
+ throw new ValidationError("Invalid bbox or zoom values");
+ }
+ if (minLat < -90 || maxLat > 90 || minLon < -180 || maxLon > 180) {
+ throw new ValidationError("Coordinates out of range");
+ }
+ if (minLon >= maxLon || minLat >= maxLat) {
+ throw new ValidationError("Invalid bbox bounds");
+ }
+
+ return { minLon, minLat, maxLon, maxLat };
+}
+
+export function parseZoom(raw) {
+ const zoom = Number(raw);
+ if (!Number.isFinite(zoom)) throw new ValidationError("Invalid bbox or zoom values");
+ if (zoom < 0 || zoom > 22) throw new ValidationError("Zoom level must be between 0 and 22");
+ return zoom;
+}
+
+/** Taille de cellule de la grille de clustering, en degrés. */
+export function cellSizeForZoom(zoom) {
+ return Math.max(0.01, 180 / Math.pow(2, zoom));
+}
+
+/**
+ * Découpe une liste CSV (pays, statuts…) en tableau borné.
+ * `transform` permet de normaliser chaque élément (ex: toUpperCase pour les pays).
+ *
+ * @param {unknown} raw
+ * @param {{ max?: number, label?: string, transform?: (s: string) => string }} [opts]
+ * @returns {string[]}
+ */
+export function parseCsvList(raw, { max, label, transform = (s) => s } = {}) {
+ const items = String(raw)
+ .split(",")
+ .map((s) => transform(s.trim()))
+ .filter(Boolean);
+ if (max && items.length > max) {
+ throw new ValidationError(`Max ${max} ${label} simultanés`);
+ }
+ return items;
+}
+
+/** Valide une année de formation. Renvoie null si l'entrée est vide. */
+export function parseYear(raw, fieldName = "year") {
+ if (raw === undefined || raw === null || raw === "") return null;
+ const year = Number(raw);
+ if (!Number.isFinite(year) || year < 1800 || year > 2100) {
+ throw new ValidationError(`${fieldName} invalide`);
+ }
+ return year;
+}
+
+/** Valide une latitude. `null` autorisé (efface la valeur). */
+export function parseLat(raw) {
+ if (raw === null || raw === undefined || raw === "") return null;
+ const v = Number(raw);
+ if (!Number.isFinite(v) || v < -90 || v > 90) throw new ValidationError("lat invalide");
+ return v;
+}
+
+/** Valide une longitude. `null` autorisé (efface la valeur). */
+export function parseLon(raw) {
+ if (raw === null || raw === undefined || raw === "") return null;
+ const v = Number(raw);
+ if (!Number.isFinite(v) || v < -180 || v > 180) throw new ValidationError("lon invalide");
+ return v;
+}
+
+/** Valide un identifiant Metal Archives (BIGINT positif). */
+export function parseMaId(raw) {
+ const id = Number(raw);
+ if (!Number.isInteger(id) || id < 0 || id > Number.MAX_SAFE_INTEGER) {
+ throw new ValidationError("bad ma_id");
+ }
+ return id;
+}
diff --git a/apps/api/test/adminAuth.test.js b/apps/api/test/adminAuth.test.js
new file mode 100644
index 0000000..260a1a0
--- /dev/null
+++ b/apps/api/test/adminAuth.test.js
@@ -0,0 +1,132 @@
+import { describe, it, expect, vi } from "vitest";
+
+process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
+
+const {
+ signAdminSession,
+ verifyAdminSession,
+ isLockedOut,
+ verifyPassword,
+ requireAdminSession,
+ ADMIN_COOKIE_NAME,
+} = await import("../src/adminAuth.js");
+const { makeFakePool, rows } = await import("./helpers/fakePool.js");
+
+describe("sessions JWT", () => {
+ it("aller-retour signature / vérification", () => {
+ expect(verifyAdminSession(signAdminSession("nico"))).toBe("nico");
+ });
+
+ it("rejette un jeton signé avec un autre secret", async () => {
+ const jwt = (await import("jsonwebtoken")).default;
+ const forged = jwt.sign({ sub: "nico" }, "un-autre-secret-de-32-caracteres-ok");
+ expect(verifyAdminSession(forged)).toBeNull();
+ });
+
+ it("rejette un jeton `alg: none` (attaque classique)", async () => {
+ const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
+ const payload = Buffer.from(JSON.stringify({ sub: "nico" })).toString("base64url");
+ expect(verifyAdminSession(`${header}.${payload}.`)).toBeNull();
+ });
+
+ it("rejette un jeton expiré", async () => {
+ const jwt = (await import("jsonwebtoken")).default;
+ const expired = jwt.sign({ sub: "nico" }, process.env.ADMIN_JWT_SECRET, { expiresIn: -10 });
+ expect(verifyAdminSession(expired)).toBeNull();
+ });
+
+ it("rejette un jeton sans sub exploitable", async () => {
+ const jwt = (await import("jsonwebtoken")).default;
+ const noSub = jwt.sign({ sub: { admin: true } }, process.env.ADMIN_JWT_SECRET);
+ expect(verifyAdminSession(noSub)).toBeNull();
+ });
+
+ it("rejette une chaîne quelconque sans lever d'exception", () => {
+ expect(verifyAdminSession("")).toBeNull();
+ expect(verifyAdminSession("aaa.bbb.ccc")).toBeNull();
+ });
+});
+
+describe("requireAdminSession", () => {
+ function fakeReply() {
+ const reply = {
+ statusCode: null,
+ body: null,
+ code(c) { reply.statusCode = c; return reply; },
+ send(b) { reply.body = b; return reply; },
+ };
+ return reply;
+ }
+
+ it("renvoie le username et ne répond pas quand le cookie est valide", () => {
+ const reply = fakeReply();
+ const req = { cookies: { [ADMIN_COOKIE_NAME]: signAdminSession("nico") } };
+ expect(requireAdminSession(req, reply)).toBe("nico");
+ expect(reply.statusCode).toBeNull();
+ });
+
+ it("répond 401 et renvoie null sans cookie", () => {
+ const reply = fakeReply();
+ expect(requireAdminSession({ cookies: {} }, reply)).toBeNull();
+ expect(reply.statusCode).toBe(401);
+ });
+
+ it("ne plante pas quand `cookies` est absent", () => {
+ const reply = fakeReply();
+ expect(requireAdminSession({}, reply)).toBeNull();
+ expect(reply.statusCode).toBe(401);
+ });
+});
+
+describe("verifyPassword", () => {
+ it("renvoie false pour un utilisateur inexistant", async () => {
+ const bcrypt = (await import("bcryptjs")).default;
+ // Le DUMMY_HASH de production est à coût 12 (~330 ms par comparaison).
+ // On neutralise le calcul : ce test porte sur la valeur de retour, et le
+ // test suivant vérifie séparément que la comparaison a bien lieu.
+ const spy = vi.spyOn(bcrypt, "compare").mockResolvedValue(true);
+ const pool = makeFakePool([{ match: "FROM admin_users", result: rows() }]);
+ // Même si bcrypt dit « vrai », l'absence d'utilisateur doit l'emporter.
+ expect(await verifyPassword(pool, "inconnu", "peu-importe")).toBe(false);
+ spy.mockRestore();
+ });
+
+ // Anti-énumération : le hash factice doit être comparé même quand l'utilisateur
+ // n'existe pas, sinon le temps de réponse révèle les comptes valides.
+ it("compare quand même un hash pour un utilisateur inexistant", async () => {
+ const bcrypt = (await import("bcryptjs")).default;
+ const spy = vi.spyOn(bcrypt, "compare").mockResolvedValue(false);
+ const pool = makeFakePool([{ match: "FROM admin_users", result: rows() }]);
+ await verifyPassword(pool, "inconnu", "x");
+ expect(spy).toHaveBeenCalled();
+ // La comparaison doit porter sur un hash bcrypt valide, sinon le temps de
+ // réponse trahirait l'absence de compte.
+ expect(spy.mock.calls[0][1]).toMatch(/^\$2[aby]\$\d\d\$/);
+ spy.mockRestore();
+ });
+
+ it("renvoie true pour le bon mot de passe", async () => {
+ const bcrypt = (await import("bcryptjs")).default;
+ const hash = bcrypt.hashSync("bon-mot-de-passe", 4); // coût faible : test rapide
+ const pool = makeFakePool([{ match: "FROM admin_users", result: rows({ password_hash: hash }) }]);
+ expect(await verifyPassword(pool, "nico", "bon-mot-de-passe")).toBe(true);
+ expect(await verifyPassword(pool, "nico", "mauvais")).toBe(false);
+ });
+});
+
+describe("isLockedOut", () => {
+ it("verrouille à partir de 5 tentatives échouées", async () => {
+ const under = makeFakePool([{ match: "admin_login_attempts", result: rows({ n: 4 }) }]);
+ const at = makeFakePool([{ match: "admin_login_attempts", result: rows({ n: 5 }) }]);
+ expect(await isLockedOut(under, "nico", "1.2.3.4")).toBe(false);
+ expect(await isLockedOut(at, "nico", "1.2.3.4")).toBe(true);
+ });
+
+ it("compte les tentatives par username OU par IP, sur 15 minutes", async () => {
+ const pool = makeFakePool([{ match: "admin_login_attempts", result: rows({ n: 0 }) }]);
+ await isLockedOut(pool, "nico", "1.2.3.4");
+ const call = pool.find("admin_login_attempts");
+ expect(call.sql).toMatch(/username = \$2 OR ip = \$3/);
+ expect(call.values).toEqual([15, "nico", "1.2.3.4"]);
+ });
+});
diff --git a/apps/api/test/adminRoutes.test.js b/apps/api/test/adminRoutes.test.js
new file mode 100644
index 0000000..71f83b0
--- /dev/null
+++ b/apps/api/test/adminRoutes.test.js
@@ -0,0 +1,388 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+
+// Doit être défini avant l'import d'adminAuth (jwtSecret() lit process.env à l'appel,
+// mais on fixe la valeur ici pour que tous les tests partagent le même secret).
+process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
+
+const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js");
+const { rows } = await import("./helpers/fakePool.js");
+const { createAdminApp } = await import("./helpers/testApp.js");
+
+let sessionCookie, app, pool;
+
+beforeAll(async () => {
+ sessionCookie = `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}`;
+ ({ app, pool } = await createAdminApp());
+});
+afterAll(async () => { await app.close(); });
+
+/** Reprogramme le pool partagé et renvoie l'app (construite une seule fois). */
+function buildApp(handlers) {
+ pool.reset(handlers ?? []);
+ return app;
+}
+
+/** Requête authentifiée par défaut. */
+function auth(headers = {}) {
+ return { cookie: sessionCookie, ...headers };
+}
+
+describe("garde d'authentification", () => {
+ it("refuse toute route admin sans cookie de session", async () => {
+ const app = buildApp(([]));
+ for (const url of ["/admin/api/stats", "/admin/api/bands", "/admin/api/live", "/admin/api/activity"]) {
+ const res = await app.inject({ method: "GET", url });
+ expect(res.statusCode, url).toBe(401);
+ }
+ });
+
+ it("refuse un cookie de session forgé", async () => {
+ const app = buildApp(([]));
+ const res = await app.inject({
+ method: "GET",
+ url: "/admin/api/stats",
+ headers: { cookie: `${ADMIN_COOKIE_NAME}=pas.un.jwt` },
+ });
+ expect(res.statusCode).toBe(401);
+ });
+
+ it("ne touche pas la base quand la session est invalide", async () => {
+ const handlers = ([]);
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/stats" });
+ expect(pool.calls).toHaveLength(0);
+ });
+
+ it("accepte une session valide", async () => {
+ const handlers = ([{ match: "FROM bands", result: rows({ total: 0 }) }]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "GET", url: "/admin/api/stats", headers: auth() });
+ expect(res.statusCode).toBe(200);
+ });
+});
+
+describe("GET /admin/api/bands — construction de la requête", () => {
+ const listPool = () =>
+ ([
+ { match: "count(*)::int AS total", result: rows({ total: 3 }) },
+ { match: "SELECT ma_id, name", result: rows({ ma_id: 1, name: "Mayhem" }) },
+ ]);
+
+ it("n'accepte que les colonnes de tri de l'allowlist", async () => {
+ const handlers = listPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "GET",
+ url: "/admin/api/bands?sort=name;DROP TABLE bands--&dir=desc",
+ headers: auth(),
+ });
+ const data = pool.find("SELECT ma_id, name");
+ expect(data.sql).not.toContain("DROP TABLE");
+ expect(data.sql).toContain("ORDER BY ma_id DESC");
+ });
+
+ it("accepte une colonne de tri légitime", async () => {
+ const handlers = listPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/bands?sort=formed_year", headers: auth() });
+ expect(pool.find("SELECT ma_id, name").sql).toContain("ORDER BY formed_year ASC");
+ });
+
+ it("passe les filtres en paramètres liés, jamais en interpolation", async () => {
+ const handlers = listPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "GET",
+ url: "/admin/api/bands?q=' OR 1=1--&country=fr",
+ headers: auth(),
+ });
+ const data = pool.find("SELECT ma_id, name");
+ expect(data.sql).not.toContain("OR 1=1");
+ expect(data.values).toContain("%' OR 1=1--%");
+ expect(data.values).toContain("FR"); // pays normalisé en majuscules
+ });
+
+ it("ignore une recherche de moins de 2 caractères", async () => {
+ const handlers = listPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/bands?q=a", headers: auth() });
+ expect(pool.find("SELECT ma_id, name").sql).not.toContain("ILIKE");
+ });
+
+ it("le count et la page utilisent le même WHERE", async () => {
+ const handlers = listPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/bands?country=de&enriched=true", headers: auth() });
+ const count = pool.find("count(*)::int AS total");
+ const data = pool.find("SELECT ma_id, name");
+ expect(count.sql).toContain("country = $1");
+ expect(data.sql).toContain("country = $1");
+ // le count ne doit pas recevoir les paramètres de pagination
+ expect(count.values).toEqual(["DE"]);
+ expect(data.values).toEqual(["DE", 50, 0]);
+ });
+
+ it("borne pageSize à 200", async () => {
+ const handlers = listPool();
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "GET", url: "/admin/api/bands?pageSize=99999", headers: auth() });
+ expect(res.json().pageSize).toBe(200);
+ });
+});
+
+describe("PATCH /admin/api/bands/:ma_id", () => {
+ const patchPool = () =>
+ ([
+ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Avant" }) },
+ { match: "UPDATE bands SET", result: rows({ ma_id: 1, name: "Après" }) },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ]);
+
+ it("refuse un ma_id non numérique", async () => {
+ const app = buildApp(patchPool());
+ const res = await app.inject({
+ method: "PATCH",
+ url: "/admin/api/bands/abc",
+ headers: auth(),
+ payload: { name: "x" },
+ });
+ expect(res.statusCode).toBe(400);
+ });
+
+ it("refuse un corps sans champ modifiable", async () => {
+ const app = buildApp(patchPool());
+ const res = await app.inject({
+ method: "PATCH",
+ url: "/admin/api/bands/1",
+ headers: auth(),
+ payload: { ma_id: 999, enriched: true, crawled_at: "2020-01-01" },
+ });
+ expect(res.statusCode).toBe(400);
+ expect(res.json().error).toMatch(/Aucun champ/);
+ });
+
+ it("ignore les champs hors allowlist mais applique les autres", async () => {
+ const handlers = patchPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "PATCH",
+ url: "/admin/api/bands/1",
+ headers: auth(),
+ payload: { name: "Darkthrone", enriched: false, locked_fields: "{}" },
+ });
+ const upd = pool.find("UPDATE bands SET");
+ expect(upd.sql).toContain("name = $2");
+ expect(upd.sql).not.toContain("enriched =");
+ expect(upd.sql).not.toContain("locked_fields = $");
+ });
+
+ it.each([
+ ["formed_year", { formed_year: 1799 }],
+ ["formed_year", { formed_year: 2101 }],
+ ["lat", { lat: 91 }],
+ ["lon", { lon: -181 }],
+ ])("rejette %s hors bornes", async (_field, payload) => {
+ const app = buildApp(patchPool());
+ const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload });
+ expect(res.statusCode).toBe(400);
+ });
+
+ it("verrouille les champs édités", async () => {
+ const handlers = patchPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "PATCH",
+ url: "/admin/api/bands/1",
+ headers: auth(),
+ payload: { name: "X", country: "NO" },
+ });
+ const upd = pool.find("UPDATE bands SET");
+ expect(upd.sql).toContain("locked_fields = locked_fields ||");
+ expect(upd.values.at(-1)).toBe(JSON.stringify({ name: true, country: true }));
+ });
+
+ it("écrit une entrée d'audit", async () => {
+ const handlers = patchPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload: { name: "X" } });
+ const audit = pool.find("INSERT INTO admin_audit_log");
+ expect(audit).toBeDefined();
+ expect(audit.values[0]).toBe("nico"); // l'utilisateur de la session, pas une valeur du corps
+ });
+
+ it("404 si le groupe n'existe pas", async () => {
+ const handlers = ([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload: { name: "X" } });
+ expect(res.statusCode).toBe(404);
+ });
+});
+
+describe("POST /admin/api/bands/:ma_id/resolve-conflict", () => {
+ const conflictPool = () =>
+ ([
+ {
+ match: "SELECT * FROM bands WHERE ma_id",
+ result: rows({ ma_id: 1, crawler_pending: { name: "MA", formed_year: 1991 } }),
+ },
+ { match: "UPDATE bands SET", result: rows({ ma_id: 1 }) },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ]);
+
+ // Régression sécurité : `field` était interpolé dans le SET sans allowlist,
+ // ce qui donnait une injection SQL à tout admin authentifié.
+ it("refuse un nom de champ hors allowlist", async () => {
+ const handlers = conflictPool();
+ const app = buildApp(handlers);
+ const res = await app.inject({
+ method: "POST",
+ url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth(),
+ payload: { field: "name = 'pwned', enriched", action: "accept_crawler" },
+ });
+ expect(res.statusCode).toBe(400);
+ expect(pool.find("UPDATE bands SET")).toBeUndefined();
+ });
+
+ it("refuse une action inconnue", async () => {
+ const app = buildApp(conflictPool());
+ const res = await app.inject({
+ method: "POST",
+ url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth(),
+ payload: { field: "name", action: "drop_everything" },
+ });
+ expect(res.statusCode).toBe(400);
+ });
+
+ it("keep_mine ne touche que crawler_pending", async () => {
+ const handlers = conflictPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "POST",
+ url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth(),
+ payload: { field: "name", action: "keep_mine" },
+ });
+ const upd = pool.find("UPDATE bands SET");
+ expect(upd.sql).toContain("crawler_pending = crawler_pending - $2");
+ expect(upd.sql).not.toContain("locked_fields");
+ });
+
+ it("accept_crawler applique la valeur et déverrouille", async () => {
+ const handlers = conflictPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "POST",
+ url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth(),
+ payload: { field: "name", action: "accept_crawler" },
+ });
+ const upd = pool.find("UPDATE bands SET");
+ expect(upd.sql).toContain("name = $2::text");
+ expect(upd.sql).toContain("locked_fields = locked_fields - $3");
+ expect(upd.values[1]).toBe("MA");
+ });
+
+ // Régression : le cast `::text` en dur cassait les colonnes numériques.
+ it("utilise un cast numérique pour formed_year", async () => {
+ const handlers = conflictPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "POST",
+ url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth(),
+ payload: { field: "formed_year", action: "accept_crawler" },
+ });
+ expect(pool.find("UPDATE bands SET").sql).toContain("formed_year = $2::int");
+ });
+
+ it("utilise un cast flottant pour lat/lon", async () => {
+ const handlers = conflictPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "POST",
+ url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth(),
+ payload: { field: "lat", action: "accept_crawler" },
+ });
+ expect(pool.find("UPDATE bands SET").sql).toContain("lat = $2::double precision");
+ });
+});
+
+describe("POST /admin/api/job-triggers", () => {
+ const jobPool = () =>
+ ([
+ { match: "INSERT INTO job_triggers", result: rows({ id: 42 }) },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ ]);
+
+ it.each(["enrich", "incremental", "full_crawl", "geocoder_enqueue"])(
+ "accepte le job_type %s",
+ async (job_type) => {
+ const app = buildApp(jobPool());
+ const res = await app.inject({
+ method: "POST",
+ url: "/admin/api/job-triggers",
+ headers: auth(),
+ payload: { job_type },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().id).toBe(42);
+ }
+ );
+
+ it.each([["inconnu"], [""], ["DROP TABLE bands"]])("refuse le job_type %s", async (job_type) => {
+ const handlers = jobPool();
+ const app = buildApp(handlers);
+ const res = await app.inject({
+ method: "POST",
+ url: "/admin/api/job-triggers",
+ headers: auth(),
+ payload: { job_type },
+ });
+ expect(res.statusCode).toBe(400);
+ expect(pool.find("INSERT INTO job_triggers")).toBeUndefined();
+ });
+
+ it("attribue le job à l'utilisateur de la session", async () => {
+ const handlers = jobPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "POST",
+ url: "/admin/api/job-triggers",
+ headers: auth(),
+ payload: { job_type: "enrich", requested_by: "quelquun-dautre" },
+ });
+ expect(pool.find("INSERT INTO job_triggers").values).toEqual(["enrich", "nico"]);
+ });
+});
+
+describe("annulations", () => {
+ it("404 quand le run n'est plus en cours", async () => {
+ const handlers = ([{ match: "UPDATE crawl_run", result: rows() }]);
+ const app = buildApp(handlers);
+ const res = await app.inject({
+ method: "POST",
+ url: "/admin/api/crawl-runs/7/cancel",
+ headers: auth(),
+ payload: {},
+ });
+ expect(res.statusCode).toBe(404);
+ });
+
+ it("cleanup borne older_than_minutes à 1 minimum", async () => {
+ const handlers = ([
+ { match: "UPDATE crawl_run", result: rows() },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "POST",
+ url: "/admin/api/crawl-runs/cleanup",
+ headers: auth(),
+ payload: { older_than_minutes: -999 },
+ });
+ expect(pool.find("UPDATE crawl_run").values).toEqual([1]);
+ });
+});
diff --git a/apps/api/test/adminRoutesCoverage.test.js b/apps/api/test/adminRoutesCoverage.test.js
new file mode 100644
index 0000000..d730c62
--- /dev/null
+++ b/apps/api/test/adminRoutesCoverage.test.js
@@ -0,0 +1,433 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+
+process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
+
+const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js");
+const { rows } = await import("./helpers/fakePool.js");
+const { createAdminApp } = await import("./helpers/testApp.js");
+
+let auth, app, pool;
+
+// Une seule construction d'app pour tout le fichier ; chaque test reprogramme
+// le pool via buildApp(handlers).
+beforeAll(async () => {
+ auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` };
+ ({ app, pool } = await createAdminApp());
+});
+afterAll(async () => { await app.close(); });
+
+/** Reprogramme le pool partagé et renvoie l'app. */
+function buildApp(handlers) {
+ pool.reset(Array.isArray(handlers) ? handlers : handlers ?? []);
+ return app;
+}
+
+/** Pool permissif : n'importe quelle requête renvoie une ligne plausible. */
+function anyPool(extra = []) {
+ return [...extra, { match: /./, result: rows({ n: 1, total: 1, id: 1, status: "done", count: 1 }) }];
+}
+
+// ------------------------------------------------------------------
+// Routes de lecture : forme de la réponse + protection
+// ------------------------------------------------------------------
+describe("routes de lecture", () => {
+ const READ_ROUTES = [
+ "/admin/api/stats",
+ "/admin/api/queue",
+ "/admin/api/crawl-checkpoints",
+ "/admin/api/logs",
+ "/admin/api/geocoding",
+ "/admin/api/llm",
+ "/admin/api/job-triggers",
+ "/admin/api/live",
+ "/admin/api/activity",
+ "/admin/api/bands",
+ ];
+
+ it.each(READ_ROUTES)("%s exige une session", async (url) => {
+ const handlers = ([]);
+ const app = buildApp(handlers);
+ expect((await app.inject({ method: "GET", url })).statusCode).toBe(401);
+ expect(pool.calls).toHaveLength(0);
+ });
+
+ it.each(READ_ROUTES)("%s répond 200 et ok:true avec une session", async (url) => {
+ const app = buildApp(anyPool());
+ const res = await app.inject({ method: "GET", url, headers: auth });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().ok).toBe(true);
+ });
+
+ it.each(READ_ROUTES)("%s renvoie 500 sans divulguer l'erreur si la base tombe", async (url) => {
+ const handlers = ([{ match: /./, throws: new Error("FATAL: password authentication failed for user bm") }]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "GET", url, headers: auth });
+ expect(res.statusCode).toBe(500);
+ expect(res.body).not.toMatch(/password|user bm/);
+ });
+});
+
+describe("GET /admin/api/stats", () => {
+ it("agrège totaux, statuts, pays et genres", async () => {
+ const handlers = ([
+ { match: "count(*)::int AS total,", result: rows({ total: 10, enriched: 4, geocoded: 6 }) },
+ { match: "AS status, count", result: rows({ status: "Active", total: 7 }) },
+ { match: "AS country, count", result: rows({ country: "NO", total: 3 }) },
+ { match: "SELECT genre, count", result: rows({ genre: "Black Metal", total: 2 }) },
+ ]);
+ const app = buildApp(handlers);
+ const body = (await app.inject({ method: "GET", url: "/admin/api/stats", headers: auth })).json();
+
+ expect(body.totals.total).toBe(10);
+ expect(body.by_status[0].status).toBe("Active");
+ expect(body.by_country[0].country).toBe("NO");
+ expect(body.by_genre[0].genre).toBe("Black Metal");
+ });
+
+ it("les quatre agrégats sont lancés en parallèle, pas en cascade", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/stats", headers: auth });
+ expect(pool.calls.length).toBe(4);
+ });
+});
+
+describe("GET /admin/api/bands/:ma_id", () => {
+ const detailPool = () =>
+ ([
+ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Mayhem" }) },
+ { match: "FROM band_locations", result: rows({ id: 9, step_order: 0, location_raw: "Oslo" }) },
+ { match: "FROM llm_cache", result: rows({ id: 3, model: "llama-3.3-70b-versatile" }) },
+ ]);
+
+ it("renvoie le groupe, ses localisations et ses appels LLM", async () => {
+ const app = buildApp(detailPool());
+ const body = (await app.inject({ method: "GET", url: "/admin/api/bands/1", headers: auth })).json();
+ expect(body.item.name).toBe("Mayhem");
+ expect(body.locations).toHaveLength(1);
+ expect(body.llm).toHaveLength(1);
+ });
+
+ it("répond 400 pour un ma_id invalide", async () => {
+ const app = buildApp(detailPool());
+ for (const bad of ["abc", "-1"]) {
+ expect((await app.inject({ method: "GET", url: `/admin/api/bands/${bad}`, headers: auth })).statusCode).toBe(400);
+ }
+ });
+
+ it("répond 404 quand le groupe n'existe pas", async () => {
+ const handlers = ([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
+ const app = buildApp(handlers);
+ expect((await app.inject({ method: "GET", url: "/admin/api/bands/1", headers: auth })).statusCode).toBe(404);
+ });
+
+ // Les tables du nouveau pipeline peuvent ne pas exister sur une base ancienne :
+ // le détail du groupe doit rester consultable malgré tout.
+ it("dégrade proprement si band_locations ou llm_cache sont absentes", async () => {
+ const handlers = ([
+ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Mayhem" }) },
+ { match: "FROM band_locations", throws: new Error('relation "band_locations" does not exist') },
+ { match: "FROM llm_cache", throws: new Error('relation "llm_cache" does not exist') },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "GET", url: "/admin/api/bands/1", headers: auth });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().locations).toEqual([]);
+ expect(res.json().llm).toEqual([]);
+ });
+});
+
+describe("GET /admin/api/logs — filtres", () => {
+ it("filtre par niveau, run et curseur, en paramètres liés", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "GET",
+ url: "/admin/api/logs?level=error&run_id=12&min_id=100&pageSize=25",
+ headers: auth,
+ });
+ const call = pool.find("FROM crawl_log");
+ expect(call.sql).toContain("level = $1");
+ expect(call.sql).toContain("run_id = $2");
+ expect(call.sql).toContain("id > $3");
+ expect(call.values).toEqual(["error", 12, 100, 25, 0]);
+ });
+
+ it("sans filtre, aucune clause WHERE", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/logs", headers: auth });
+ expect(pool.find("FROM crawl_log").sql).not.toContain("WHERE");
+ });
+
+ // Régression : page/pageSize étaient calculés mais jamais renvoyés, le client
+ // ne pouvait donc pas savoir s'il restait des logs à charger.
+ it("renvoie les informations de pagination", async () => {
+ const app = buildApp(anyPool());
+ const body = (await app.inject({ method: "GET", url: "/admin/api/logs?page=3", headers: auth })).json();
+ expect(body.page).toBe(3);
+ expect(body.pageSize).toBe(50);
+ });
+});
+
+describe("GET /admin/api/llm — filtres", () => {
+ it("filtre par modèle, nullité et texte", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "GET",
+ url: "/admin/api/llm?model=llama-3.1-8b-instant&only_null=1&q=oslo",
+ headers: auth,
+ });
+ const call = pool.find("FROM llm_cache lc");
+ expect(call.sql).toContain("model = $1");
+ expect(call.sql).toContain("is_null = true");
+ expect(call.values).toContain("llama-3.1-8b-instant");
+ expect(call.values).toContain("%oslo%");
+ });
+
+ it("le count applique le même filtre que la page", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/llm?model=x", headers: auth });
+ const count = pool.find("count(*)::int AS total FROM llm_cache");
+ expect(count.sql).toContain("model = $1");
+ expect(count.values).toEqual(["x"]);
+ });
+
+ it("tronque une recherche exagérément longue", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: `/admin/api/llm?q=${"a".repeat(500)}`, headers: auth });
+ const bound = pool.find("FROM llm_cache lc").values.find((v) => String(v).startsWith("%aaa"));
+ expect(bound).toHaveLength(102); // 100 caractères + les deux %
+ });
+});
+
+describe("GET /admin/api/activity — liste unifiée", () => {
+ it("réunit crawl_run et admin_audit_log", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/activity", headers: auth });
+ const call = pool.find("UNION ALL");
+ expect(call.sql).toContain("FROM crawl_run");
+ expect(call.sql).toContain("FROM admin_audit_log");
+ });
+
+ it("filtre par type et statut en paramètres liés", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/activity?type=run&status=running", headers: auth });
+ const data = pool.find(/SELECT \* FROM \([\s\S]*UNION ALL/);
+ expect(data.values).toEqual(["run", "running", 50, 0]);
+ });
+
+ it("trie par date décroissante", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/activity", headers: auth });
+ expect(pool.find(/SELECT \* FROM \(/).sql).toContain("ORDER BY ts DESC");
+ });
+});
+
+// ------------------------------------------------------------------
+// Actions de maintenance du géocodage
+// ------------------------------------------------------------------
+describe("actions sur band_locations", () => {
+ const ACTIONS = [
+ ["/admin/api/locations/reset-errors", "geocode_status = 'error'"],
+ ["/admin/api/locations/reset-llm", "geocode_status IN ('llm_needed', 'manual')"],
+ ["/admin/api/locations/reset-all", "is_country_only = FALSE"],
+ ["/admin/api/locations/requeue-all", "geocode_status = ANY($1::text[])"],
+ ];
+
+ it.each(ACTIONS.map(([url]) => url))("%s exige une session", async (url) => {
+ const handlers = ([]);
+ const app = buildApp(handlers);
+ expect((await app.inject({ method: "POST", url, payload: {} })).statusCode).toBe(401);
+ expect(pool.calls).toHaveLength(0);
+ });
+
+ it.each(ACTIONS)("%s cible les bonnes lignes", async (url, expectedClause) => {
+ const handlers = ([
+ { match: "UPDATE band_locations", result: { rows: [], rowCount: 12 } },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "POST", url, headers: auth, payload: {} });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.json().count).toBe(12);
+ expect(pool.find("UPDATE band_locations").sql).toContain(expectedClause);
+ });
+
+ it.each(ACTIONS.map(([url]) => url))("%s remet les lignes en queue", async (url) => {
+ const handlers = ([
+ { match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ await app.inject({ method: "POST", url, headers: auth, payload: {} });
+ expect(pool.find("UPDATE band_locations").sql).toContain("geocode_status='queued'");
+ });
+
+ it.each(ACTIONS.map(([url]) => url))("%s trace l'action dans les logs avec l'auteur", async (url) => {
+ const handlers = ([
+ { match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ await app.inject({ method: "POST", url, headers: auth, payload: {} });
+ expect(pool.find("INSERT INTO crawl_log").values[1]).toContain("admin:nico");
+ });
+
+ // reset-all efface les coordonnées (le worker va tout re-géocoder) alors que
+ // reset-errors ne remet en file que ce qui avait échoué.
+ it("reset-all efface les coordonnées, reset-errors non", async () => {
+ const mk = async (url) => {
+ const handlers = ([
+ { match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ await app.inject({ method: "POST", url, headers: auth, payload: {} });
+ return pool.find("UPDATE band_locations").sql;
+ };
+ expect(await mk("/admin/api/locations/reset-all")).toContain("lat=NULL");
+ expect(await mk("/admin/api/locations/reset-errors")).not.toContain("lat=NULL");
+ });
+
+ it("requeue-all inclut 'done' seulement sur demande explicite", async () => {
+ const mk = async (payload) => {
+ const handlers = ([
+ { match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ await app.inject({ method: "POST", url: "/admin/api/locations/requeue-all", headers: auth, payload });
+ return pool.find("UPDATE band_locations").values[0];
+ };
+ expect(await mk({})).toEqual(["error", "llm_needed"]);
+ expect(await mk({ include_done: true })).toEqual(["error", "llm_needed", "manual", "done"]);
+ });
+
+ it("requeue-all épargne toujours les localisations pays-seul", async () => {
+ const handlers = ([
+ { match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "POST", url: "/admin/api/locations/requeue-all",
+ headers: auth, payload: { include_done: true },
+ });
+ expect(pool.find("UPDATE band_locations").sql).toContain("is_country_only = FALSE");
+ });
+
+ it("une action qui échoue renvoie 500 sans écrire de log de succès", async () => {
+ const handlers = ([{ match: "UPDATE band_locations", throws: new Error("deadlock") }]);
+ const app = buildApp(handlers);
+ const res = await app.inject({
+ method: "POST", url: "/admin/api/locations/reset-errors", headers: auth, payload: {},
+ });
+ expect(res.statusCode).toBe(500);
+ expect(pool.find("INSERT INTO crawl_log")).toBeUndefined();
+ });
+});
+
+describe("POST /admin/api/geocode-cache/purge-nominatim", () => {
+ it("ne supprime que les entrées de l'ancien pipeline", async () => {
+ const handlers = ([
+ { match: "DELETE FROM geocode_cache", result: { rows: [], rowCount: 4 } },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({
+ method: "POST", url: "/admin/api/geocode-cache/purge-nominatim", headers: auth, payload: {},
+ });
+ expect(res.json().count).toBe(4);
+ // Le marqueur place_rank distingue Nominatim de Geoapify : sans ce WHERE,
+ // la purge viderait aussi le cache Geoapify (appels payants).
+ expect(pool.find("DELETE FROM geocode_cache").sql).toContain("place_rank");
+ });
+
+ it("exige une session", async () => {
+ const handlers = ([]);
+ const app = buildApp(handlers);
+ expect((await app.inject({
+ method: "POST", url: "/admin/api/geocode-cache/purge-nominatim", payload: {},
+ })).statusCode).toBe(401);
+ expect(pool.calls).toHaveLength(0);
+ });
+});
+
+describe("POST /admin/api/crawl-runs/cleanup", () => {
+ const cleanupPool = () =>
+ ([
+ { match: "UPDATE crawl_run", result: rows({ id: 1, run_type: "enrich", started_at: "2026-01-01" }) },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ]);
+
+ it("utilise 30 minutes par défaut", async () => {
+ const handlers = cleanupPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth, payload: {} });
+ expect(pool.find("UPDATE crawl_run").values).toEqual([30]);
+ });
+
+ it("ne touche que les runs marqués running", async () => {
+ const handlers = cleanupPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth, payload: {} });
+ expect(pool.find("UPDATE crawl_run").sql).toContain("status = 'running'");
+ });
+
+ it("renvoie le nombre de runs nettoyés", async () => {
+ const app = buildApp(cleanupPool());
+ const res = await app.inject({
+ method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth, payload: { older_than_minutes: 60 },
+ });
+ expect(res.json().cleaned).toBe(1);
+ });
+});
+
+describe("GET /admin/api/geocoding", () => {
+ it("dégrade proprement quand les tables du nouveau pipeline manquent", async () => {
+ const handlers = ([
+ { match: "FROM geocode_cache", result: rows({ n: 5 }) },
+ { match: "FROM bands b", result: rows() },
+ { match: "FROM band_locations", throws: new Error("relation does not exist") },
+ { match: "FROM llm_cache", throws: new Error("relation does not exist") },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "GET", url: "/admin/api/geocoding", headers: auth });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().cache_size).toBe(5);
+ expect(res.json().locations).toEqual([]);
+ });
+});
+
+describe("GET /admin/api/live", () => {
+ it("remonte runs actifs, jobs en attente et file de géocodage", async () => {
+ const handlers = ([
+ { match: "WHERE status = 'running'", result: rows({ id: 1, run_type: "enrich" }) },
+ { match: "WHERE status = 'pending'", result: rows({ id: 2, job_type: "enrich" }) },
+ { match: "GROUP BY geocode_status", result: rows({ status: "queued", n: 42 }) },
+ { match: "geocode_status = 'processing'", result: rows() },
+ { match: "FROM crawl_checkpoint", result: rows({ key: "last_full_crawl_at" }) },
+ ]);
+ const app = buildApp(handlers);
+ const body = (await app.inject({ method: "GET", url: "/admin/api/live", headers: auth })).json();
+ expect(body.active_runs).toHaveLength(1);
+ expect(body.pending_jobs).toHaveLength(1);
+ expect(body.geo_queue[0].n).toBe(42);
+ expect(body.checkpoints).toHaveLength(1);
+ });
+
+ it("limite l'aperçu des localisations en cours de traitement", async () => {
+ const handlers = anyPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "GET", url: "/admin/api/live", headers: auth });
+ expect(pool.find("geocode_status = 'processing'").sql).toContain("LIMIT 3");
+ });
+});
diff --git a/apps/api/test/cancellation.test.js b/apps/api/test/cancellation.test.js
new file mode 100644
index 0000000..eee4b9b
--- /dev/null
+++ b/apps/api/test/cancellation.test.js
@@ -0,0 +1,252 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+
+process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
+
+const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js");
+const { rows } = await import("./helpers/fakePool.js");
+const { createAdminApp } = await import("./helpers/testApp.js");
+
+let auth, app, pool;
+beforeAll(async () => {
+ auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` };
+ ({ app, pool } = await createAdminApp());
+});
+afterAll(async () => { await app.close(); });
+
+/** Reprogramme le pool partagé et renvoie l'app (construite une seule fois). */
+function buildApp(handlers) {
+ pool.reset(handlers ?? []);
+ return app;
+}
+
+/**
+ * L'annulation d'un run est COOPÉRATIVE : l'API pose un drapeau, le crawler
+ * l'observe et s'arrête. Ces tests verrouillent le contrat côté API — le
+ * pendant côté crawler est dans apps/crawler/tests/test_cancellation.py.
+ */
+describe("POST /admin/api/crawl-runs/:id/cancel", () => {
+ const runningPool = () =>
+ ([
+ { match: "SET cancel_requested = TRUE", result: rows({ id: 7, run_type: "full_europe" }) },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ]);
+
+ it("pose le drapeau sans toucher au statut", async () => {
+ const handlers = runningPool();
+ const app = buildApp(handlers);
+ const res = await app.inject({
+ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {},
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.json().cancel_requested).toBe(true);
+
+ const upd = pool.find("UPDATE crawl_run");
+ expect(upd.sql).toContain("cancel_requested = TRUE");
+ // Le point central de la correction : l'API ne décide plus du statut.
+ // C'est le crawler qui écrira 'cancelled' quand il se sera vraiment arrêté.
+ // On n'inspecte que la clause SET — `status` a le droit d'apparaître dans
+ // le WHERE (on ne cible que les runs en cours).
+ const setClause = upd.sql.split(/\bWHERE\b/i)[0];
+ expect(setClause).not.toMatch(/status\s*=/);
+ expect(setClause).not.toContain("finished_at");
+ });
+
+ it("ne cible que les runs réellement en cours", async () => {
+ const handlers = runningPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {} });
+ expect(pool.find("UPDATE crawl_run").sql).toContain("status = 'running'");
+ });
+
+ it("enregistre qui a demandé l'annulation", async () => {
+ const handlers = runningPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {} });
+ expect(pool.find("UPDATE crawl_run").values).toEqual([7, "nico"]);
+ expect(pool.find("INSERT INTO admin_audit_log")).toBeDefined();
+ });
+
+ it("est idempotent : une seconde demande répond 200, pas une erreur", async () => {
+ const handlers = ([
+ { match: "SET cancel_requested = TRUE", result: rows() }, // 0 ligne : déjà demandé
+ { match: "SELECT status, cancel_requested", result: rows({ status: "running", cancel_requested: true }) },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({
+ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {},
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().already_requested).toBe(true);
+ });
+
+ it("répond 404 pour un run terminé", async () => {
+ const handlers = ([
+ { match: "SET cancel_requested = TRUE", result: rows() },
+ { match: "SELECT status, cancel_requested", result: rows({ status: "done", cancel_requested: false }) },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({
+ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {},
+ });
+ expect(res.statusCode).toBe(404);
+ });
+
+ it("exige une session admin", async () => {
+ const handlers = ([]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", payload: {} });
+ expect(res.statusCode).toBe(401);
+ expect(pool.calls).toHaveLength(0);
+ });
+});
+
+describe("POST /admin/api/job-triggers/:id/cancel", () => {
+ it("retire un job encore en file", async () => {
+ const handlers = ([
+ { match: "UPDATE job_triggers", result: rows({ id: 3, job_type: "enrich" }) },
+ { match: "INSERT INTO crawl_log", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers/3/cancel", headers: auth, payload: {} });
+ expect(res.statusCode).toBe(200);
+ expect(pool.find("UPDATE job_triggers").sql).toContain("status='pending'");
+ });
+
+ // Un job déjà réclamé n'existe plus qu'à travers son crawl_run : le message
+ // doit y renvoyer explicitement au lieu d'un « non trouvé » trompeur.
+ it("répond 409 avec un message actionnable si le job a déjà démarré", async () => {
+ const handlers = ([
+ { match: "UPDATE job_triggers", result: rows() },
+ { match: "SELECT status FROM job_triggers", result: rows({ status: "running" }) },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers/3/cancel", headers: auth, payload: {} });
+ expect(res.statusCode).toBe(409);
+ expect(res.json().error).toMatch(/déjà démarré/);
+ expect(res.json().error).toMatch(/run/i);
+ });
+
+ it("répond 409 « non trouvé » pour un job inexistant", async () => {
+ const handlers = ([
+ { match: "UPDATE job_triggers", result: rows() },
+ { match: "SELECT status FROM job_triggers", result: rows() },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers/99/cancel", headers: auth, payload: {} });
+ expect(res.json().error).toMatch(/non trouvé/);
+ });
+});
+
+/**
+ * L'édition admin de lat/lon doit atteindre band_locations : c'est cette table
+ * que la carte clusterise. Sans ça, corriger un point n'avait aucun effet visible.
+ */
+describe("PATCH /admin/api/bands/:ma_id — cohérence géographique", () => {
+ const patchPool = (bandAfter) =>
+ ([
+ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Avant" }) },
+ { match: "UPDATE bands SET", result: rows(bandAfter) },
+ { match: "INSERT INTO band_locations", result: rows() },
+ { match: "DELETE FROM band_locations", result: rows() },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ]);
+
+ it("pose un override dans band_locations quand lat/lon sont fournis", async () => {
+ const handlers = patchPool({ ma_id: 1, lat: 59.9, lon: 10.7, location_text: "Oslo" });
+ const app = buildApp(handlers);
+ await app.inject({
+ method: "PATCH", url: "/admin/api/bands/1", headers: auth,
+ payload: { lat: 59.9, lon: 10.7 },
+ });
+
+ const ins = pool.find("INSERT INTO band_locations");
+ expect(ins).toBeDefined();
+ expect(ins.values).toEqual([1, "Oslo", 59.9, 10.7]);
+ // step_order = -1 → l'override passe avant tous les steps parsés et devient
+ // le point principal repris par _sync_band_point côté geocoder.
+ expect(ins.sql).toContain("-1");
+ expect(ins.sql).toContain("'done'");
+ expect(ins.sql).toContain("'admin'");
+ });
+
+ it("l'override est un upsert, pas un doublon", async () => {
+ const handlers = patchPool({ ma_id: 1, lat: 1, lon: 2, location_text: "X" });
+ const app = buildApp(handlers);
+ await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { lat: 1, lon: 2 } });
+ expect(pool.find("INSERT INTO band_locations").sql).toContain("ON CONFLICT (ma_id, step_order, location_raw) DO UPDATE");
+ });
+
+ it("efface l'override quand lat/lon sont vidés", async () => {
+ const handlers = patchPool({ ma_id: 1, lat: null, lon: null, location_text: "Oslo" });
+ const app = buildApp(handlers);
+ await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { lat: null, lon: null } });
+
+ const del = pool.find("DELETE FROM band_locations");
+ expect(del).toBeDefined();
+ expect(del.sql).toContain("step_order = -1");
+ expect(pool.find("INSERT INTO band_locations")).toBeUndefined();
+ });
+
+ it("ne touche pas band_locations quand l'édition ne concerne pas les coordonnées", async () => {
+ const handlers = patchPool({ ma_id: 1, name: "Après" });
+ const app = buildApp(handlers);
+ await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Après" } });
+ expect(pool.find("INSERT INTO band_locations")).toBeUndefined();
+ expect(pool.find("DELETE FROM band_locations")).toBeUndefined();
+ });
+});
+
+describe("PATCH /admin/api/bands/:ma_id — transaction", () => {
+ const okPool = () =>
+ ([
+ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Avant" }) },
+ { match: "UPDATE bands SET", result: rows({ ma_id: 1, name: "Après" }) },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ]);
+
+ it("encadre l'écriture et l'audit dans une même transaction", async () => {
+ const handlers = okPool();
+ const app = buildApp(handlers);
+ await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Après" } });
+
+ expect(pool.sequence(["BEGIN", "UPDATE bands SET", "INSERT INTO admin_audit_log", "COMMIT"]))
+ .toEqual(["BEGIN", "UPDATE bands SET", "INSERT INTO admin_audit_log", "COMMIT"]);
+ expect(pool.rolledBack).toBe(false);
+ });
+
+ it("annule tout si l'écriture d'audit échoue", async () => {
+ const handlers = ([
+ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1 }) },
+ { match: "UPDATE bands SET", result: rows({ ma_id: 1 }) },
+ { match: "INSERT INTO admin_audit_log", throws: new Error("disque plein") },
+ ]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
+
+ expect(res.statusCode).toBe(500);
+ expect(pool.rolledBack).toBe(true);
+ expect(pool.committed).toBe(false);
+ });
+
+ it("libère la connexion même en cas d'erreur", async () => {
+ const handlers = ([
+ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1 }) },
+ { match: "UPDATE bands SET", throws: new Error("boom") },
+ ]);
+ const app = buildApp(handlers);
+ await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
+ expect(pool.released).toBe(pool.connections);
+ expect(pool.released).toBeGreaterThan(0);
+ });
+
+ it("annule la transaction quand le groupe est introuvable", async () => {
+ const handlers = ([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
+ const app = buildApp(handlers);
+ const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
+ expect(res.statusCode).toBe(404);
+ expect(pool.rolledBack).toBe(true);
+ expect(pool.released).toBe(1);
+ });
+});
diff --git a/apps/api/test/harness.test.js b/apps/api/test/harness.test.js
new file mode 100644
index 0000000..46aeb2c
--- /dev/null
+++ b/apps/api/test/harness.test.js
@@ -0,0 +1,145 @@
+import { describe, it, expect } from "vitest";
+import { makeFakePool, rows } from "./helpers/fakePool.js";
+
+/**
+ * Méta-tests du harnais.
+ *
+ * Presque toutes les assertions du projet s'appuient sur `makeFakePool` :
+ * si ce double se met à tout avaler en silence (un `find` qui renvoie toujours
+ * undefined, un `throws` ignoré, un compteur qui ne s'incrémente pas), des
+ * dizaines de tests passeraient au vert sans plus rien vérifier.
+ *
+ * Ces tests vérifient donc l'outil de mesure lui-même.
+ */
+describe("makeFakePool — enregistrement", () => {
+ it("enregistre chaque requête avec son SQL et ses paramètres", async () => {
+ const pool = makeFakePool();
+ await pool.query("SELECT 1 FROM bands WHERE ma_id = $1", [42]);
+ expect(pool.calls).toHaveLength(1);
+ expect(pool.calls[0].sql).toContain("FROM bands");
+ expect(pool.calls[0].values).toEqual([42]);
+ });
+
+ it("normalise l'absence de paramètres en tableau vide", async () => {
+ const pool = makeFakePool();
+ await pool.query("SELECT 1");
+ expect(pool.calls[0].values).toEqual([]);
+ });
+
+ it("accepte la forme objet { text }", async () => {
+ const pool = makeFakePool();
+ await pool.query({ text: "SELECT 1 FROM bands" }, []);
+ expect(pool.calls[0].sql).toBe("SELECT 1 FROM bands");
+ });
+});
+
+describe("makeFakePool — recherche", () => {
+ it("find() trouve par sous-chaîne ET renvoie undefined quand rien ne matche", async () => {
+ const pool = makeFakePool();
+ await pool.query("UPDATE bands SET name = $1", ["x"]);
+
+ expect(pool.find("UPDATE bands")).toBeDefined();
+ // Le cas qui compte : un find() qui renverrait toujours undefined ferait
+ // passer au vert tous les `expect(pool.find(...)).toBeUndefined()`.
+ expect(pool.find("DELETE FROM bands")).toBeUndefined();
+ });
+
+ it("find() accepte une RegExp", async () => {
+ const pool = makeFakePool();
+ await pool.query("SELECT a, b FROM bands");
+ expect(pool.find(/SELECT\s+a,\s*b/)).toBeDefined();
+ expect(pool.find(/SELECT\s+z/)).toBeUndefined();
+ });
+
+ it("findAll() renvoie toutes les occurrences, pas seulement la première", async () => {
+ const pool = makeFakePool();
+ await pool.query("INSERT INTO bands VALUES (1)");
+ await pool.query("INSERT INTO bands VALUES (2)");
+ await pool.query("SELECT 1");
+ expect(pool.findAll("INSERT INTO bands")).toHaveLength(2);
+ expect(pool.findAll("DELETE")).toHaveLength(0);
+ });
+
+ it("sequence() reflète l'ordre réel d'exécution", async () => {
+ const pool = makeFakePool();
+ await pool.query("BEGIN");
+ await pool.query("UPDATE bands SET x = 1");
+ await pool.query("COMMIT");
+ expect(pool.sequence(["COMMIT", "BEGIN", "UPDATE bands"]))
+ .toEqual(["BEGIN", "UPDATE bands", "COMMIT"]);
+ });
+});
+
+describe("makeFakePool — handlers", () => {
+ it("renvoie le résultat du premier handler qui matche", async () => {
+ const pool = makeFakePool([
+ { match: "FROM bands", result: rows({ ma_id: 1 }) },
+ { match: "FROM bands", result: rows({ ma_id: 2 }) },
+ ]);
+ expect((await pool.query("SELECT * FROM bands")).rows[0].ma_id).toBe(1);
+ });
+
+ it("renvoie un résultat vide quand aucun handler ne matche", async () => {
+ const pool = makeFakePool([{ match: "FROM autre", result: rows({ x: 1 }) }]);
+ expect(await pool.query("SELECT * FROM bands")).toEqual({ rows: [], rowCount: 0 });
+ });
+
+ // Si `throws` était ignoré, tous les tests de chemin d'erreur (rollback,
+ // 500 sans fuite d'information) passeraient sans rien exercer.
+ it("throws fait bien lever la requête", async () => {
+ const pool = makeFakePool([{ match: "FROM bands", throws: new Error("boum") }]);
+ await expect(pool.query("SELECT * FROM bands")).rejects.toThrow("boum");
+ });
+
+ it("result peut être une fonction du SQL et des paramètres", async () => {
+ const pool = makeFakePool([
+ { match: "FROM bands", result: (_sql, values) => rows({ echo: values[0] }) },
+ ]);
+ expect((await pool.query("SELECT * FROM bands WHERE id=$1", [7])).rows[0].echo).toBe(7);
+ });
+
+ it("rows() produit un rowCount cohérent", () => {
+ expect(rows()).toEqual({ rows: [], rowCount: 0 });
+ expect(rows({ a: 1 }, { a: 2 }).rowCount).toBe(2);
+ });
+});
+
+describe("makeFakePool — transactions", () => {
+ it("connect() renvoie un client qui partage le journal des requêtes", async () => {
+ const pool = makeFakePool();
+ const client = await pool.connect();
+ await client.query("SELECT 1 FROM bands");
+ expect(pool.find("FROM bands")).toBeDefined();
+ });
+
+ it("détecte COMMIT et ROLLBACK séparément", async () => {
+ const committed = makeFakePool();
+ const c1 = await committed.connect();
+ await c1.query("COMMIT");
+ expect(committed.committed).toBe(true);
+ expect(committed.rolledBack).toBe(false);
+
+ const rolled = makeFakePool();
+ const c2 = await rolled.connect();
+ await c2.query("ROLLBACK");
+ expect(rolled.rolledBack).toBe(true);
+ expect(rolled.committed).toBe(false);
+ });
+
+ it("compte les connexions ouvertes et libérées", async () => {
+ const pool = makeFakePool();
+ const client = await pool.connect();
+ expect(pool.connections).toBe(1);
+ expect(pool.released).toBe(0);
+ client.release();
+ expect(pool.released).toBe(1);
+ });
+
+ it("un pool neuf ne rapporte ni commit ni rollback", () => {
+ const pool = makeFakePool();
+ expect(pool.committed).toBe(false);
+ expect(pool.rolledBack).toBe(false);
+ expect(pool.connections).toBe(0);
+ expect(pool.released).toBe(0);
+ });
+});
diff --git a/apps/api/test/helpers/fakePool.js b/apps/api/test/helpers/fakePool.js
new file mode 100644
index 0000000..f3f65ae
--- /dev/null
+++ b/apps/api/test/helpers/fakePool.js
@@ -0,0 +1,110 @@
+/**
+ * Faux `pg.Pool` pour les tests de routes.
+ *
+ * Objectif : tester le routage, l'authentification, la validation d'entrée et
+ * la *forme* du SQL généré (allowlists de colonnes, casts, transactions) — sans
+ * démarrer de conteneur Postgres. Un test qui monte un conteneur met des
+ * dizaines de secondes ; celui-ci tourne en millisecondes.
+ *
+ * `handlers` associe un fragment de SQL (sous-chaîne ou RegExp) à un résultat.
+ * Toutes les requêtes exécutées sont enregistrées dans `pool.calls`.
+ *
+ * Le faux client rend les transactions observables : `pool.committed` et
+ * `pool.rolledBack` permettent d'affirmer qu'une route a bien annulé son travail
+ * sur erreur, et `pool.released` qu'elle n'a pas fui de connexion.
+ */
+/**
+ * @typedef {object} Handler
+ * @property {string|RegExp} match fragment de SQL à reconnaître
+ * @property {any} [result] résultat pg, ou fonction (sql, values) => résultat
+ * @property {Error|string} [throws] fait échouer la requête (chemins d'erreur)
+ *
+ * @param {Handler[]} [handlers]
+ */
+export function makeFakePool(handlers = []) {
+ const calls = [];
+ const state = { committed: false, rolledBack: false, released: 0, connections: 0 };
+ let active = handlers;
+
+ async function query(text, values) {
+ const sql = typeof text === "string" ? text : text.text;
+ calls.push({ sql, values: values ?? [] });
+
+ if (sql === "COMMIT") state.committed = true;
+ if (sql === "ROLLBACK") state.rolledBack = true;
+
+ for (const { match, result, throws } of active) {
+ const hit = match instanceof RegExp ? match.test(sql) : sql.includes(match);
+ if (hit) {
+ if (throws) throw throws instanceof Error ? throws : new Error(String(throws));
+ return typeof result === "function" ? result(sql, values) : result;
+ }
+ }
+ return { rows: [], rowCount: 0 };
+ }
+
+ const pool = {
+ calls,
+ query,
+
+ /**
+ * Reconfigure le pool sans en recréer un.
+ *
+ * Permet de construire l'app Fastify UNE fois par fichier de test (14 ms
+ * par construction, multipliées par ~12 000 exécutions pendant un run de
+ * mutation) au lieu d'une fois par cas de test.
+ */
+ reset(newHandlers = []) {
+ active = newHandlers;
+ calls.length = 0;
+ state.committed = false;
+ state.rolledBack = false;
+ state.released = 0;
+ state.connections = 0;
+ return pool;
+ },
+
+ async connect() {
+ state.connections++;
+ return {
+ query,
+ release() {
+ state.released++;
+ },
+ };
+ },
+
+ get committed() { return state.committed; },
+ get rolledBack() { return state.rolledBack; },
+ get released() { return state.released; },
+ get connections() { return state.connections; },
+
+ /** Retrouve la première requête exécutée contenant `fragment`. */
+ find(fragment) {
+ return calls.find((c) =>
+ fragment instanceof RegExp ? fragment.test(c.sql) : c.sql.includes(fragment)
+ );
+ },
+
+ /** Toutes les requêtes contenant `fragment`. */
+ findAll(fragment) {
+ return calls.filter((c) =>
+ fragment instanceof RegExp ? fragment.test(c.sql) : c.sql.includes(fragment)
+ );
+ },
+
+ /** Ordre d'exécution des requêtes correspondant à l'un des fragments. */
+ sequence(fragments) {
+ return calls
+ .map((c) => fragments.find((f) => (f instanceof RegExp ? f.test(c.sql) : c.sql.includes(f))))
+ .filter(Boolean);
+ },
+ };
+
+ return pool;
+}
+
+/** Raccourci : un résultat pg minimal. */
+export function rows(...items) {
+ return { rows: items, rowCount: items.length };
+}
diff --git a/apps/api/test/helpers/testApp.js b/apps/api/test/helpers/testApp.js
new file mode 100644
index 0000000..2083840
--- /dev/null
+++ b/apps/api/test/helpers/testApp.js
@@ -0,0 +1,65 @@
+import Fastify from "fastify";
+import cookie from "@fastify/cookie";
+import { makeFakePool } from "./fakePool.js";
+
+/**
+ * Applications de test partagées.
+ *
+ * Construire une app Fastify coûte ~14 ms (helmet + cookie + rate-limit +
+ * ready()). C'est négligeable une fois, mais les tests de mutation rejouent la
+ * suite des milliers de fois : à raison d'une construction par cas de test, ces
+ * 14 ms devenaient l'essentiel des 5 minutes du run.
+ *
+ * On construit donc l'app UNE fois par fichier, autour d'un pool reconfigurable
+ * (`pool.reset(handlers)`), et chaque test ne fait que reprogrammer le pool.
+ *
+ * Une app par fichier de test (et non une globale partagée) : les fichiers
+ * s'exécutent dans des workers séparés, et l'isolation reste totale puisque le
+ * seul état mutable est le pool, remis à zéro avant chaque cas.
+ */
+
+/** App n'exposant que les routes admin (plugin adminRoutes seul). */
+export async function createAdminApp() {
+ const { default: adminRoutes } = await import("../../src/adminRoutes.js");
+ const pool = makeFakePool();
+ const app = Fastify({ logger: false });
+ await app.register(cookie);
+ await app.register(adminRoutes, { pool });
+ await app.ready();
+ return { app, pool };
+}
+
+/** App complète (routes publiques + admin), via la fabrique de production. */
+export async function createFullApp(opts = {}) {
+ const { buildServer } = await import("../../src/app.js");
+ const pool = makeFakePool();
+ const app = await buildServer({
+ pool,
+ logger: false,
+ corsOrigins: ["https://metalfrom.eu"],
+ seedAdmin: false,
+ // L'app est partagée entre les cas du fichier : les plafonds de débit
+ // réels rendraient le 11e test rouge sans rapport avec ce qu'il vérifie.
+ // Les vraies valeurs sont testées dans rateLimit.test.js.
+ globalRateLimitMax: 1e9,
+ adminRateLimitMax: 1e9,
+ authRateLimitMax: 1e9,
+ ...opts,
+ });
+ return { app, pool };
+}
+
+/**
+ * Quelques cas doivent construire leur propre app (options de fabrique
+ * différentes : jeton d'import absent, pool null…). Ils restent rares et
+ * assument le coût de construction.
+ */
+export async function createFullAppWith(opts) {
+ const { buildServer } = await import("../../src/app.js");
+ return buildServer({
+ logger: false,
+ corsOrigins: ["https://metalfrom.eu"],
+ seedAdmin: false,
+ ...opts,
+ });
+}
diff --git a/apps/api/test/publicRoutes.test.js b/apps/api/test/publicRoutes.test.js
new file mode 100644
index 0000000..2f85deb
--- /dev/null
+++ b/apps/api/test/publicRoutes.test.js
@@ -0,0 +1,430 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+
+process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
+
+const { rows } = await import("./helpers/fakePool.js");
+const { createFullApp, createFullAppWith } = await import("./helpers/testApp.js");
+
+const TOKEN = "c4b99106fcd09badce25810e974ec4d6";
+
+// Deux apps pour tout le fichier (cf. helpers/testApp.js : construire une app
+// coûte ~14 ms, multipliées par des milliers d'exécutions en tests de mutation).
+// Chaque cas se contente de reprogrammer le pool associé.
+let app, pool, tokenApp, tokenPool;
+
+beforeAll(async () => {
+ ({ app, pool } = await createFullApp());
+ ({ app: tokenApp, pool: tokenPool } = await createFullApp({ importToken: TOKEN }));
+});
+afterAll(async () => {
+ await app.close();
+ await tokenApp.close();
+});
+
+/** Handlers par défaut : chaque requête renvoie quelque chose de plausible. */
+function defaultHandlers() {
+ return [
+ { match: "FROM band_locations bl", result: rows({ ma_id: 1, name: "Mayhem", lat: 59.9, lon: 10.7 }) },
+ { match: "grid_cells", result: rows({ lat: 59.9, lon: 10.7, count: 3, sample_bands: [] }) },
+ { match: "FROM bands", result: rows({ ma_id: 1, name: "Mayhem", total: 1 }) },
+ ];
+}
+
+/**
+ * Reprogramme le pool partagé et renvoie l'app publique.
+ * @param {import("./helpers/fakePool.js").Handler[]} [handlers]
+ */
+function build(handlers = defaultHandlers()) {
+ pool.reset(handlers);
+ return app;
+}
+
+/** Idem pour l'app configurée avec un jeton d'import. */
+function buildToken(handlers = []) {
+ tokenPool.reset(handlers);
+ return tokenApp;
+}
+
+describe("/api/health et /", () => {
+ it("health répond 200 sans toucher la base", async () => {
+ const res = await build([]).inject({ method: "GET", url: "/api/health" });
+ expect(res.statusCode).toBe(200);
+ expect(res.json()).toEqual({ ok: true });
+ expect(pool.calls).toHaveLength(0);
+ });
+
+ it("la racine identifie le service", async () => {
+ expect((await build([]).inject({ method: "GET", url: "/" })).json().ok).toBe(true);
+ });
+});
+
+describe("CORS", () => {
+ it("renvoie l'en-tête pour une origine autorisée", async () => {
+ const res = await build().inject({
+ method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu" },
+ });
+ expect(res.headers["access-control-allow-origin"]).toBe("https://metalfrom.eu");
+ });
+
+ it("n'écho PAS une origine non autorisée", async () => {
+ const res = await build().inject({
+ method: "GET", url: "/api/health", headers: { origin: "https://evil.example" },
+ });
+ expect(res.headers["access-control-allow-origin"]).toBeUndefined();
+ });
+
+ it("ne fait pas de match par préfixe sur l'origine", async () => {
+ const res = await build().inject({
+ method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu.evil.example" },
+ });
+ expect(res.headers["access-control-allow-origin"]).toBeUndefined();
+ });
+
+ // Régression : sans `return reply`, Fastify poursuivait le cycle de vie et le
+ // préflight repartait dans le handler de route (ou un 404).
+ it("le préflight OPTIONS répond 204 et court-circuite le routage", async () => {
+ const res = await build().inject({ method: "OPTIONS", url: "/api/bands" });
+ expect(res.statusCode).toBe(204);
+ expect(res.body).toBe("");
+ });
+
+ it("OPTIONS répond 204 même sur une route inexistante", async () => {
+ expect((await build().inject({ method: "OPTIONS", url: "/nimporte/quoi" })).statusCode).toBe(204);
+ });
+});
+
+describe("en-têtes de sécurité (helmet)", () => {
+ it.each([
+ "x-content-type-options",
+ "x-frame-options",
+ "strict-transport-security",
+ ])("pose %s", async (header) => {
+ const res = await build().inject({ method: "GET", url: "/api/health" });
+ expect(res.headers[header]).toBeDefined();
+ });
+
+ it("ne divulgue pas la stack ni les identifiants en cas d'erreur serveur", async () => {
+ const res = await build([
+ { match: "FROM bands", throws: new Error("connexion perdue: host=10.0.1.7 user=bm") },
+ ]).inject({ method: "GET", url: "/api/stats" });
+ expect(res.statusCode).toBe(500);
+ expect(res.body).not.toMatch(/10\.0\.1\.7|user=bm|at Object/);
+ });
+});
+
+describe("/api/bands — validation", () => {
+ it("répond 200 sans paramètre", async () => {
+ const res = await build().inject({ method: "GET", url: "/api/bands" });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().limit).toBe(1000);
+ });
+
+ // Régression : Number("abc") = NaN partait dans LIMIT $n → erreur pg → 500.
+ it.each([
+ ["limit=abc", "/api/bands?limit=abc"],
+ ["limit=0", "/api/bands?limit=0"],
+ ["limit=-5", "/api/bands?limit=-5"],
+ ["offset=abc", "/api/bands?offset=abc"],
+ ["offset=-1", "/api/bands?offset=-1"],
+ ["offset>1M", "/api/bands?offset=1000001"],
+ ])("répond 400 (pas 500) pour %s", async (_label, url) => {
+ const res = await build().inject({ method: "GET", url });
+ expect(res.statusCode).toBe(400);
+ expect(res.json().ok).toBe(false);
+ });
+
+ it("plafonne le limit sans erreur", async () => {
+ expect((await build().inject({ method: "GET", url: "/api/bands?limit=999999" })).json().limit).toBe(150000);
+ });
+
+ it("rejette une recherche d'un seul caractère", async () => {
+ expect((await build().inject({ method: "GET", url: "/api/bands?q=a" })).statusCode).toBe(400);
+ });
+
+ it("rejette les patterns ILIKE pathologiques", async () => {
+ expect((await build().inject({ method: "GET", url: "/api/bands?q=%25%25%25" })).statusCode).toBe(400);
+ });
+
+ it("rejette plus de 100 pays", async () => {
+ const many = Array.from({ length: 101 }, (_, i) => `C${i}`).join(",");
+ const res = await build().inject({ method: "GET", url: `/api/bands?countries=${many}` });
+ expect(res.statusCode).toBe(400);
+ expect(res.json().error).toMatch(/Max 100/);
+ });
+
+ it("rejette plus de 50 statuts", async () => {
+ const many = Array.from({ length: 51 }, (_, i) => `s${i}`).join(",");
+ expect((await build().inject({ method: "GET", url: `/api/bands?status=${many}` })).statusCode).toBe(400);
+ });
+
+ it("passe la recherche en paramètre lié", async () => {
+ await build().inject({
+ method: "GET", url: "/api/bands?q=" + encodeURIComponent("'; DROP TABLE bands--"),
+ });
+ const call = pool.find("FROM bands");
+ expect(call.sql).not.toContain("DROP TABLE");
+ expect(call.values.some((v) => String(v).includes("DROP TABLE"))).toBe(true);
+ });
+
+ it("normalise les codes pays en majuscules", async () => {
+ await build().inject({ method: "GET", url: "/api/bands?countries=fr,de" });
+ expect(pool.find("FROM bands").values).toContainEqual(["FR", "DE"]);
+ });
+});
+
+describe("/api/clusters — validation géographique", () => {
+ it.each([
+ ["bbox absente", "/api/clusters?zoom=5"],
+ ["zoom absent", "/api/clusters?bbox=-5,40,10,55"],
+ ["bbox à 3 valeurs", "/api/clusters?bbox=1,2,3&zoom=5"],
+ ["bbox non numérique", "/api/clusters?bbox=a,b,c,d&zoom=5"],
+ ["latitude > 90", "/api/clusters?bbox=-5,40,10,95&zoom=5"],
+ ["longitude < -180", "/api/clusters?bbox=-181,40,10,55&zoom=5"],
+ ["bbox inversée", "/api/clusters?bbox=10,40,-5,55&zoom=5"],
+ ["zoom négatif", "/api/clusters?bbox=-5,40,10,55&zoom=-1"],
+ ["zoom > 22", "/api/clusters?bbox=-5,40,10,55&zoom=23"],
+ ])("répond 400 pour %s", async (_label, url) => {
+ expect((await build().inject({ method: "GET", url })).statusCode).toBe(400);
+ });
+
+ it("accepte une bbox valide", async () => {
+ const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
+ expect(res.statusCode).toBe(200);
+ });
+
+ // Le seuil de zoom 12 fait basculer entre agrégation en grille et points bruts.
+ it("agrège en clusters en dessous du zoom 12", async () => {
+ const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" });
+ expect(res.json().type).toBe("clusters");
+ expect(pool.find("grid_cells")).toBeDefined();
+ });
+
+ it("renvoie les groupes bruts à partir du zoom 12", async () => {
+ const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=12" });
+ expect(res.json().type).toBe("bands");
+ expect(pool.find("grid_cells")).toBeUndefined();
+ });
+
+ it("ne lit que les localisations réellement géocodées", async () => {
+ await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
+ const sql = pool.find("grid_cells").sql;
+ expect(sql).toContain("bl.geom IS NOT NULL");
+ expect(sql).toContain("geocode_status IN ('done','country_only')");
+ });
+
+ it("utilise l'index spatial via ST_MakeEnvelope avec des paramètres liés", async () => {
+ await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
+ const call = pool.find("grid_cells");
+ expect(call.sql).toContain("bl.geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)");
+ expect(call.values.slice(0, 4)).toEqual([-5, 40, 10, 55]);
+ });
+});
+
+describe("/api/band/:ma_id", () => {
+ it("répond 400 pour un identifiant non numérique", async () => {
+ expect((await build().inject({ method: "GET", url: "/api/band/abc" })).statusCode).toBe(400);
+ });
+
+ it("répond 404 quand le groupe n'existe pas", async () => {
+ const res = await build([{ match: "FROM bands", result: rows() }])
+ .inject({ method: "GET", url: "/api/band/42" });
+ expect(res.statusCode).toBe(404);
+ });
+
+ it("accepte un ma_id au-delà de 2^31 (colonne BIGINT)", async () => {
+ const res = await build([{ match: "FROM bands", result: rows({ ma_id: 3500000000 }) }])
+ .inject({ method: "GET", url: "/api/band/3500000000" });
+ expect(res.statusCode).toBe(200);
+ expect(pool.find("FROM bands").values).toEqual([3500000000]);
+ });
+});
+
+describe("/admin/import — jeton Bearer", () => {
+ it.each([
+ ["sans en-tête", undefined],
+ ["jeton vide", "Bearer "],
+ ["jeton faux de même longueur", `Bearer ${"0".repeat(TOKEN.length)}`],
+ ["jeton faux plus court", "Bearer abc"],
+ ["schéma Basic", "Basic " + Buffer.from("a:b").toString("base64")],
+ ])("répond 401 %s", async (_label, authorization) => {
+ const res = await buildToken().inject({
+ method: "POST", url: "/admin/import",
+ headers: authorization ? { authorization } : {},
+ payload: { bands: [] },
+ });
+ expect(res.statusCode).toBe(401);
+ });
+
+ it("ne touche pas la base quand le jeton est invalide", async () => {
+ await buildToken().inject({
+ method: "POST", url: "/admin/import",
+ headers: { authorization: "Bearer faux" },
+ payload: { bands: [{ ma_id: 1 }] },
+ });
+ expect(tokenPool.calls).toHaveLength(0);
+ });
+
+ it("accepte le bon jeton", async () => {
+ const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
+ method: "POST", url: "/admin/import",
+ headers: { authorization: `Bearer ${TOKEN}` },
+ payload: { bands: [{ ma_id: 1, name: "Mayhem" }] },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().upserted).toBe(1);
+ });
+
+ // Un jeton multi-octets de même longueur en caractères mais pas en octets
+ // faisait lever timingSafeEqual → 500 au lieu d'un 401 propre.
+ it("répond 401 (pas 500) pour un jeton multi-octets", async () => {
+ const res = await buildToken().inject({
+ method: "POST", url: "/admin/import",
+ headers: { authorization: `Bearer ${"é".repeat(TOKEN.length)}` },
+ payload: { bands: [] },
+ });
+ expect(res.statusCode).toBe(401);
+ });
+
+ it("refuse un corps sans tableau bands", async () => {
+ for (const payload of [{}, { bands: "pas-un-tableau" }, { bands: null }]) {
+ const res = await buildToken().inject({
+ method: "POST", url: "/admin/import",
+ headers: { authorization: `Bearer ${TOKEN}` }, payload,
+ });
+ expect(res.statusCode).toBe(400);
+ }
+ });
+
+ it("refuse plus de 1000 groupes par lot", async () => {
+ const bands = Array.from({ length: 1001 }, (_, i) => ({ ma_id: i }));
+ const res = await buildToken().inject({
+ method: "POST", url: "/admin/import",
+ headers: { authorization: `Bearer ${TOKEN}` }, payload: { bands },
+ });
+ expect(res.statusCode).toBe(400);
+ });
+
+ it("ignore les entrées sans ma_id numérique sans planter", async () => {
+ const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
+ method: "POST", url: "/admin/import",
+ headers: { authorization: `Bearer ${TOKEN}` },
+ payload: { bands: [{ ma_id: "abc" }, null, "chaine", { ma_id: 7 }] },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().upserted).toBe(1);
+ });
+
+ // Cas nécessitant une fabrique différente : app sans jeton configuré.
+ it("quand aucun jeton n'est configuré, tout est refusé", async () => {
+ const noTokenApp = await createFullAppWith({ importToken: "", pool: null });
+ const res = await noTokenApp.inject({
+ method: "POST", url: "/admin/import",
+ headers: { authorization: "Bearer nimportequoi" }, payload: { bands: [] },
+ });
+ expect(res.statusCode).toBe(401);
+ await noTokenApp.close();
+ });
+});
+
+describe("/admin/auth/login", () => {
+ it("refuse un corps mal formé sans requête SQL", async () => {
+ const a = build([]);
+ for (const payload of [{}, { username: "a" }, { username: 1, password: 2 }]) {
+ expect((await a.inject({ method: "POST", url: "/admin/auth/login", payload })).statusCode).toBe(400);
+ }
+ expect(pool.calls).toHaveLength(0);
+ });
+
+ it("répond 429 quand le compte est verrouillé, sans vérifier le mot de passe", async () => {
+ const res = await build([
+ { match: "admin_login_attempts", result: rows({ n: 99 }) },
+ { match: "FROM admin_users", result: rows({ password_hash: "x" }) },
+ ]).inject({
+ method: "POST", url: "/admin/auth/login",
+ payload: { username: "nico", password: "x" },
+ });
+ expect(res.statusCode).toBe(429);
+ expect(pool.find("FROM admin_users")).toBeUndefined();
+ });
+
+ it("enregistre la tentative échouée et ne pose pas de cookie", async () => {
+ const bcrypt = (await import("bcryptjs")).default;
+ // Hash à coût 4 : le DUMMY_HASH de production est à coût 12 (~330 ms).
+ const hash = bcrypt.hashSync("autre-chose", 4);
+ const res = await build([
+ { match: "SELECT count(*)::int AS n", result: rows({ n: 0 }) },
+ { match: "FROM admin_users", result: rows({ password_hash: hash }) },
+ { match: "INSERT INTO admin_login_attempts", result: rows() },
+ ]).inject({
+ method: "POST", url: "/admin/auth/login",
+ payload: { username: "nico", password: "mauvais" },
+ });
+ expect(res.statusCode).toBe(401);
+ expect(res.headers["set-cookie"]).toBeUndefined();
+ expect(pool.find("INSERT INTO admin_login_attempts").values[2]).toBe(false);
+ });
+
+ it("le cookie de session est httpOnly, secure, sameSite=strict", async () => {
+ const bcrypt = (await import("bcryptjs")).default;
+ const hash = bcrypt.hashSync("bon", 4);
+ const res = await build([
+ { match: "SELECT count(*)::int AS n", result: rows({ n: 0 }) },
+ { match: "FROM admin_users", result: rows({ password_hash: hash }) },
+ { match: "INSERT INTO admin_login_attempts", result: rows() },
+ { match: "UPDATE admin_users", result: rows() },
+ ]).inject({
+ method: "POST", url: "/admin/auth/login",
+ payload: { username: "nico", password: "bon" },
+ });
+ expect(res.statusCode).toBe(200);
+ const cookie = String(res.headers["set-cookie"]);
+ expect(cookie).toMatch(/HttpOnly/i);
+ expect(cookie).toMatch(/Secure/i);
+ expect(cookie).toMatch(/SameSite=Strict/i);
+ // Le jeton ne doit jamais apparaître dans le corps de la réponse
+ expect(res.json()).toEqual({ ok: true, username: "nico" });
+ });
+
+ it("tronque un username exagérément long avant la requête", async () => {
+ const bcrypt = (await import("bcryptjs")).default;
+ const hash = bcrypt.hashSync("x", 4);
+ await build([
+ { match: "SELECT count(*)::int AS n", result: rows({ n: 0 }) },
+ { match: "FROM admin_users", result: rows({ password_hash: hash }) },
+ { match: "INSERT INTO admin_login_attempts", result: rows() },
+ ]).inject({
+ method: "POST", url: "/admin/auth/login",
+ payload: { username: "a".repeat(5000), password: "x" },
+ });
+ expect(pool.find("FROM admin_users").values[0]).toHaveLength(100);
+ });
+});
+
+describe("gestionnaire d'erreurs global", () => {
+ // Régression : setErrorHandler écrasait TOUS les statuts en 500, y compris
+ // les 4xx légitimes (payload trop gros, JSON invalide, rate-limit).
+ it("préserve un 400 sur JSON invalide", async () => {
+ const res = await build([]).inject({
+ method: "POST", url: "/admin/auth/login",
+ headers: { "content-type": "application/json" },
+ payload: "{ceci n'est pas du json",
+ });
+ expect(res.statusCode).toBe(400);
+ });
+
+ it("renvoie 500 générique pour une erreur inattendue", async () => {
+ const res = await build([{ match: "FROM bands", throws: new Error("boom") }])
+ .inject({ method: "GET", url: "/api/stats" });
+ expect(res.statusCode).toBe(500);
+ expect(res.json().error).not.toContain("boom");
+ });
+});
+
+describe("sans base de données configurée", () => {
+ it("les routes de données répondent 500 mais le service reste debout", async () => {
+ const noDbApp = await createFullAppWith({ pool: null });
+ expect((await noDbApp.inject({ method: "GET", url: "/api/stats" })).statusCode).toBe(500);
+ expect((await noDbApp.inject({ method: "GET", url: "/api/health" })).statusCode).toBe(200);
+ await noDbApp.close();
+ });
+});
diff --git a/apps/api/test/publicRoutesCoverage.test.js b/apps/api/test/publicRoutesCoverage.test.js
new file mode 100644
index 0000000..8c50eff
--- /dev/null
+++ b/apps/api/test/publicRoutesCoverage.test.js
@@ -0,0 +1,393 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+
+process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
+
+const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js");
+const { rows } = await import("./helpers/fakePool.js");
+const { createFullApp } = await import("./helpers/testApp.js");
+
+const TOKEN_FOR_APP = "jeton-de-test";
+
+// Deux apps seulement pour tout le fichier : une par jeu d'options de fabrique.
+// Chaque test se contente de reprogrammer le pool associé.
+let app, pool, tokenApp, tokenPool;
+
+beforeAll(async () => {
+ ({ app, pool } = await createFullApp());
+ ({ app: tokenApp, pool: tokenPool } = await createFullApp({ importToken: TOKEN_FOR_APP }));
+});
+afterAll(async () => { await app.close(); await tokenApp.close(); });
+
+/** Reprogramme le pool partagé et renvoie l'app publique. */
+function build(handlers) {
+ pool.reset(handlers ?? []);
+ return app;
+}
+
+/** Idem pour l'app configurée avec un jeton d'import. */
+function buildToken(handlers) {
+ tokenPool.reset(handlers ?? []);
+ return tokenApp;
+}
+
+const anyPool = (extra = []) =>
+ [...extra, { match: /./, result: rows({ n: 1, total: 1, value: "x", count: 1 }) }];
+
+describe("routes d'agrégation publiques", () => {
+ const ROUTES = ["/api/db", "/api/stats", "/api/countries", "/api/statuses", "/api/facets"];
+
+ it.each(ROUTES)("%s répond 200 avec ok:true", async (url) => {
+ const app = build(anyPool());
+ const res = await app.inject({ method: "GET", url });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().ok).toBe(true);
+ });
+
+ it.each(ROUTES)("%s répond 500 sans divulguer l'erreur si la base tombe", async (url) => {
+ const handlers = ([{ match: /./, throws: new Error("FATAL: role bm does not exist") }]);
+ const app = build(handlers);
+ const res = await app.inject({ method: "GET", url });
+ expect(res.statusCode).toBe(500);
+ expect(res.body).not.toMatch(/role bm|FATAL/);
+ });
+
+ it.each(ROUTES)("%s est accessible sans authentification (données publiques)", async (url) => {
+ const app = build(anyPool());
+ expect((await app.inject({ method: "GET", url })).statusCode).not.toBe(401);
+ });
+});
+
+describe("/api/stats", () => {
+ it("expose les compteurs attendus par le frontend", async () => {
+ const handlers = ([
+ { match: "FROM bands", result: rows({ total: 100, geocoded: 80, no_location: 20, enriched: 60 }) },
+ ]);
+ const app = build(handlers);
+ const body = (await app.inject({ method: "GET", url: "/api/stats" })).json();
+ expect(body).toMatchObject({ ok: true, total: 100, geocoded: 80, no_location: 20, enriched: 60 });
+ });
+
+ // geocoded compte les GROUPES ayant un point principal (bands.geom), pas les
+ // localisations. La carte, elle, clusterise band_locations : les deux chiffres
+ // n'ont pas la même unité et ne doivent pas être comparés naïvement.
+ it("compte les groupes via bands.geom, pas les localisations", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: "/api/stats" });
+ const sql = pool.find("FROM bands").sql;
+ expect(sql).toContain("geom IS NOT NULL");
+ expect(sql).not.toContain("band_locations");
+ });
+});
+
+describe("/api/facets", () => {
+ it("ne propose que des facettes portant sur des groupes géocodés", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: "/api/facets" });
+ // Sinon l'utilisateur peut cocher une facette qui ne fera apparaître
+ // aucun point sur la carte.
+ for (const call of pool.calls) {
+ expect(call.sql).toContain("geom IS NOT NULL");
+ }
+ });
+
+ it("borne la liste des genres", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: "/api/facets" });
+ expect(pool.find("genre as value").sql).toContain("LIMIT 500");
+ });
+
+ it("renvoie une plage d'années même quand la base est vide", async () => {
+ const handlers = ([{ match: /./, result: rows() }]);
+ const app = build(handlers);
+ const body = (await app.inject({ method: "GET", url: "/api/facets" })).json();
+ expect(body.year_range).toEqual({ min_year: null, max_year: null });
+ });
+
+ it("écarte les années aberrantes de la plage", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: "/api/facets" });
+ const sql = pool.find("MIN(formed_year)").sql;
+ expect(sql).toContain("formed_year >= 1900");
+ expect(sql).toContain("extract(year from now())");
+ });
+});
+
+describe("/api/bands — filtres", () => {
+ it("filtre sur la présence de coordonnées", async () => {
+ const mk = async (qs) => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: `/api/bands${qs}` });
+ return pool.find("FROM bands").sql;
+ };
+ expect(await mk("?geocoded=1")).toContain("geom IS NOT NULL");
+ expect(await mk("?geocoded=0")).toContain("geom IS NULL");
+ expect(await mk("")).not.toContain("geom IS");
+ });
+
+ it("only_black=1 filtre sur le genre", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: "/api/bands?only_black=1" });
+ expect(pool.find("FROM bands").sql).toContain("genre ILIKE '%black%'");
+ });
+
+ it("combine plusieurs filtres avec AND", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: "/api/bands?countries=NO&geocoded=1&status=Active" });
+ const sql = pool.find("FROM bands").sql;
+ expect(sql).toContain(" AND ");
+ expect(sql.match(/ AND /g).length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("les statuts vides sont normalisés en 'Unknown'", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: "/api/bands?status=Unknown" });
+ expect(pool.find("FROM bands").sql).toContain("COALESCE(NULLIF(trim(status), ''), 'Unknown')");
+ });
+
+ it("un tri stable est imposé (pagination cohérente)", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: "/api/bands" });
+ // Sans ORDER BY déterministe, deux pages successives peuvent renvoyer
+ // deux fois le même groupe et en omettre un autre.
+ expect(pool.find("FROM bands").sql).toContain("ORDER BY ma_id ASC");
+ });
+});
+
+describe("/api/clusters — filtres", () => {
+ const base = "/api/clusters?bbox=-5,40,10,55&zoom=5";
+
+ it("applique le filtre de genre en paramètre lié", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: `${base}&genre=black` });
+ const call = pool.find("grid_cells");
+ expect(call.sql).toContain("b.genre ILIKE");
+ expect(call.values).toContain("%black%");
+ });
+
+ it("rejette un genre trop court ou pathologique", async () => {
+ const app = build(anyPool());
+ expect((await app.inject({ method: "GET", url: `${base}&genre=a` })).statusCode).toBe(400);
+ expect((await app.inject({ method: "GET", url: `${base}&genre=%25%25%25` })).statusCode).toBe(400);
+ });
+
+ it.each([
+ ["year_min=1799", 400], ["year_min=2101", 400], ["year_min=abc", 400],
+ ["year_max=1799", 400], ["year_max=abc", 400],
+ ["year_min=1980&year_max=1990", 200],
+ ])("%s → %i", async (qs, expected) => {
+ const app = build(anyPool());
+ expect((await app.inject({ method: "GET", url: `${base}&${qs}` })).statusCode).toBe(expected);
+ });
+
+ it("refuse plus de 100 pays et plus de 50 statuts", async () => {
+ const app = build(anyPool());
+ const c = Array.from({ length: 101 }, (_, i) => `C${i}`).join(",");
+ const s = Array.from({ length: 51 }, (_, i) => `s${i}`).join(",");
+ expect((await app.inject({ method: "GET", url: `${base}&countries=${c}` })).statusCode).toBe(400);
+ expect((await app.inject({ method: "GET", url: `${base}&status=${s}` })).statusCode).toBe(400);
+ });
+
+ it("la taille de cellule décroît quand le zoom augmente", async () => {
+ const cell = async (zoom) => {
+ const app = build(anyPool());
+ const body = (await app.inject({ method: "GET", url: `/api/clusters?bbox=-5,40,10,55&zoom=${zoom}` })).json();
+ return body.cell_size;
+ };
+ expect(await cell(3)).toBeGreaterThan(await cell(8));
+ });
+
+ it("la taille de cellule a un plancher (pas de division infinie)", async () => {
+ const app = build(anyPool());
+ const body = (await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" })).json();
+ expect(body.cell_size).toBeGreaterThanOrEqual(0.01);
+ });
+
+ it("borne le nombre de résultats", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: base });
+ expect(pool.find("grid_cells").sql).toContain("LIMIT 1000");
+ });
+
+ it("borne aussi le mode points bruts", async () => {
+ const handlers = anyPool();
+ const app = build(handlers);
+ await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=14" });
+ expect(pool.find("FROM band_locations bl").sql).toContain("LIMIT 2000");
+ });
+});
+
+describe("/admin/enrich/next", () => {
+ const TOKEN = TOKEN_FOR_APP;
+ const withToken = { authorization: `Bearer ${TOKEN}` };
+
+ it("exige le jeton Bearer", async () => {
+ const handlers = ([]);
+ const app = buildToken(handlers);
+ expect((await app.inject({ method: "GET", url: "/admin/enrich/next" })).statusCode).toBe(401);
+ expect(tokenPool.calls).toHaveLength(0);
+ });
+
+ it("ne renvoie que les groupes ayant une URL", async () => {
+ const handlers = ([
+ { match: "FROM bands", result: rows({ ma_id: 1, url: "http://x" }, { ma_id: 2, url: null }) },
+ ]);
+ const app = buildToken(handlers);
+ const body = (await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken })).json();
+ expect(body.count).toBe(1);
+ expect(body.items).toEqual([{ ma_id: 1, url: "http://x" }]);
+ });
+
+ it("borne la limite entre 1 et 500", async () => {
+ const lim = async (q) => {
+ const handlers = ([{ match: "FROM bands", result: rows() }]);
+ const app = buildToken(handlers);
+ await app.inject({ method: "GET", url: `/admin/enrich/next?limit=${q}`, headers: withToken });
+ return tokenPool.find("FROM bands").values.at(-1);
+ };
+ expect(await lim(9999)).toBe(500);
+ expect(await lim(0)).toBe(1);
+ expect(await lim(-5)).toBe(1);
+ expect(await lim("abc")).toBe(50); // valeur par défaut, pas NaN
+ expect(await lim(25)).toBe(25);
+ });
+
+ it("filtre par pays, normalisé en majuscules et lié", async () => {
+ const handlers = ([{ match: "FROM bands", result: rows() }]);
+ const app = buildToken(handlers);
+ await app.inject({ method: "GET", url: "/admin/enrich/next?country=no", headers: withToken });
+ const call = tokenPool.find("FROM bands");
+ expect(call.sql).toContain("country = $1");
+ expect(call.values[0]).toBe("NO");
+ });
+
+ it("ne sélectionne que les groupes jamais enrichis", async () => {
+ const handlers = ([{ match: "FROM bands", result: rows() }]);
+ const app = buildToken(handlers);
+ await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken });
+ expect(tokenPool.find("FROM bands").sql).toContain("(data->'band_page') IS NULL");
+ });
+});
+
+describe("/admin/auth/me et logout", () => {
+ it("me répond 401 sans cookie", async () => {
+ const app = build(anyPool());
+ expect((await app.inject({ method: "GET", url: "/admin/auth/me" })).statusCode).toBe(401);
+ });
+
+ it("me renvoie le username de la session", async () => {
+ const app = build(anyPool());
+ const res = await app.inject({
+ method: "GET", url: "/admin/auth/me",
+ headers: { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` },
+ });
+ expect(res.json()).toEqual({ ok: true, username: "nico" });
+ });
+
+ it("logout efface le cookie", async () => {
+ const app = build(anyPool());
+ const res = await app.inject({ method: "POST", url: "/admin/auth/logout" });
+ expect(res.statusCode).toBe(200);
+ const cookie = String(res.headers["set-cookie"]);
+ expect(cookie).toContain(ADMIN_COOKIE_NAME);
+ expect(cookie).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
+ });
+
+ it("logout ne nécessite pas de session valide (idempotent)", async () => {
+ const app = build(anyPool());
+ expect((await app.inject({ method: "POST", url: "/admin/auth/logout" })).statusCode).toBe(200);
+ });
+});
+
+describe("/admin/import — upsert", () => {
+ const TOKEN = TOKEN_FOR_APP;
+ const withToken = { authorization: `Bearer ${TOKEN}` };
+
+ const importPool = () => ([{ match: "INSERT INTO bands", result: rows() }]);
+
+ it("préserve les valeurs existantes quand le champ est absent (COALESCE)", async () => {
+ const handlers = importPool();
+ const app = buildToken(handlers);
+ await app.inject({
+ method: "POST", url: "/admin/import", headers: withToken,
+ payload: { bands: [{ ma_id: 1, name: "Mayhem" }] },
+ });
+ const sql = tokenPool.find("INSERT INTO bands").sql;
+ expect(sql).toContain("ON CONFLICT (ma_id) DO UPDATE");
+ expect(sql).toContain("COALESCE(EXCLUDED.name, bands.name)");
+ });
+
+ it("accepte `location` comme alias de `location_text`", async () => {
+ const handlers = importPool();
+ const app = buildToken(handlers);
+ await app.inject({
+ method: "POST", url: "/admin/import", headers: withToken,
+ payload: { bands: [{ ma_id: 1, location: "Oslo" }] },
+ });
+ expect(tokenPool.find("INSERT INTO bands").values[5]).toBe("Oslo");
+ });
+
+ it("marque enriched quand un objet data est fourni", async () => {
+ const handlers = importPool();
+ const app = buildToken(handlers);
+ await app.inject({
+ method: "POST", url: "/admin/import", headers: withToken,
+ payload: { bands: [{ ma_id: 1, data: { band_page: "..." } }] },
+ });
+ expect(tokenPool.find("INSERT INTO bands").values[7]).toBe(true);
+ });
+
+ it("un data non-objet n'active pas enriched", async () => {
+ const handlers = importPool();
+ const app = buildToken(handlers);
+ await app.inject({
+ method: "POST", url: "/admin/import", headers: withToken,
+ payload: { bands: [{ ma_id: 1, data: "pas-un-objet" }] },
+ });
+ const vals = tokenPool.find("INSERT INTO bands").values;
+ expect(vals[6]).toBeNull();
+ expect(vals[7]).toBeNull();
+ });
+
+ it("tous les champs passent en paramètres liés", async () => {
+ const handlers = importPool();
+ const app = buildToken(handlers);
+ await app.inject({
+ method: "POST", url: "/admin/import", headers: withToken,
+ payload: { bands: [{ ma_id: 1, name: "'; DROP TABLE bands--" }] },
+ });
+ const call = tokenPool.find("INSERT INTO bands");
+ expect(call.sql).not.toContain("DROP TABLE");
+ expect(call.values).toContain("'; DROP TABLE bands--");
+ });
+
+ it("compte exactement les lignes traitées", async () => {
+ const handlers = importPool();
+ const app = buildToken(handlers);
+ const res = await app.inject({
+ method: "POST", url: "/admin/import", headers: withToken,
+ payload: { bands: [{ ma_id: 1 }, { ma_id: 2 }, { ma_id: 3 }] },
+ });
+ expect(res.json().upserted).toBe(3);
+ expect(tokenPool.findAll("INSERT INTO bands")).toHaveLength(3);
+ });
+
+ it("accepte un lot de exactement 1000 groupes", async () => {
+ const app = buildToken(importPool());
+ const bands = Array.from({ length: 1000 }, (_, i) => ({ ma_id: i + 1 }));
+ const res = await app.inject({
+ method: "POST", url: "/admin/import", headers: withToken, payload: { bands },
+ });
+ expect(res.statusCode).toBe(200);
+ });
+});
diff --git a/apps/api/test/rateLimit.test.js b/apps/api/test/rateLimit.test.js
new file mode 100644
index 0000000..d7b19f9
--- /dev/null
+++ b/apps/api/test/rateLimit.test.js
@@ -0,0 +1,126 @@
+import { describe, it, expect } from "vitest";
+
+process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
+
+const { createFullAppWith } = await import("./helpers/testApp.js");
+const { makeFakePool, rows } = await import("./helpers/fakePool.js");
+
+/**
+ * Les autres fichiers de test partagent une app avec des plafonds de débit
+ * neutralisés (sinon l'état du limiteur fuiterait d'un cas à l'autre). C'EST
+ * ICI, et seulement ici, que les vraies valeurs de production sont vérifiées —
+ * sinon la paramétrisation introduite pour la performance créerait un angle
+ * mort sur une protection de sécurité.
+ */
+
+const TOKEN = "jeton-de-test";
+
+async function appWithRealLimits(overrides = {}) {
+ return createFullAppWith({
+ pool: makeFakePool([{ match: /./, result: rows({ total: 0 }) }]),
+ importToken: TOKEN,
+ ...overrides,
+ });
+}
+
+describe("plafond global", () => {
+ it("répond 429 au-delà du plafond", async () => {
+ const app = await appWithRealLimits({ globalRateLimitMax: 3 });
+ const codes = [];
+ for (let i = 0; i < 5; i++) {
+ codes.push((await app.inject({ method: "GET", url: "/api/health" })).statusCode);
+ }
+ expect(codes).toEqual([200, 200, 200, 429, 429]);
+ await app.close();
+ });
+
+ it("le 429 traverse le gestionnaire d'erreurs sans devenir un 500", async () => {
+ // Régression : setErrorHandler écrasait tous les statuts en 500, ce qui
+ // transformait une limitation de débit en fausse panne serveur.
+ const app = await appWithRealLimits({ globalRateLimitMax: 1 });
+ await app.inject({ method: "GET", url: "/api/health" });
+ const res = await app.inject({ method: "GET", url: "/api/health" });
+ expect(res.statusCode).toBe(429);
+ expect(res.json().ok).toBe(false);
+ await app.close();
+ });
+
+ it("la valeur de production par défaut est 1000/minute", async () => {
+ const app = await appWithRealLimits();
+ const res = await app.inject({ method: "GET", url: "/api/health" });
+ expect(res.headers["x-ratelimit-limit"]).toBe("1000");
+ await app.close();
+ });
+});
+
+describe("plafond de /admin/import", () => {
+ it("la valeur de production par défaut est 10/minute", async () => {
+ const app = await appWithRealLimits();
+ const res = await app.inject({
+ method: "POST", url: "/admin/import",
+ headers: { authorization: `Bearer ${TOKEN}` }, payload: { bands: [] },
+ });
+ expect(res.headers["x-ratelimit-limit"]).toBe("10");
+ await app.close();
+ });
+
+ it("limite par jeton, pas globalement", async () => {
+ const app = await appWithRealLimits({ adminRateLimitMax: 2 });
+ const call = (token) =>
+ app.inject({
+ method: "POST", url: "/admin/import",
+ headers: { authorization: `Bearer ${token}` }, payload: { bands: [] },
+ });
+
+ await call(TOKEN);
+ await call(TOKEN);
+ // Le 3e appel avec CE jeton est bloqué…
+ expect((await call(TOKEN)).statusCode).toBe(429);
+ // …mais un autre jeton dispose de son propre compteur (il sera rejeté en
+ // 401, pas en 429 : c'est bien l'authentification qui tranche, pas le débit).
+ expect((await call("un-autre-jeton-de-la-meme-taille")).statusCode).toBe(401);
+ await app.close();
+ });
+});
+
+describe("plafond de /admin/auth/login", () => {
+ it("la valeur de production par défaut est 10/minute", async () => {
+ const app = await appWithRealLimits();
+ const res = await app.inject({
+ method: "POST", url: "/admin/auth/login", payload: { username: "", password: "" },
+ });
+ expect(res.headers["x-ratelimit-limit"]).toBe("10");
+ await app.close();
+ });
+
+ // Le verrouillage de compte (5 échecs / 15 min, en base) et le rate-limit HTTP
+ // sont deux protections distinctes : celle-ci s'applique même à des requêtes
+ // malformées qui n'atteignent jamais la base.
+ it("limite le bruteforce même sur des corps invalides", async () => {
+ const app = await appWithRealLimits({ authRateLimitMax: 2 });
+ const call = () =>
+ app.inject({ method: "POST", url: "/admin/auth/login", payload: {} });
+
+ expect((await call()).statusCode).toBe(400);
+ expect((await call()).statusCode).toBe(400);
+ expect((await call()).statusCode).toBe(429);
+ await app.close();
+ });
+
+ it("limite par IP", async () => {
+ const app = await appWithRealLimits({ authRateLimitMax: 1 });
+ const call = (ip) =>
+ app.inject({
+ method: "POST", url: "/admin/auth/login",
+ headers: { "x-forwarded-for": ip },
+ payload: {},
+ });
+
+ expect((await call("1.2.3.4")).statusCode).toBe(400);
+ expect((await call("1.2.3.4")).statusCode).toBe(429);
+ // Une autre IP garde son propre quota (trustProxy est actif : l'app est
+ // derrière Traefik, l'IP réelle vient de X-Forwarded-For).
+ expect((await call("5.6.7.8")).statusCode).toBe(400);
+ await app.close();
+ });
+});
diff --git a/apps/api/test/validate.test.js b/apps/api/test/validate.test.js
new file mode 100644
index 0000000..ffbf817
--- /dev/null
+++ b/apps/api/test/validate.test.js
@@ -0,0 +1,220 @@
+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/);
+ });
+});
diff --git a/apps/crawler/src/db.py b/apps/crawler/src/db.py
index 37e2789..5b5940b 100644
--- a/apps/crawler/src/db.py
+++ b/apps/crawler/src/db.py
@@ -3,9 +3,10 @@
"""
import json
import logging
+import time
from contextlib import contextmanager
-from datetime import datetime, timezone
-from typing import Any, Dict, List, Optional
+from datetime import UTC, datetime
+from typing import Any
import psycopg2
import psycopg2.extras
@@ -29,14 +30,14 @@ def get_conn():
def now_utc() -> datetime:
- return datetime.now(timezone.utc)
+ return datetime.now(UTC)
# ------------------------------------------------------------------
# Bands
# ------------------------------------------------------------------
-def upsert_bands(bands: List[Dict[str, Any]]) -> Dict[str, int]:
+def upsert_bands(bands: list[dict[str, Any]]) -> dict[str, int]:
"""
Upsert en bulk. Préserve les champs existants si la nouvelle valeur est None.
Retourne {"inserted": N, "updated": N}.
@@ -99,7 +100,7 @@ def upsert_bands(bands: List[Dict[str, Any]]) -> Dict[str, int]:
return {"inserted": inserted, "updated": updated}
-def upsert_band_enriched(ma_id: int, data: Dict[str, Any], html_hash: str) -> bool:
+def upsert_band_enriched(ma_id: int, data: dict[str, Any], html_hash: str) -> bool:
"""
Met à jour les champs d'enrichissement d'un band.
Respecte locked_fields : les champs édités manuellement dans l'admin ne sont pas
@@ -107,7 +108,7 @@ def upsert_band_enriched(ma_id: int, data: Dict[str, Any], html_hash: str) -> bo
stockée dans crawler_pending pour résolution manuelle.
"""
ts = now_utc()
- new_vals: Dict[str, Any] = {
+ new_vals: dict[str, Any] = {
"status": data.get("status"),
"genre": data.get("genre"),
"themes": data.get("themes"),
@@ -157,7 +158,7 @@ def upsert_band_enriched(ma_id: int, data: Dict[str, Any], html_hash: str) -> bo
return cur.rowcount > 0
-def get_bands_to_enrich(country: Optional[str] = None, limit: int = 50) -> List[Dict]:
+def get_bands_to_enrich(country: str | None = None, limit: int = 50) -> list[dict]:
"""
File de priorité pour l'enrichissement :
1. Nouveaux bands (band_page absent) — jamais enrichis
@@ -203,7 +204,7 @@ def get_bands_to_enrich(country: Optional[str] = None, limit: int = 50) -> List[
# Logs (visibles dans le dashboard admin)
# ------------------------------------------------------------------
-def log_event(level: str, message: str, run_id: Optional[int] = None, ma_id: Optional[int] = None):
+def log_event(level: str, message: str, run_id: int | None = None, ma_id: int | None = None):
"""Écrit une ligne de log en DB pour le dashboard admin (n'interrompt jamais le crawl)."""
try:
with get_conn() as conn:
@@ -220,7 +221,7 @@ def log_event(level: str, message: str, run_id: Optional[int] = None, ma_id: Opt
# Crawl run tracking
# ------------------------------------------------------------------
-def update_crawl_run_progress(run_id: int, stats: Dict[str, int]):
+def update_crawl_run_progress(run_id: int, stats: dict[str, int]):
"""Mise à jour des compteurs d'un run en cours (progression live)."""
try:
with get_conn() as conn:
@@ -236,7 +237,7 @@ def update_crawl_run_progress(run_id: int, stats: Dict[str, int]):
log.warning(f"[db] update_crawl_run_progress failed: {e}")
-def start_crawl_run(run_type: str, countries: Optional[List[str]] = None) -> int:
+def start_crawl_run(run_type: str, countries: list[str] | None = None) -> int:
sql = "INSERT INTO crawl_run (run_type, countries) VALUES (%s, %s) RETURNING id"
with get_conn() as conn:
with conn.cursor() as cur:
@@ -244,8 +245,62 @@ def start_crawl_run(run_type: str, countries: Optional[List[str]] = None) -> int
return cur.fetchone()[0]
-def finish_crawl_run(run_id: int, stats: Dict[str, int], error: Optional[str] = None):
- status = "error" if error else "done"
+class RunCancelled(Exception):
+ """Levée dans les boucles de crawl quand un admin demande l'annulation.
+
+ Volontairement distincte des erreurs : un run annulé n'est pas un échec, il
+ ne doit ni être marqué 'error' ni polluer les alertes.
+ """
+
+
+# Le drapeau d'annulation est relu au plus une fois toutes les
+# _CANCEL_POLL_SECONDS secondes : les boucles de crawl appellent le check à
+# chaque groupe, on ne veut pas une requête SQL par groupe.
+_CANCEL_POLL_SECONDS = 5.0
+_cancel_cache: dict[int, tuple[float, bool]] = {}
+
+
+def is_cancel_requested(run_id: int, force: bool = False) -> bool:
+ """Un admin a-t-il demandé l'arrêt de ce run ?
+
+ En cas d'erreur DB on renvoie False : une base momentanément injoignable ne
+ doit pas interrompre un crawl en cours.
+ """
+ now = time.monotonic()
+ cached = _cancel_cache.get(run_id)
+ if not force and cached and (now - cached[0]) < _CANCEL_POLL_SECONDS:
+ return cached[1]
+
+ try:
+ with get_conn() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT cancel_requested FROM crawl_run WHERE id = %s", (run_id,)
+ )
+ row = cur.fetchone()
+ requested = bool(row and row[0])
+ except Exception as e:
+ log.warning(f"[db] is_cancel_requested failed: {e}")
+ return cached[1] if cached else False
+
+ _cancel_cache[run_id] = (now, requested)
+ return requested
+
+
+def raise_if_cancelled(run_id: int):
+ """Point de contrôle à placer dans les boucles longues."""
+ if is_cancel_requested(run_id):
+ raise RunCancelled(f"run #{run_id} annulé par un administrateur")
+
+
+def finish_crawl_run(
+ run_id: int,
+ stats: dict[str, int],
+ error: str | None = None,
+ cancelled: bool = False,
+):
+ _cancel_cache.pop(run_id, None)
+ status = "cancelled" if cancelled else ("error" if error else "done")
sql = """
UPDATE crawl_run SET
status = %s, finished_at = now(),
@@ -267,7 +322,7 @@ def finish_crawl_run(run_id: int, stats: Dict[str, int], error: Optional[str] =
# Checkpoints
# ------------------------------------------------------------------
-def get_checkpoint(key: str) -> Optional[str]:
+def get_checkpoint(key: str) -> str | None:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT value FROM crawl_checkpoint WHERE key = %s", (key,))
@@ -290,7 +345,7 @@ def set_checkpoint(key: str, value: str):
# Job triggers (déclenchement depuis le dashboard admin)
# ------------------------------------------------------------------
-def claim_job_trigger(job_type: Optional[str] = None) -> Optional[int]:
+def claim_job_trigger(job_type: str | None = None) -> int | None:
"""Récupère et verrouille un job trigger en attente. Retourne son id ou None."""
where = "status = 'pending'"
params: list = []
@@ -317,7 +372,7 @@ def claim_job_trigger(job_type: Optional[str] = None) -> Optional[int]:
return None
-def finish_job_trigger(trigger_id: int, error: Optional[str] = None):
+def finish_job_trigger(trigger_id: int, error: str | None = None):
try:
with get_conn() as conn:
with conn.cursor() as cur:
@@ -333,7 +388,7 @@ def finish_job_trigger(trigger_id: int, error: Optional[str] = None):
# Helpers
# ------------------------------------------------------------------
-def _parse_year(s: Optional[str]) -> Optional[int]:
+def _parse_year(s: str | None) -> int | None:
if not s:
return None
m = __import__("re").search(r"\b(1[89]\d\d|20\d\d)\b", str(s))
diff --git a/apps/crawler/src/flaresolverr.py b/apps/crawler/src/flaresolverr.py
index e11829c..9bf648e 100644
--- a/apps/crawler/src/flaresolverr.py
+++ b/apps/crawler/src/flaresolverr.py
@@ -9,7 +9,6 @@ Workflow :
5. fs.destroy_session(session_id) → libère la ressource Chrome
"""
import logging
-from typing import Optional, Tuple
import requests
@@ -47,7 +46,7 @@ class FlareSolverr:
except Exception as e:
log.warning(f"[fs] destroy session {session_id} failed: {e}")
- def get(self, url: str, session_id: Optional[str] = None) -> Tuple[int, str]:
+ def get(self, url: str, session_id: str | None = None) -> tuple[int, str]:
"""
GET via FlareSolverr. Retourne (http_status_code, response_body).
Si session_id fourni, réutilise le Chrome existant (pas de re-warmup).
diff --git a/apps/crawler/src/jobs.py b/apps/crawler/src/jobs.py
index f5fbc9e..e8da363 100644
--- a/apps/crawler/src/jobs.py
+++ b/apps/crawler/src/jobs.py
@@ -2,29 +2,35 @@
Jobs de crawl. Chaque fonction est un job indépendant appelé par le scheduler.
"""
import logging
-from datetime import datetime, timezone
-from typing import List, Optional
+from datetime import UTC, datetime
from .config import (
- BAND_MIN_DELAY, BAND_MAX_DELAY,
- COOLDOWN_EVERY, COOLDOWN_MIN, COOLDOWN_MAX,
+ BAND_MAX_DELAY,
+ BAND_MIN_DELAY,
+ COOLDOWN_EVERY,
+ COOLDOWN_MAX,
+ COOLDOWN_MIN,
MA_BASE,
)
from .db import (
- upsert_bands, upsert_band_enriched,
+ RunCancelled,
+ finish_crawl_run,
get_bands_to_enrich,
- start_crawl_run, finish_crawl_run,
- update_crawl_run_progress,
- get_checkpoint, set_checkpoint,
+ get_checkpoint,
log_event,
+ raise_if_cancelled,
+ set_checkpoint,
+ start_crawl_run,
+ update_crawl_run_progress,
+ upsert_band_enriched,
+ upsert_bands,
)
from .europe_codes import EUROPE_COUNTRY_CODES
+from .ma_http import MASession
+from .polite import cooldown, sleep_range
+from .scraper_band import page_hash, parse_band_page
_EU_SET = set(EUROPE_COUNTRY_CODES)
-from .europe_codes import EUROPE_COUNTRY_CODES
-from .ma_http import MASession
-from .polite import sleep_range, cooldown
-from .scraper_band import parse_band_page, page_hash
log = logging.getLogger(__name__)
@@ -35,21 +41,26 @@ CHUNK = 300 # taille des batches d'upsert
# Crawl complet (tous les pays d'un coup)
# ------------------------------------------------------------------
-def run_full_crawl(session: MASession, countries: List[str] = None):
+def run_full_crawl(session: MASession, countries: list[str] = None):
countries = countries or EUROPE_COUNTRY_CODES
run_id = start_crawl_run("full_europe", countries)
stats = {"seen": 0, "new": 0, "updated": 0, "enriched": 0}
error = None
+ cancelled = False
log_event("info", f"full crawl started ({len(countries)} countries)", run_id=run_id)
try:
for cc in countries:
+ raise_if_cancelled(run_id)
log.info(f"[full] country={cc}")
buf = []
for band in session.fetch_country_bands(cc):
band["data"] = {"url": band.pop("url", None)}
buf.append(band)
if len(buf) >= CHUNK:
+ # Un crawl complet Europe dure des heures : le contrôle doit
+ # tomber à chaque lot, pas seulement entre deux pays.
+ raise_if_cancelled(run_id)
r = upsert_bands(buf)
stats["seen"] += len(buf)
stats["new"] += r["inserted"]
@@ -64,12 +75,16 @@ def run_full_crawl(session: MASession, countries: List[str] = None):
set_checkpoint("last_full_crawl_at", _now_iso())
log.info(f"[full] done: {stats}")
log_event("info", f"full crawl done: {stats}", run_id=run_id)
+ except RunCancelled:
+ cancelled = True
+ log.info(f"[full] annulé sur demande admin: {stats}")
+ log_event("warning", f"full crawl annulé par un admin: {stats}", run_id=run_id)
except Exception as e:
error = str(e)
log.error(f"[full] error: {e}", exc_info=True)
log_event("error", f"full crawl error: {e}", run_id=run_id)
finally:
- finish_crawl_run(run_id, stats, error)
+ finish_crawl_run(run_id, stats, error, cancelled=cancelled)
# ------------------------------------------------------------------
@@ -86,6 +101,7 @@ def run_incremental(session: MASession, order_by: str):
run_id = start_crawl_run(f"incremental_{order_by}")
stats = {"seen": 0, "new": 0, "updated": 0, "enriched": 0}
error = None
+ cancelled = False
latest_date = since
log.info(f"[incr/{order_by}] since={since}")
@@ -103,6 +119,7 @@ def run_incremental(session: MASession, order_by: str):
band["data"] = {"url": band.pop("url", None)}
buf.append(band)
if len(buf) >= CHUNK:
+ raise_if_cancelled(run_id)
r = upsert_bands(buf)
stats["seen"] += len(buf)
stats["new"] += r["inserted"]
@@ -119,19 +136,23 @@ def run_incremental(session: MASession, order_by: str):
set_checkpoint(checkpoint_key, latest_date)
log.info(f"[incr/{order_by}] done: {stats}")
log_event("info", f"incremental/{order_by} done: {stats}", run_id=run_id)
+ except RunCancelled:
+ cancelled = True
+ log.info(f"[incr/{order_by}] annulé sur demande admin: {stats}")
+ log_event("warning", f"incremental/{order_by} annulé par un admin: {stats}", run_id=run_id)
except Exception as e:
error = str(e)
log.error(f"[incr/{order_by}] error: {e}", exc_info=True)
log_event("error", f"incremental/{order_by} error: {e}", run_id=run_id)
finally:
- finish_crawl_run(run_id, stats, error)
+ finish_crawl_run(run_id, stats, error, cancelled=cancelled)
# ------------------------------------------------------------------
# Enrichissement des pages individuelles de bands
# ------------------------------------------------------------------
-def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = None):
+def run_enrich(session: MASession, limit: int = 100, country: str | None = None):
"""
Visite les pages individuelles des bands non encore enrichies.
Stocke themes, membres, label, dates MA, hash HTML.
@@ -139,6 +160,7 @@ def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = No
run_id = start_crawl_run("enrich", [country] if country else None)
stats = {"seen": 0, "new": 0, "updated": 0, "enriched": 0}
error = None
+ cancelled = False
try:
bands = get_bands_to_enrich(country=country, limit=limit)
@@ -146,6 +168,10 @@ def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = No
log_event("info", f"enrich started: {len(bands)} bands queued", run_id=run_id)
for i, band in enumerate(bands):
+ # Volontairement AVANT le try/except interne : celui-ci fait
+ # `except Exception: continue` et avalerait RunCancelled.
+ raise_if_cancelled(run_id)
+
url = band.get("url")
if not url:
continue
@@ -174,12 +200,16 @@ def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = No
log.info(f"[enrich] done: {stats}")
log_event("info", f"enrich done: {stats}", run_id=run_id)
+ except RunCancelled:
+ cancelled = True
+ log.info(f"[enrich] annulé sur demande admin: {stats}")
+ log_event("warning", f"enrich annulé par un admin: {stats}", run_id=run_id)
except Exception as e:
error = str(e)
log.error(f"[enrich] error: {e}", exc_info=True)
log_event("error", f"enrich error: {e}", run_id=run_id)
finally:
- finish_crawl_run(run_id, stats, error)
+ finish_crawl_run(run_id, stats, error, cancelled=cancelled)
# ------------------------------------------------------------------
@@ -187,4 +217,4 @@ def run_enrich(session: MASession, limit: int = 100, country: Optional[str] = No
# ------------------------------------------------------------------
def _now_iso() -> str:
- return datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ return datetime.now(UTC).strftime("%Y-%m-%d")
diff --git a/apps/crawler/src/ma_http.py b/apps/crawler/src/ma_http.py
index 1319469..d603fdc 100644
--- a/apps/crawler/src/ma_http.py
+++ b/apps/crawler/src/ma_http.py
@@ -11,11 +11,11 @@ import logging
import re
import time
from html import unescape as html_unescape
-from typing import Any, Dict, Optional
+from typing import Any
from urllib.parse import urlencode
-from .config import MA_BASE, AJAX_PAGE_SIZE, LIST_MIN_DELAY, LIST_MAX_DELAY
-from .flaresolverr import FlareSolverr, FlareSolverrError
+from .config import AJAX_PAGE_SIZE, LIST_MAX_DELAY, LIST_MIN_DELAY, MA_BASE
+from .flaresolverr import FlareSolverr
from .polite import sleep_range
log = logging.getLogger(__name__)
@@ -27,7 +27,7 @@ _SESSION_TTL = 3600 * 4 # refresh session toutes les 4h
class MASession:
def __init__(self, fs: FlareSolverr):
self.fs = fs
- self._session_id: Optional[str] = None
+ self._session_id: str | None = None
self._session_age = 0.0
# ------------------------------------------------------------------
@@ -47,7 +47,7 @@ class MASession:
log.info(f"[ma_http] warmup done (status={status}), session ready")
self._session_age = time.time()
- def get_json(self, url: str, params: Dict = None, retries: int = 2) -> Any:
+ def get_json(self, url: str, params: dict = None, retries: int = 2) -> Any:
"""GET JSON depuis un endpoint AJAX MA."""
self.ensure_session()
full_url = _build_url(url, params)
@@ -68,7 +68,9 @@ class MASession:
self.ensure_session(force_refresh=True)
sleep_range(LIST_MIN_DELAY * 2, LIST_MAX_DELAY * 2)
continue
- raise RuntimeError(f"JSON parse error après {retries} tentatives sur {url}: {e}")
+ raise RuntimeError(
+ f"JSON parse error après {retries} tentatives sur {url}: {e}"
+ ) from e
raise RuntimeError(f"Failed after {retries} retries: {url}")
def get_html(self, url: str, retries: int = 2) -> str:
@@ -134,7 +136,7 @@ class MASession:
sleep_range(LIST_MIN_DELAY, LIST_MAX_DELAY)
- def fetch_archive_bands(self, order_by: str, since_date: Optional[str] = None):
+ def fetch_archive_bands(self, order_by: str, since_date: str | None = None):
"""
Pagine l'endpoint AJAX des archives (latest additions ou latest modified).
order_by : 'created' | 'modified'
@@ -187,7 +189,7 @@ class MASession:
# Helpers internes
# ------------------------------------------------------------------
-def _build_url(base: str, params: Dict = None) -> str:
+def _build_url(base: str, params: dict = None) -> str:
if not params:
return base
return f"{base}?{urlencode(params)}"
@@ -215,7 +217,7 @@ def _extract_link(html_str: str):
return href, text
-def _extract_ma_id(url: str) -> Optional[int]:
+def _extract_ma_id(url: str) -> int | None:
m = re.search(r"/bands/[^/]+/(\d+)", url or "")
return int(m.group(1)) if m else None
@@ -224,7 +226,7 @@ def _clean(s) -> str:
return re.sub(r"<[^>]+>", "", str(s or "")).strip()
-def _parse_list_row(row, country_code: str) -> Optional[Dict]:
+def _parse_list_row(row, country_code: str) -> dict | None:
"""Ligne du listing pays : [name_html, genre, location, status]"""
if len(row) < 3:
return None
@@ -243,7 +245,7 @@ def _parse_list_row(row, country_code: str) -> Optional[Dict]:
}
-def _parse_archive_row(row, order_by: str) -> Optional[Dict]:
+def _parse_archive_row(row, order_by: str) -> dict | None:
"""
Ligne des archives MA (6 colonnes) :
[date_label, band_link, country_link, genre, time_label, user_link]
diff --git a/apps/crawler/src/main.py b/apps/crawler/src/main.py
index dadc431..5e612ee 100644
--- a/apps/crawler/src/main.py
+++ b/apps/crawler/src/main.py
@@ -16,21 +16,23 @@ Variables d'env :
CRAWLER_FULL_CRAWL_INTERVAL_DAYS (défaut: 60, mettre 0 pour désactiver)
"""
import logging
-import os
import sys
import time
-from datetime import datetime, timezone
+from datetime import UTC, datetime
import schedule
from .config import (
- FLARESOLVERR_URL, FS_TIMEOUT_MS,
- SCHED_INCREMENTAL_H, SCHED_ENRICH_H, FULL_CRAWL_INTERVAL_DAYS,
ENRICH_LIMIT,
+ FLARESOLVERR_URL,
+ FS_TIMEOUT_MS,
+ FULL_CRAWL_INTERVAL_DAYS,
+ SCHED_ENRICH_H,
+ SCHED_INCREMENTAL_H,
)
-from .flaresolverr import FlareSolverr, FlareSolverrError
from .db import claim_job_trigger, finish_job_trigger, get_checkpoint
-from .jobs import run_full_crawl, run_incremental, run_enrich
+from .flaresolverr import FlareSolverr
+from .jobs import run_enrich, run_full_crawl, run_incremental
from .ma_http import MASession
logging.basicConfig(
@@ -79,8 +81,8 @@ def main():
last = get_checkpoint("last_full_crawl_at")
if last:
try:
- last_dt = datetime.strptime(last, "%Y-%m-%d").replace(tzinfo=timezone.utc)
- elapsed_days = (datetime.now(timezone.utc) - last_dt).days
+ last_dt = datetime.strptime(last, "%Y-%m-%d").replace(tzinfo=UTC)
+ elapsed_days = (datetime.now(UTC) - last_dt).days
if elapsed_days < FULL_CRAWL_INTERVAL_DAYS:
return
except ValueError:
diff --git a/apps/crawler/src/scraper_band.py b/apps/crawler/src/scraper_band.py
index 70a1b46..c321df8 100644
--- a/apps/crawler/src/scraper_band.py
+++ b/apps/crawler/src/scraper_band.py
@@ -6,7 +6,7 @@ le schéma band_page historique stocké en DB (lineup structuré, discography_ur
"""
import hashlib
import re
-from typing import Any, Dict, List, Optional
+from typing import Any
from bs4 import BeautifulSoup
@@ -19,16 +19,16 @@ _LINEUP_SECTIONS = {
}
-def parse_band_page(html: str) -> Dict[str, Any]:
+def parse_band_page(html: str) -> dict[str, Any]:
soup = BeautifulSoup(html, "lxml")
- out: Dict[str, Any] = {}
+ out: dict[str, Any] = {}
h1 = soup.find("h1", class_=re.compile(r"band_name", re.I)) or soup.find("h1")
out["name"] = _text(h1)
# --- band_stats : dt/dd pairs ---
stats_div = soup.find("div", id="band_stats") or soup.find("div", id="band_info") or soup
- kv: Dict[str, str] = {}
+ kv: dict[str, str] = {}
for dl in stats_div.find_all("dl"):
for dt in dl.find_all("dt"):
dd = dt.find_next_sibling("dd")
@@ -50,7 +50,7 @@ def parse_band_page(html: str) -> Dict[str, Any]:
out["label"] = _pick(kv, "Current label", "Last label", "Label")
# --- Lineup (grouped by section, with artist URLs) ---
- lineup: Dict[str, List[Dict]] = {"current": [], "past": [], "live": [], "session": []}
+ lineup: dict[str, list[dict]] = {"current": [], "past": [], "live": [], "session": []}
for table in soup.find_all("table", class_="lineupTable"):
section_key = _lineup_section(table)
bucket = lineup.setdefault(section_key, [])
@@ -104,7 +104,7 @@ def _text(el) -> str:
return (el.get_text(" ", strip=True) if el else "").strip()
-def _pick(kv: Dict, *keys: str) -> Optional[str]:
+def _pick(kv: dict, *keys: str) -> str | None:
for k in keys:
if k in kv:
return kv[k]
diff --git a/apps/crawler/tests/conftest.py b/apps/crawler/tests/conftest.py
new file mode 100644
index 0000000..cb6fa16
--- /dev/null
+++ b/apps/crawler/tests/conftest.py
@@ -0,0 +1,12 @@
+import os
+import sys
+from pathlib import Path
+
+# Le crawler s'exécute en `python -m src.main` depuis /app : les tests doivent
+# voir la même racine pour que les imports relatifs (`from .db import ...`)
+# fonctionnent à l'identique.
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+# db.py lit DATABASE_URL à l'import via config.py. Aucune connexion n'est
+# ouverte tant qu'on n'appelle pas get_conn(), que les tests remplacent.
+os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test")
diff --git a/apps/crawler/tests/test_cancellation.py b/apps/crawler/tests/test_cancellation.py
new file mode 100644
index 0000000..dc5178c
--- /dev/null
+++ b/apps/crawler/tests/test_cancellation.py
@@ -0,0 +1,172 @@
+"""
+Annulation coopérative des crawl_run.
+
+C'est la moitié qui fait le travail réel : l'API ne fait que poser un drapeau
+(voir apps/api/test/cancellation.test.js), c'est le crawler qui doit l'observer
+et s'arrêter. Sans ces tests, le bouton « Annuler » pourrait redevenir
+cosmétique sans que rien ne le signale.
+
+Aucune connexion Postgres : `get_conn` est remplacé par un faux curseur.
+"""
+
+import contextlib
+from unittest.mock import patch
+
+import pytest
+from src import db
+from src.db import RunCancelled, is_cancel_requested, raise_if_cancelled
+
+
+class FakeCursor:
+ """Curseur minimal qui rejoue une file de résultats et compte les requêtes."""
+
+ def __init__(self, results, fail=False):
+ self._results = list(results)
+ self.queries = []
+ self.fail = fail
+
+ def execute(self, sql, params=None):
+ if self.fail:
+ raise RuntimeError("base injoignable")
+ self.queries.append((sql, params))
+
+ def fetchone(self):
+ return self._results.pop(0) if self._results else None
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+
+def fake_conn(cursor):
+ @contextlib.contextmanager
+ def _get_conn():
+ class Conn:
+ def cursor(self_inner):
+ return cursor
+
+ yield Conn()
+
+ return _get_conn
+
+
+@pytest.fixture(autouse=True)
+def _clear_cache():
+ """Le drapeau est mis en cache par run_id : repartir propre à chaque test."""
+ db._cancel_cache.clear()
+ yield
+ db._cancel_cache.clear()
+
+
+class TestIsCancelRequested:
+ def test_false_quand_le_drapeau_est_baisse(self):
+ cur = FakeCursor([(False,)])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ assert is_cancel_requested(1) is False
+
+ def test_true_quand_le_drapeau_est_leve(self):
+ cur = FakeCursor([(True,)])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ assert is_cancel_requested(1) is True
+
+ def test_false_quand_le_run_nexiste_pas(self):
+ cur = FakeCursor([])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ assert is_cancel_requested(999) is False
+
+ def test_le_resultat_est_mis_en_cache(self):
+ """Le check est appelé à chaque lot : il ne doit pas faire une requête par appel."""
+ cur = FakeCursor([(False,), (False,), (False,)])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ for _ in range(5):
+ is_cancel_requested(1)
+ assert len(cur.queries) == 1
+
+ def test_force_contourne_le_cache(self):
+ cur = FakeCursor([(False,), (True,)])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ assert is_cancel_requested(1) is False
+ assert is_cancel_requested(1, force=True) is True
+ assert len(cur.queries) == 2
+
+ def test_le_cache_est_par_run(self):
+ cur = FakeCursor([(True,), (False,)])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ assert is_cancel_requested(1) is True
+ assert is_cancel_requested(2) is False
+
+ # Une base momentanément injoignable ne doit PAS faire passer un crawl
+ # sain pour un crawl annulé.
+ def test_une_erreur_db_ne_declenche_pas_dannulation(self):
+ cur = FakeCursor([], fail=True)
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ assert is_cancel_requested(1) is False
+
+ def test_une_erreur_db_conserve_la_derniere_valeur_connue(self):
+ ok = FakeCursor([(True,)])
+ with patch.object(db, "get_conn", fake_conn(ok)):
+ assert is_cancel_requested(1) is True
+
+ db._cancel_cache[1] = (0.0, True) # cache expiré, valeur connue = True
+ ko = FakeCursor([], fail=True)
+ with patch.object(db, "get_conn", fake_conn(ko)):
+ assert is_cancel_requested(1) is True
+
+ def test_interroge_bien_la_colonne_cancel_requested(self):
+ cur = FakeCursor([(False,)])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ is_cancel_requested(42)
+ sql, params = cur.queries[0]
+ assert "cancel_requested" in sql
+ assert "crawl_run" in sql
+ assert params == (42,)
+
+
+class TestRaiseIfCancelled:
+ def test_ne_leve_pas_quand_le_drapeau_est_baisse(self):
+ cur = FakeCursor([(False,)])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ raise_if_cancelled(1) # ne doit pas lever
+
+ def test_leve_RunCancelled_quand_le_drapeau_est_leve(self):
+ cur = FakeCursor([(True,)])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ with pytest.raises(RunCancelled):
+ raise_if_cancelled(1)
+
+ # RunCancelled doit rester attrapable par les `except Exception` existants
+ # tout en étant distinguable — d'où l'ordre des clauses dans jobs.py.
+ def test_RunCancelled_derive_de_Exception(self):
+ assert issubclass(RunCancelled, Exception)
+
+
+class TestFinishCrawlRun:
+ def _run_finish(self, **kwargs):
+ cur = FakeCursor([])
+ with patch.object(db, "get_conn", fake_conn(cur)):
+ db.finish_crawl_run(1, {"seen": 5}, **kwargs)
+ return cur.queries[0]
+
+ def test_statut_done_par_defaut(self):
+ _sql, params = self._run_finish()
+ assert params[0] == "done"
+
+ def test_statut_error_avec_une_erreur(self):
+ _sql, params = self._run_finish(error="boom")
+ assert params[0] == "error"
+
+ # Un run annulé n'est pas un échec : il ne doit pas remonter dans les erreurs.
+ def test_statut_cancelled_quand_annule(self):
+ _sql, params = self._run_finish(cancelled=True)
+ assert params[0] == "cancelled"
+
+ def test_cancelled_prime_sur_error(self):
+ _sql, params = self._run_finish(error="interrompu", cancelled=True)
+ assert params[0] == "cancelled"
+
+ def test_le_cache_dannulation_est_purge(self):
+ db._cancel_cache[1] = (999.0, True)
+ self._run_finish(cancelled=True)
+ assert 1 not in db._cancel_cache
diff --git a/apps/geocoder/.dockerignore b/apps/geocoder/.dockerignore
new file mode 100644
index 0000000..5844e26
--- /dev/null
+++ b/apps/geocoder/.dockerignore
@@ -0,0 +1,9 @@
+# Le Dockerfile du geocoder fait `COPY . .` : sans ce fichier, tout ce qui
+# traîne dans apps/geocoder/ (caches, tests, venv) part dans l'image.
+__pycache__/
+*.pyc
+*.pyo
+.pytest_cache/
+.ruff_cache/
+.venv/
+tests/
diff --git a/apps/geocoder/src/enqueue.py b/apps/geocoder/src/enqueue.py
index 9f5c1b2..179ad50 100644
--- a/apps/geocoder/src/enqueue.py
+++ b/apps/geocoder/src/enqueue.py
@@ -18,12 +18,12 @@ Tourne en continu (restart: unless-stopped), poll toutes les 15s.
import os
import time
-import psycopg2
+import psycopg2
from parser import (
- parse_location_text,
- country_centroid,
COUNTRY_NAME_TO_ISO2,
+ country_centroid,
+ parse_location_text,
)
POLL_INTERVAL = int(os.environ.get("GEOCODE_ENQUEUE_POLL", "15"))
diff --git a/apps/geocoder/src/groq_worker.py b/apps/geocoder/src/groq_worker.py
index 81463c1..4f13a08 100644
--- a/apps/geocoder/src/groq_worker.py
+++ b/apps/geocoder/src/groq_worker.py
@@ -15,15 +15,16 @@ Rate limits Groq free tier :
llama-3.1-8b-instant : 30 req/min, 14 400 req/jour (fallback)
"""
+import hashlib
+import json
import os
+import re
import sys
import time
-import json
-import hashlib
-import re
-import requests
+from datetime import UTC, datetime
+
import psycopg2
-from datetime import datetime, timezone
+import requests
# sys.path[0] = src/ quand lancé comme "python src/groq_worker.py"
from parser import COUNTRY_NAMES
@@ -155,7 +156,7 @@ def main():
day_count = {m[0]: 0 for m in MODELS}
min_count = {m[0]: 0 for m in MODELS}
min_start = time.monotonic()
- day_start = datetime.now(timezone.utc).date()
+ day_start = datetime.now(UTC).date()
with conn.cursor() as cur:
while True:
@@ -163,7 +164,7 @@ def main():
if time.monotonic() - min_start >= 60:
min_count = {m[0]: 0 for m in MODELS}
min_start = time.monotonic()
- today = datetime.now(timezone.utc).date()
+ today = datetime.now(UTC).date()
if today != day_start:
day_count = {m[0]: 0 for m in MODELS}
day_start = today
diff --git a/apps/geocoder/src/worker.py b/apps/geocoder/src/worker.py
index bec2e80..8ca1214 100644
--- a/apps/geocoder/src/worker.py
+++ b/apps/geocoder/src/worker.py
@@ -14,13 +14,13 @@ Stratégie :
4. Succès → band_locations + sync bands.lat/lon (origine).
"""
-import os
-import time
-import random
import json
-import requests
-import psycopg2
+import os
+import random
+import time
+import psycopg2
+import requests
from parser import build_fallback_queries
GEOAPIFY_API_KEY = os.environ.get("GEOAPIFY_API_KEY", "").strip()
diff --git a/apps/geocoder/tests/test_parser.py b/apps/geocoder/tests/test_parser.py
new file mode 100644
index 0000000..e359f80
--- /dev/null
+++ b/apps/geocoder/tests/test_parser.py
@@ -0,0 +1,129 @@
+"""
+Tests du parseur de localisations Metal Archives.
+
+Aucun accès réseau ni base : `parser.py` n'a que des fonctions pures, ce qui en
+fait le module le plus rentable à couvrir de tout le pipeline de géocodage —
+c'est lui qui décide combien de lignes `band_locations` sont créées, et donc
+combien d'appels Geoapify/Groq (payants) seront déclenchés.
+"""
+
+import pytest
+from src.parser import (
+ _classify,
+ build_fallback_queries,
+ country_centroid,
+ parse_location_text,
+)
+
+
+class TestClassify:
+ @pytest.mark.parametrize("value", ["N/A", "n/a", "", " "])
+ def test_valeurs_vides_ignorees(self, value):
+ assert _classify(value) == "skip"
+
+ @pytest.mark.parametrize("value", ["FR", "DE", "NO"])
+ def test_codes_iso2(self, value):
+ assert _classify(value) == "country_code"
+
+ def test_ville_normale(self):
+ assert _classify("Oslo") == "parseable"
+
+
+class TestParseLocationText:
+ def test_texte_vide_ne_produit_aucune_ligne(self):
+ assert parse_location_text("") == []
+ assert parse_location_text("N/A") == []
+
+ def test_ville_simple(self):
+ rows = parse_location_text("Oslo")
+ assert len(rows) == 1
+ assert rows[0]["location_raw"] == "Oslo"
+ assert rows[0]["is_country_only"] is False
+ assert rows[0]["step_order"] == 0
+
+ def test_ville_avec_region(self):
+ rows = parse_location_text("Bergen, Hordaland")
+ assert [r["location_raw"] for r in rows] == ["Bergen, Hordaland"]
+
+ def test_code_pays_seul_marque_country_only(self):
+ rows = parse_location_text("FR")
+ assert len(rows) == 1
+ assert rows[0]["is_country_only"] is True
+ assert rows[0]["location_raw"] == "FR"
+
+ def test_code_pays_minuscule_non_reconnu(self):
+ """Comportement actuel documenté, pas une validation.
+
+ `_ISO2_RE` vaut `^[A-Z]{2}$` : un code pays en minuscules n'est PAS
+ reconnu comme pays et repart en géocodage comme s'il s'agissait d'une
+ ville, ce qui consomme un appel Geoapify pour un résultat au mieux
+ douteux. Metal Archives renvoie les codes en majuscules, donc le cas ne
+ se produit pas en pratique aujourd'hui — mais rien ne le garantit.
+ Si `_ISO2_RE` devient insensible à la casse, ce test doit être inversé.
+ """
+ rows = parse_location_text("fr")
+ assert rows[0]["is_country_only"] is False
+
+ def test_plusieurs_villes_separees(self):
+ """Une même étape peut lister plusieurs villes : chacune devient une ligne."""
+ rows = parse_location_text("Oslo/Bergen")
+ assert len(rows) >= 2
+ assert {r["location_raw"] for r in rows} >= {"Oslo", "Bergen"}
+ # Même étape → même step_order
+ assert len({r["step_order"] for r in rows}) == 1
+
+ def test_etapes_successives_incrementent_step_order(self):
+ rows = parse_location_text("Oslo (early), Bergen (later)")
+ orders = [r["step_order"] for r in rows]
+ assert orders == sorted(orders)
+
+ def test_les_valeurs_ignorables_ne_creent_pas_de_ligne(self):
+ rows = parse_location_text("N/A")
+ assert rows == []
+
+ def test_resultat_toujours_serialisable(self):
+ """Chaque ligne doit exposer exactement les clés attendues par db.py."""
+ for row in parse_location_text("Trondheim (1991-1993), Oslo"):
+ assert set(row) == {"step_order", "step_label", "location_raw", "is_country_only"}
+ assert isinstance(row["step_order"], int)
+ assert isinstance(row["is_country_only"], bool)
+
+
+class TestCountryCentroid:
+ def test_code_iso2_connu(self):
+ lat, lon = country_centroid("FR")
+ assert 41 < lat < 52
+ assert -6 < lon < 10
+
+ def test_insensible_a_la_casse_et_aux_espaces(self):
+ assert country_centroid(" fr ") == country_centroid("FR")
+
+ def test_code_inconnu_renvoie_none(self):
+ assert country_centroid("ZZ") is None
+
+
+class TestBuildFallbackQueries:
+ def test_du_plus_specifique_au_plus_vague(self):
+ queries = build_fallback_queries("Bergen, Hordaland", "NO")
+ assert queries[0].startswith("Bergen, Hordaland")
+ assert queries[-1] == "Bergen"
+
+ def test_aucun_doublon(self):
+ queries = build_fallback_queries("Oslo", "NO")
+ assert len(queries) == len(set(queries))
+
+ def test_le_pays_est_injecte_comme_contexte(self):
+ queries = build_fallback_queries("Bergen", "NO")
+ assert any("Norway" in q for q in queries)
+
+ def test_pays_deja_present_non_duplique(self):
+ queries = build_fallback_queries("Bergen, Norway", "NO")
+ assert not any(q.count("Norway") > 1 for q in queries)
+
+ def test_pays_inconnu_ne_plante_pas(self):
+ assert build_fallback_queries("Bergen", "ZZ") == ["Bergen"]
+ assert build_fallback_queries("Bergen", None) == ["Bergen"]
+
+ def test_toujours_au_moins_une_requete(self):
+ for raw in ["Oslo", "A, B, C, D", "Saint-Étienne"]:
+ assert len(build_fallback_queries(raw, "FR")) >= 1
diff --git a/apps/web/quizz-site/axolotl.jpeg b/apps/web/quizz-site/axolotl.jpeg
deleted file mode 100644
index 3dc14d8..0000000
Binary files a/apps/web/quizz-site/axolotl.jpeg and /dev/null differ
diff --git a/apps/web/quizz-site/echidne.jpeg b/apps/web/quizz-site/echidne.jpeg
deleted file mode 100644
index 7d91d6a..0000000
Binary files a/apps/web/quizz-site/echidne.jpeg and /dev/null differ
diff --git a/apps/web/quizz-site/index.html b/apps/web/quizz-site/index.html
deleted file mode 100644
index 3716d3c..0000000
--- a/apps/web/quizz-site/index.html
+++ /dev/null
@@ -1,696 +0,0 @@
-
-
-
-
-
-✨ Le Quiz des Équipes
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Suivant →
-
-
-
-
-
-
-
-
-
-
Bienvenue dans l'équipe
-
-
-
-
← Recommencer
-
-
-
-
-
-
-
-
diff --git a/apps/web/quizz-site/poule-de-soie.jpeg b/apps/web/quizz-site/poule-de-soie.jpeg
deleted file mode 100644
index 8907821..0000000
Binary files a/apps/web/quizz-site/poule-de-soie.jpeg and /dev/null differ
diff --git a/apps/web/quizz-site/ratel.jpeg b/apps/web/quizz-site/ratel.jpeg
deleted file mode 100644
index 74285a7..0000000
Binary files a/apps/web/quizz-site/ratel.jpeg and /dev/null differ
diff --git a/apps/web/quizz-site/saiga.jpeg b/apps/web/quizz-site/saiga.jpeg
deleted file mode 100644
index 783c2e5..0000000
Binary files a/apps/web/quizz-site/saiga.jpeg and /dev/null differ
diff --git a/apps/web/site/app.js b/apps/web/site/app.js
index d6645d8..fe6b978 100644
--- a/apps/web/site/app.js
+++ b/apps/web/site/app.js
@@ -1,1785 +1,1643 @@
-/* Black Metal Map UI — clusters/heat/timeline/filter/sort/highlight */
-/* Performance-optimized with viewport-based loading */
-
-const API_BASE = "https://bm.nicolasfryder.ovh";
-
-// --- i18n ---
-const SUPPORTED_LANGS = ["fr", "en", "de", "es", "it", "pl", "nl", "ro", "pt", "cs", "sv", "hu", "da", "fi", "sk", "hr", "sl", "lt", "lv", "et", "nb"];
-
-function detectLang() {
- const langs = navigator.languages?.length ? navigator.languages : [navigator.language || "en"];
- for (const l of langs) {
- const code = l.split("-")[0].toLowerCase();
- if (SUPPORTED_LANGS.includes(code)) return code;
- }
- return "en";
-}
-
-let currentLang = localStorage.getItem("lang") || detectLang();
-
-function t(key) {
- return window.LOCALES?.[currentLang]?.[key] ?? window.LOCALES?.["en"]?.[key] ?? key;
-}
-
-function applyI18n() {
- document.documentElement.lang = currentLang;
- document.querySelectorAll("[data-i18n]").forEach(el => {
- el.textContent = t(el.dataset.i18n);
- });
- document.querySelectorAll("[data-i18n-placeholder]").forEach(el => {
- el.placeholder = t(el.dataset.i18nPlaceholder);
- });
- // Sort select options (can't use data-i18n on options in all browsers)
- const sel = document.getElementById("sortSelect");
- if (sel) {
- [["sort_az",0],["sort_status",1],["sort_genre",2],["sort_country",3],["sort_year",4]].forEach(([k,i]) => {
- if (sel.options[i]) sel.options[i].text = t(k);
- });
- }
-}
-const DARK_TILES = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
-const DARK_ATTRIB = '© OpenStreetMap contributors © CARTO';
-
-// --- Configuration ---
-const USE_VIEWPORT_LOADING = true; // Enable viewport-based loading
-const INITIAL_LOAD_LIMIT = 5000; // Initial bands to load for filters
-const VIEWPORT_DEBOUNCE_MS = 300; // Debounce for viewport changes
-
-// --- State ---
-let allBands = []; // all loaded bands (geocoded + non geocoded)
-let geocodedBands = []; // subset lat/lon ok
-let noLocationBands = []; // subset missing coords
-let viewportBands = []; // bands in current viewport
-
-let filtered = [];
-let markersById = new Map(); // ma_id -> marker
-let listElById = new Map(); // ma_id -> element
-let coordIndex = new Map(); // "lat,lon" -> bands[]
-
-let heatLayer = null;
-let clusterLayer = null;
-let currentMode = "clusters";
-
-// All heat points (for full heatmap even when viewing clusters)
-let allHeatPoints = [];
-let heatPointsLoaded = false;
-
-const statusEnabled = new Map();
-const genreEnabled = new Map();
-const countryEnabled = new Map();
-const macroGenreEnabled = new Map();
-const themeEnabled = new Map();
-
-let yearRange = { min: null, max: null }; // global
-let yearFilter = { min: null, max: null }; // current
-
-let macroActiveCount = 0;
-
-// Facet data from server (for filters)
-let facetData = null;
-
-// Loading state
-let isLoadingViewport = false;
-let pendingViewportLoad = false;
-let currentViewportData = [];
-
-// --- Utils ---
-const $ = (id) => document.getElementById(id);
-const appEl = $("app");
-
-async function loadGoatCounterFooterStats() {
- const totalEl = document.getElementById("gcTotal");
- const monthEl = document.getElementById("gcMonth");
- if (!totalEl || !monthEl) return;
-
- // 1er jour du mois courant (ce mois-ci)
- const d = new Date();
- const yyyy = d.getFullYear();
- const mm = String(d.getMonth() + 1).padStart(2, "0");
- const startOfMonth = `${yyyy}-${mm}-01`;
-
- const base = "https://metalfromeurope.goatcounter.com/counter/";
- const totalUrl = `${base}TOTAL.json`;
- const monthUrl = `${base}TOTAL.json?start=${encodeURIComponent(startOfMonth)}`;
-
- try {
- const [t, m] = await Promise.all([
- fetch(totalUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))),
- fetch(monthUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))),
- ]);
-
- // GoatCounter renvoie `count` comme string formatée (séparateurs milliers). :contentReference[oaicite:2]{index=2}
- totalEl.textContent = t.count ?? "—";
- monthEl.textContent = m.count ?? "—";
- } catch (e) {
- console.warn("GoatCounter footer stats failed:", e);
- totalEl.textContent = "—";
- monthEl.textContent = "—";
- }
-}
-
-// Debounce function for search
-let searchDebounceTimer = null;
-function debounce(func, delay) {
- return function(...args) {
- clearTimeout(searchDebounceTimer);
- searchDebounceTimer = setTimeout(() => func.apply(this, args), delay);
- };
-}
-
-function escapeHtml(s) {
- return String(s ?? "").replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
-}
-
-function norm(s) {
- if (s == null || s === undefined || s === "") return "";
- return String(s).trim();
-}
-
-function parseYear(val) {
- if (val == null) return null;
- const n = Number(val);
- if (Number.isFinite(n)) return n;
- const m = String(val).match(/(19|20)\d{2}/);
- return m ? Number(m[0]) : null;
-}
-
-function splitThemes(val) {
- if (!val) return [];
- if (Array.isArray(val)) return val.map(norm).filter(Boolean);
- return String(val).split(/[,;/]/).map(norm).filter(Boolean);
-}
-
-function currentQuery() {
- return norm($("q").value).toLowerCase();
-}
-
-function bandMatchesQuery(b, q) {
- if (!q) return true;
- const themes = Array.isArray(b.themes) ? b.themes.join(" ") : "";
- const hay = `${b.name} ${b.genre} ${b.status} ${b.country} ${b.location_text} ${themes}`.toLowerCase();
- return hay.includes(q);
-}
-
-function bandMatchesStatus(b) {
- const s = norm(b.status);
- if (!s) {
- // If no status, check if "Unknown" is enabled
- if (!statusEnabled.has("Unknown")) return true;
- return statusEnabled.get("Unknown") === true;
- }
- if (!statusEnabled.has(s)) return true;
- return statusEnabled.get(s) === true;
-}
-
-function bandMatchesCountry(b) {
- const c = norm(b.country);
- if (!c) {
- // If no country, check if "??" is enabled
- if (!countryEnabled.has("??")) return true;
- return countryEnabled.get("??") === true;
- }
- if (!countryEnabled.has(c)) return true;
- return countryEnabled.get(c) === true;
-}
-
-function bandMatchesMacroGenre(b) {
- if (macroActiveCount === 0) return true;
- const g = norm(b.genre).toLowerCase();
- if (!g) return false;
- return MACRO_GENRES.some(m => macroGenreEnabled.get(m.key) === true && m.terms.some(t => g.includes(t)));
-}
-
-function bandMatchesGenre(b) {
- if (!bandMatchesMacroGenre(b)) return false;
- const g = norm(b.genre);
- if (!g) {
- // If no genre, check if "Unknown" is enabled
- if (!genreEnabled.has("Unknown")) return true;
- return genreEnabled.get("Unknown") === true;
- }
- if (!genreEnabled.has(g)) return true;
- return genreEnabled.get(g) === true;
-}
-
-function bandMatchesTheme(b) {
- if (!themeEnabled.size) return true;
- const allOn = Array.from(themeEnabled.values()).every(v => v === true);
- if (allOn) return true;
- const tokens = Array.isArray(b.themes) ? b.themes : [];
- if (!tokens.length) return false;
- return tokens.some(t => themeEnabled.get(t) === true);
-}
-
-function bandMatchesYear(b) {
- const y = b.formed_year;
-
- // If no filter is set, pass all
- if (yearFilter.min == null || yearFilter.max == null) return true;
-
- // If yearRange not initialized yet, pass all
- if (yearRange.min == null || yearRange.max == null) return true;
-
- // If filter is at full range, pass all (including those without years)
- const isFullRange = (yearFilter.min === yearRange.min && yearFilter.max === yearRange.max);
-
- if (isFullRange) {
- return true;
- }
-
- // If user has narrowed the range, only show bands with valid years in range
- if (!Number.isFinite(y)) {
- // Band has no year and user has filtered by year -> exclude
- return false;
- }
-
- return y >= yearFilter.min && y <= yearFilter.max;
-}
-
-function keyFromLatLon(lat, lon) {
- const la = Number(lat).toFixed(6);
- const lo = Number(lon).toFixed(6);
- return `${la},${lo}`;
-}
-
-const MACRO_GENRES = [
- { key: "Black", terms: ["black"] },
- { key: "Death", terms: ["death"] },
- { key: "Doom/Stoner/Sludge", terms: ["doom", "stoner", "sludge"] },
- { key: "Electronic/Industrial", terms: ["electronic", "industrial", "electro"] },
- { key: "Experimental/Avant-garde", terms: ["experimental", "avant", "avant-garde", "avantgarde"] },
- { key: "Folk/Viking/Pagan", terms: ["folk", "viking", "pagan"] },
- { key: "Gothic", terms: ["gothic"] },
- { key: "Grindcore", terms: ["grind"] },
- { key: "Groove", terms: ["groove"] },
- { key: "Heavy", terms: ["heavy"] },
- { key: "Metalcore/Deathcore", terms: ["metalcore", "deathcore"] },
- { key: "Power", terms: ["power"] },
- { key: "Progressive", terms: ["progressive", "prog"] },
- { key: "Speed", terms: ["speed"] },
- { key: "Symphonic", terms: ["symphonic"] },
- { key: "Thrash", terms: ["thrash"] },
-];
-
-// --- API ---
-async function apiGet(path) {
- const url = `${API_BASE}${path}`;
- const r = await fetch(url, { mode: "cors" });
- if (!r.ok) throw new Error(`API ${path} failed: ${r.status}`);
- return await r.json();
-}
-
-async function loadStats() {
- // tolérant : si /api/stats n'existe pas encore, on ignore
- try {
- const j = await apiGet("/api/stats");
- if (j && j.ok) {
- $("total").textContent = String(j.total || 0);
- $("geocoded").textContent = String(j.geocoded || 0);
- $("noLocCount").textContent = String(j.no_location || 0);
- }
- } catch (_) {
- // Silently ignore stats errors
- }
-}
-
-/**
- * Load facet data for filters (fast, minimal data)
- */
-async function loadFacets() {
- try {
- const j = await apiGet("/api/facets");
- if (j.ok) {
- facetData = j;
- console.log("Facets loaded:", {
- statuses: j.statuses?.length,
- countries: j.countries?.length,
- genres: j.genres?.length,
- yearRange: j.year_range
- });
- }
- } catch (e) {
- console.warn("Failed to load facets, will compute from bands:", e);
- }
-}
-
-/**
- * Parse band data from API response
- */
-function parseBandData(x) {
- let status = x.status || x.data?.status || x.data?.band_status || x.data?.status_label;
-
- return {
- ma_id: Number(x.ma_id),
- name: norm(x.name),
- url: x.url || (x.data?.url) || (x.data?.band_page?.url) || null,
- country: norm(x.country || x.data?.country),
- status: norm(status),
- genre: norm(x.genre || x.data?.genre || x.data?.genres || x.data?.style),
- themes: splitThemes(x.themes || x.data?.themes || x.data?.theme || x.data?.thematic),
- location_text: norm(x.location_text || x.data?.location_text || x.data?.location),
- formed_year: parseYear(x.formed_year ?? x.data?.formed_year ?? x.data?.formed ?? x.data?.formation_year ?? x.data?.year_formed),
- lat: (x.lat == null ? null : Number(x.lat)),
- lon: (x.lon == null ? null : Number(x.lon)),
- // Localisation précise (band_locations) : présent quand le point vient
- // d'un step géocodé plutôt que du point unique du groupe.
- location_raw: x.location_raw != null ? norm(x.location_raw) : null,
- step_label: x.step_label != null ? norm(x.step_label) : null,
- data: x.data || {},
- geocoded_at: x.geocoded_at || null,
- };
-}
-
-/**
- * Load initial sample of bands for quick startup
- */
-async function loadBands() {
- // Load a limited set initially for faster startup
- const limit = USE_VIEWPORT_LOADING ? INITIAL_LOAD_LIMIT : 200000;
- const j = await apiGet(`/api/bands?limit=${limit}`);
- const items = j.items || j.bands || [];
-
- console.log(`Loaded ${items.length} bands initially`);
-
- allBands = items.map(parseBandData);
-
- geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon));
- noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon)));
-
- // Update counts
- $("total").textContent = String(allBands.length);
- $("geocoded").textContent = String(geocodedBands.length);
- $("noLocCount").textContent = String(noLocationBands.length);
-}
-
-/**
- * Load all heat points for the full heatmap (done once in background)
- */
-async function loadAllHeatPoints() {
- if (heatPointsLoaded) return;
-
- try {
- console.log("Loading all bands for heatmap...");
- const j = await apiGet("/api/bands?limit=200000");
- const items = j.items || [];
-
- allHeatPoints = [];
-
- // If allBands is empty, populate it
- if (!allBands.length) {
- allBands = items.map(parseBandData);
- geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon));
- noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon)));
- }
-
- // Index by coords for heatmap intensity
- const coordCounts = new Map();
-
- for (const x of items) {
- const lat = x.lat == null ? null : Number(x.lat);
- const lon = x.lon == null ? null : Number(x.lon);
-
- if (Number.isFinite(lat) && Number.isFinite(lon)) {
- const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
- coordCounts.set(key, (coordCounts.get(key) || 0) + 1);
- }
- }
-
- // Convert to heat points
- for (const [key, count] of coordCounts) {
- const [lat, lon] = key.split(",").map(Number);
- allHeatPoints.push([lat, lon, count]);
- }
-
- heatPointsLoaded = true;
- console.log(`Loaded ${allHeatPoints.length} unique heat points from ${items.length} bands`);
-
- // Update stats
- $("total").textContent = String(items.length);
- $("geocoded").textContent = String(geocodedBands.length);
- $("noLocCount").textContent = String(noLocationBands.length);
-
- // Rebuild heatmap if in heat mode
- if (currentMode === "heat") {
- buildHeatLayer(allHeatPoints);
- }
- } catch (e) {
- console.error("Failed to load heat points:", e);
- }
-}
-
-/**
- * Load bands for current viewport from server
- */
-async function loadViewportBands() {
- if (!USE_VIEWPORT_LOADING) return;
- if (isLoadingViewport) {
- pendingViewportLoad = true;
- return;
- }
-
- isLoadingViewport = true;
-
- try {
- const bounds = map.getBounds();
- const zoom = map.getZoom();
-
- // Expand bounds slightly to preload edges
- const expandFactor = 0.2;
- const latDiff = (bounds.getNorth() - bounds.getSouth()) * expandFactor;
- const lonDiff = (bounds.getEast() - bounds.getWest()) * expandFactor;
-
- const bbox = [
- bounds.getWest() - lonDiff,
- bounds.getSouth() - latDiff,
- bounds.getEast() + lonDiff,
- bounds.getNorth() + latDiff
- ].join(",");
-
- // Build query params for filters
- const params = new URLSearchParams({
- bbox,
- zoom: String(Math.floor(zoom))
- });
-
- // Add active filters
- const activeStatuses = Array.from(statusEnabled.entries())
- .filter(([k, v]) => v)
- .map(([k]) => k);
- if (activeStatuses.length && activeStatuses.length < statusEnabled.size) {
- params.set("status", activeStatuses.join(","));
- }
-
- const activeCountries = Array.from(countryEnabled.entries())
- .filter(([k, v]) => v)
- .map(([k]) => k);
- if (activeCountries.length && activeCountries.length < countryEnabled.size) {
- params.set("countries", activeCountries.join(","));
- }
-
- if (yearFilter.min != null && yearFilter.max != null) {
- if (yearFilter.min !== yearRange.min) params.set("year_min", String(yearFilter.min));
- if (yearFilter.max !== yearRange.max) params.set("year_max", String(yearFilter.max));
- }
-
- // Add genre filter if macro genres are active
- if (macroActiveCount > 0) {
- const activeTerms = MACRO_GENRES
- .filter(m => macroGenreEnabled.get(m.key) === true)
- .flatMap(m => m.terms);
- if (activeTerms.length) {
- params.set("genre", activeTerms[0]); // API supports single genre filter
- }
- }
-
- const url = `/api/clusters?${params.toString()}`;
- console.log("Loading viewport:", url);
-
- const j = await apiGet(url);
-
- if (j.ok) {
- currentViewportData = j.items || [];
-
- if (j.type === "bands") {
- // High zoom: got individual bands
- viewportBands = currentViewportData.map(parseBandData);
- rebuildLayersFromBands(viewportBands);
- } else {
- // Low zoom: got clusters
- rebuildLayersFromClusters(currentViewportData);
- }
-
- $("count").textContent = String(j.count || currentViewportData.length || 0);
- }
- } catch (e) {
- console.error("Failed to load viewport bands:", e);
- } finally {
- isLoadingViewport = false;
-
- if (pendingViewportLoad) {
- pendingViewportLoad = false;
- setTimeout(loadViewportBands, 100);
- }
- }
-}
-
-/**
- * Create a cluster marker (zoomable, not final location)
- * These have a dashed border to indicate they can be zoomed
- * Burgundy/dark red color, always white text
- */
-function createClusterMarker(lat, lon, count, bands) {
- // Size based on count (logarithmic scale)
- const size = Math.min(24 + Math.log2(count + 1) * 8, 55);
-
- // Constant burgundy/dark red color for all clusters
- const color = 'rgba(120, 40, 60, 0.92)';
- const borderColor = 'rgba(180, 80, 100, 0.9)';
- const textColor = '#fff';
-
- // Create custom div icon with count - dashed border indicates "zoomable"
- const icon = L.divIcon({
- className: 'cluster-marker cluster-zoomable',
- html: `${count > 999 ? Math.round(count/1000) + 'k' : count}
`,
- iconSize: [size, size],
- iconAnchor: [size/2, size/2]
- });
-
- const marker = L.marker([lat, lon], { icon });
-
- // Store data for click handling
- marker.__bands = bands;
- marker.__count = count;
- marker.__isCluster = true; // Flag to identify as zoomable cluster
-
- // Click handler - always zoom for clusters
- marker.on("click", (e) => {
- L.DomEvent.stopPropagation(e);
- map.setView([lat, lon], map.getZoom() + 2, { animate: true });
- });
-
- return marker;
-}
-
-/**
- * Create a location marker (final level - shows popup with all bands)
- * These have a solid border and bright red color to indicate they show details
- */
-function createLocationMarker(lat, lon, count, bands, locationName) {
- // Size based on count (smaller than clusters)
- const size = Math.min(18 + Math.log2(count + 1) * 6, 42);
-
- // Bright red color for final locations
- let color, borderColor;
- if (count === 1) {
- color = 'rgba(180, 30, 30, 0.9)';
- borderColor = 'rgba(255, 100, 100, 0.9)';
- } else if (count < 5) {
- color = 'rgba(198, 26, 26, 0.9)';
- borderColor = 'rgba(255, 120, 120, 0.9)';
- } else if (count < 20) {
- color = 'rgba(210, 50, 50, 0.9)';
- borderColor = 'rgba(255, 150, 150, 0.9)';
- } else {
- color = 'rgba(220, 80, 80, 0.9)';
- borderColor = 'rgba(255, 180, 180, 0.95)';
- }
-
- // Create custom div icon with count - solid border indicates "clickable for details"
- const icon = L.divIcon({
- className: 'cluster-marker cluster-location',
- html: `${count}
`,
- iconSize: [size, size],
- iconAnchor: [size/2, size/2]
- });
-
- const marker = L.marker([lat, lon], { icon });
-
- // Store data
- marker.__bands = bands;
- marker.__count = count;
- marker.__isCluster = false; // Flag to identify as final location
- marker.__locationName = locationName;
-
- // Build popup HTML with ALL bands (scrollable)
- const bandListHtml = bands.map(b => {
- const maUrl = b.url || (b.ma_id ? `https://www.metal-archives.com/bands/x/${b.ma_id}` : null);
- const genre = b.genre || '—';
- const status = b.status || '—';
- const year = Number.isFinite(b.formed_year) ? b.formed_year : '—';
-
- if (maUrl) {
- return `
- ${escapeHtml(b.name || 'Unknown')}
- ${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}
- `;
- }
- return `
-
${escapeHtml(b.name || 'Unknown')}
-
${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}
-
`;
- }).join("");
-
- const popupHtml = `
-
-
- 📍 ${escapeHtml(locationName || t('location_fallback'))}
- ${count} ${count === 1 ? t('group_s') : t('group_p')}
-
-
- ${bandListHtml}
-
-
- `;
-
- // Bind popup
- marker.bindPopup(popupHtml, {
- maxWidth: 380,
- maxHeight: 400,
- className: 'location-popup'
- });
-
- // Click handler - update sidebar list AND open popup
- marker.on("click", (e) => {
- L.DomEvent.stopPropagation(e);
- const gLabel = count === 1 ? t('group_s') : t('group_p');
- const hint = locationName ? `${count} ${gLabel} — ${locationName}` : `${count} ${gLabel}`;
- renderList(bands, hint);
- // Small delay to ensure popup opens correctly
- setTimeout(() => marker.openPopup(), 10);
- });
-
- return marker;
-}
-
-/**
- * Check if all bands share the same location_text
- */
-function bandsShareLocation(bands) {
- if (!bands || bands.length === 0) return false;
- if (bands.length === 1) return true;
-
- const firstLoc = norm(bands[0]?.location_text || '');
- if (!firstLoc) return false;
-
- return bands.every(b => norm(b?.location_text || '') === firstLoc);
-}
-
-/**
- * Rebuild layers from cluster data (server-side aggregated)
- */
-function rebuildLayersFromClusters(clusters) {
- markersById.clear();
- coordIndex.clear();
- clusterLayer.clearLayers();
-
- for (const cluster of clusters) {
- const { lat, lon, count, sample_bands, location_text } = cluster;
-
- if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
-
- // Parse sample bands
- const bands = (sample_bands || []).map(b => {
- if (typeof b === 'string') {
- try { return JSON.parse(b); } catch { return null; }
- }
- return b;
- }).filter(Boolean);
-
- const bandCount = count || bands.length;
-
- // Check if all bands share the same location
- const allSameLocation = bandsShareLocation(bands);
- const locationName = allSameLocation ? (location_text || bands[0]?.location_text || null) : null;
-
- // Use location marker (popup) if all bands share same location
- // Use cluster marker (zoom) if bands come from different locations
- const useLocationMarker = allSameLocation;
-
- const marker = useLocationMarker
- ? createLocationMarker(lat, lon, bandCount, bands, locationName)
- : createClusterMarker(lat, lon, bandCount, bands);
-
- clusterLayer.addLayer(marker);
-
- // Store reference for each band in cluster
- for (const b of bands) {
- if (b.ma_id) markersById.set(b.ma_id, marker);
- }
- }
-
- if (currentMode === "clusters") {
- if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
- }
-
- $("unique").textContent = String(clusters.length);
-
- // Render list with visible bands
- const visibleBands = clusters.flatMap(c => c.sample_bands || [])
- .map(b => typeof b === 'string' ? JSON.parse(b) : b)
- .filter(Boolean);
- renderList(visibleBands.slice(0, 200));
-}
-
-/**
- * Rebuild layers from individual band data
- */
-function rebuildLayersFromBands(bands) {
- markersById.clear();
- coordIndex.clear();
- clusterLayer.clearLayers();
-
- // Index by coords
- for (const b of bands) {
- if (!Number.isFinite(b.lat) || !Number.isFinite(b.lon)) continue;
- const k = keyFromLatLon(b.lat, b.lon);
- if (!coordIndex.has(k)) coordIndex.set(k, []);
- coordIndex.get(k).push(b);
- }
-
- for (const [k, coordBands] of coordIndex.entries()) {
- const [la, lo] = k.split(",").map(Number);
-
- if (!Number.isFinite(la) || !Number.isFinite(lo)) continue;
-
- const locationName = coordBands[0]?.location_text || null;
-
- // Always use location marker for individual bands (final level)
- const marker = createLocationMarker(la, lo, coordBands.length, coordBands, locationName);
-
- for (const b of coordBands) markersById.set(b.ma_id, marker);
-
- clusterLayer.addLayer(marker);
- }
-
- if (currentMode === "clusters") {
- if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
- }
-
- $("count").textContent = String(bands.length);
- $("unique").textContent = String(coordIndex.size);
- renderList(bands);
-}
-
-/**
- * Rebuild layers from locally filtered data (legacy mode)
- */
-function rebuildLayers() {
- markersById.clear();
- coordIndex.clear();
-
- clusterLayer.clearLayers();
-
- if (currentMode === "clusters") {
- if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
- } else if (map.hasLayer(clusterLayer)) {
- map.removeLayer(clusterLayer);
- }
-
- // index by coords
- for (const b of filtered) {
- const k = keyFromLatLon(b.lat, b.lon);
- if (!coordIndex.has(k)) coordIndex.set(k, []);
- coordIndex.get(k).push(b);
- }
-
- console.log(`Rebuilding layers: ${filtered.length} bands, ${coordIndex.size} unique coords`);
-
- let markerCount = 0;
- const heatPoints = [];
- let heatMax = 1;
- for (const [k, bands] of coordIndex.entries()) {
- const [la, lo] = k.split(",").map(Number);
-
- // Validate coordinates
- if (!Number.isFinite(la) || !Number.isFinite(lo)) {
- console.warn(`Invalid coords for key ${k}:`, { la, lo });
- continue;
- }
-
- const intensity = bands.length;
- heatPoints.push([la, lo, intensity]);
- if (intensity > heatMax) heatMax = intensity;
-
- const locationName = bands[0]?.location_text || null;
-
- // Use location marker (final level) for all in legacy mode
- const marker = createLocationMarker(la, lo, bands.length, bands, locationName);
- markerCount++;
-
- for (const b of bands) markersById.set(b.ma_id, marker);
-
- clusterLayer.addLayer(marker);
- }
-
- console.log(`Added ${markerCount} markers to clusterLayer`);
-
- // heatmap (use all points if loaded, otherwise use filtered)
- if (heatPointsLoaded) {
- buildHeatLayer(allHeatPoints);
- } else {
- buildHeatLayer(heatPoints, heatMax);
- }
-
- if (currentMode === "heat") {
- if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer);
- } else if (heatLayer && map.hasLayer(heatLayer)) {
- map.removeLayer(heatLayer);
- }
-
- // counters
- $("count").textContent = String(filtered.length);
- $("unique").textContent = String(coordIndex.size);
-}
-
-// --- Map init ---
-const map = L.map("map", {
- preferCanvas: true,
- tap: true, // Enable tap for mobile
- touchZoom: true,
- dragging: true,
- zoomControl: false // Disable default, we'll add our own on the right
-}).setView([50.2, 10.2], 4);
-
-// Add zoom control on the right side
-L.control.zoom({
- position: 'topright'
-}).addTo(map);
-
-L.tileLayer(DARK_TILES, { maxZoom: 19, attribution: DARK_ATTRIB }).addTo(map);
-
-// Force map to render properly on mobile
-setTimeout(() => {
- if (map && map.invalidateSize) {
- map.invalidateSize(true);
- }
-}, 100);
-
-// Also invalidate on window resize
-window.addEventListener('resize', () => {
- if (map && map.invalidateSize) {
- map.invalidateSize();
- }
-});
-
-// Invalidate when page becomes visible (mobile browsers)
-document.addEventListener('visibilitychange', () => {
- if (!document.hidden && map && map.invalidateSize) {
- setTimeout(() => map.invalidateSize(true), 100);
- }
-});
-
-// Layer for custom cluster markers
-clusterLayer = L.layerGroup();
-map.addLayer(clusterLayer);
-
-// Debounced viewport change handler
-let viewportDebounceTimer = null;
-function onViewportChange() {
- if (!USE_VIEWPORT_LOADING) return;
-
- clearTimeout(viewportDebounceTimer);
- viewportDebounceTimer = setTimeout(() => {
- loadViewportBands();
- }, VIEWPORT_DEBOUNCE_MS);
-}
-
-// Listen for map movements
-map.on("moveend", onViewportChange);
-map.on("zoomend", onViewportChange);
-
-function buildHeatLayer(points, maxIntensity) {
- if (!points || !points.length) {
- if (heatLayer) map.removeLayer(heatLayer);
- return;
- }
-
- // Calcul des statistiques pour calibrer l'échelle
- const intensities = points.map(p => p[2]).sort((a, b) => b - a);
- const actualMax = intensities[0] || 1;
- const p95 = intensities[Math.floor(intensities.length * 0.05)] || 1; // 95e percentile
- const p90 = intensities[Math.floor(intensities.length * 0.10)] || 1; // 90e percentile
- const p75 = intensities[Math.floor(intensities.length * 0.25)] || 1; // 75e percentile
- const p50 = intensities[Math.floor(intensities.length * 0.5)] || 1; // Médiane
-
- // Utilise le 75e percentile pour une heatmap moins saturée
- // Multiplié par 2 pour étaler davantage les couleurs
- const max = Math.max(15, (maxIntensity || p75) * 2);
-
- console.log(`Heatmap: ${points.length} points, actualMax=${actualMax}, p95=${p95}, p90=${p90}, p75=${p75}, p50=${p50}, using max=${max}`);
-
- if (heatLayer) map.removeLayer(heatLayer);
- heatLayer = L.heatLayer(points, {
- radius: 20, // Réduit de 28 à 20 pour moins de chevauchement
- blur: 18, // Réduit de 22 à 18
- maxZoom: 10, // Augmenté de 8 à 10 pour garder l'effet plus longtemps
- minOpacity: 0.15, // Réduit de 0.25 à 0.15 pour moins de saturation
- max: max,
- gradient: {
- 0.0: '#000033', // Bleu très foncé (presque invisible)
- 0.15: '#000066', // Bleu foncé
- 0.3: '#0066cc', // Bleu moyen
- 0.45: '#00ccff', // Cyan
- 0.58: '#44ff44', // Vert
- 0.72: '#ffff00', // Jaune
- 0.86: '#ff8800', // Orange
- 1.0: '#ff0000' // Rouge
- }
- });
-
- if (currentMode === "heat") {
- map.addLayer(heatLayer);
- }
-}
-
-function setMode(mode) {
- // mode: "clusters" | "heat"
- currentMode = mode;
- const cOn = mode === "clusters";
- const hOn = mode === "heat";
-
- if (cOn) {
- if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
- if (heatLayer && map.hasLayer(heatLayer)) map.removeLayer(heatLayer);
- } else {
- if (map.hasLayer(clusterLayer)) map.removeLayer(clusterLayer);
- if (!heatPointsLoaded) {
- loadAllHeatPoints();
- } else {
- buildHeatLayer(allHeatPoints);
- if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer);
- }
- }
-}
-
-// --- UI widgets (multiselect) ---
-function buildMultiSelect(containerId, items, enabledMap, labelFn, options = {}) {
- const container = $(containerId);
- if (!container) return;
-
- const summaryEl = options.summaryEl || null;
- const emptyLabel = options.emptyLabel || "Aucun élément";
-
- container.innerHTML = `
-
-
- `;
-
- const searchEl = container.querySelector(".ms-search");
- const listEl = container.querySelector(".ms-list");
-
- function updateSummary() {
- if (!summaryEl) return;
- const total = items.length;
- if (!total) {
- summaryEl.textContent = "Aucun";
- return;
- }
- const selected = items.filter(it => enabledMap.get(it.key) !== false).length;
- if (selected === total) summaryEl.textContent = "Tous";
- else if (selected === 0) summaryEl.textContent = "Aucun";
- else summaryEl.textContent = `${selected}/${total}`;
- }
-
- function render() {
- const q = norm(searchEl.value).toLowerCase();
- listEl.innerHTML = "";
-
- if (!items.length) {
- listEl.innerHTML = `${escapeHtml(emptyLabel)}
`;
- updateSummary();
- return;
- }
-
- const filteredItems = items.filter(it => labelFn(it).toLowerCase().includes(q));
- if (!filteredItems.length) {
- listEl.innerHTML = `Aucun résultat
`;
- updateSummary();
- return;
- }
-
- for (const it of filteredItems) {
- const key = it.key;
- const on = enabledMap.get(key) === true;
- const div = document.createElement("div");
- div.className = `ms-item ${on ? "on" : ""}`;
- div.dataset.key = key;
- div.innerHTML = `
- ${escapeHtml(labelFn(it))}
- ${it.count}
- `;
- div.addEventListener("click", () => {
- enabledMap.set(key, !(enabledMap.get(key) === true));
- render();
- applyFilters();
- });
- listEl.appendChild(div);
- }
-
- updateSummary();
- }
-
- container.querySelector('[data-act="all"]').addEventListener("click", () => {
- for (const it of items) enabledMap.set(it.key, true);
- render();
- applyFilters();
- });
- container.querySelector('[data-act="none"]').addEventListener("click", () => {
- for (const it of items) enabledMap.set(it.key, false);
- render();
- applyFilters();
- });
- searchEl.addEventListener("input", render);
-
- render();
-}
-
-function buildStatusToggles(statusItems) {
- const grid = $("statusGrid");
- grid.innerHTML = "";
- for (const it of statusItems) {
- statusEnabled.set(it.key, true);
- const btn = document.createElement("div");
- btn.className = "toggle on";
- btn.dataset.status = it.key;
- btn.innerHTML = `${escapeHtml(it.key)} ${it.count} `;
- btn.addEventListener("click", () => {
- const cur = statusEnabled.get(it.key) === true;
- statusEnabled.set(it.key, !cur);
- btn.classList.toggle("on", !cur);
- applyFilters();
- });
- grid.appendChild(btn);
- }
-}
-
-function buildMacroGenreButtons(genreFacets = []) {
- const container = $("macroGenres");
- if (!container) return;
- container.innerHTML = "";
- macroActiveCount = 0;
-
- const counts = new Map();
- for (const m of MACRO_GENRES) counts.set(m.key, 0);
-
- // Calculate macro genre counts from genre facets
- for (const facet of genreFacets) {
- const g = (facet.key || "").toLowerCase();
- if (!g) continue;
- for (const m of MACRO_GENRES) {
- if (m.terms.some(t => g.includes(t))) {
- counts.set(m.key, (counts.get(m.key) || 0) + facet.count);
- }
- }
- }
-
- for (const m of MACRO_GENRES) {
- macroGenreEnabled.set(m.key, false);
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "macro-btn";
- btn.dataset.key = m.key;
- btn.innerHTML = `${escapeHtml(m.key)} ${counts.get(m.key) || 0} `;
- btn.addEventListener("click", () => {
- const cur = macroGenreEnabled.get(m.key) === true;
- macroGenreEnabled.set(m.key, !cur);
- btn.classList.toggle("on", !cur);
- macroActiveCount += cur ? -1 : 1;
- applyFilters();
- });
- container.appendChild(btn);
- }
-}
-
-function setupDropdown(triggerId, panelId) {
- const trigger = $(triggerId);
- const panel = $(panelId);
- if (!trigger || !panel) return;
- trigger.addEventListener("click", () => {
- const nowCollapsed = panel.classList.toggle("is-collapsed");
- trigger.setAttribute("aria-expanded", String(!nowCollapsed));
- });
-}
-
-// --- List + details + highlight ---
-function sortBands(arr) {
- const mode = $("sortSelect")?.value || "az";
- const a = [...arr];
-
- if (mode === "az") {
- a.sort((x,y) => (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
- } else if (mode === "status") {
- a.sort((x,y) => (x.status || "").localeCompare(y.status || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
- } else if (mode === "genre") {
- a.sort((x,y) => (x.genre || "").localeCompare(y.genre || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
- } else if (mode === "country") {
- a.sort((x,y) => (x.country || "").localeCompare(y.country || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
- } else if (mode === "year") {
- a.sort((x,y) => (Number.isFinite(y.formed_year) ? y.formed_year : -1) - (Number.isFinite(x.formed_year) ? x.formed_year : -1));
- }
-
- return a;
-}
-
-function setActive(ma_id) {
- // list highlight
- for (const [id, el] of listElById.entries()) el.classList.toggle("active", id === ma_id);
-
- // marker highlight
- const m = markersById.get(ma_id);
- if (m && m.setStyle) {
- m.setStyle({ radius: 8, fillOpacity: 0.85, weight: 2 });
- // reset others
- for (const [id, mm] of markersById.entries()) {
- if (id !== ma_id && mm.setStyle) mm.setStyle({ radius: 6, fillOpacity: 0.55, weight: 1 });
- }
- }
-}
-
-function renderList(items, hintOverride) {
- const list = $("list");
- list.innerHTML = "";
- listElById.clear();
-
- const sorted = sortBands(items);
- $("selectionHint").textContent = hintOverride || `${sorted.length} ${t("results_label")}`;
-
- const q = currentQuery();
-
- for (const b of sorted.slice(0, 4000)) {
- const div = document.createElement("div");
- div.className = "band";
- div.dataset.id = String(b.ma_id);
-
- const name = escapeHtml(b.name || "Unknown");
- const genre = escapeHtml(b.genre || "—");
- const loc = escapeHtml(b.location_text || "—");
- const st = escapeHtml(b.status || "—");
- const yr = Number.isFinite(b.formed_year) ? String(b.formed_year) : "—";
-
- div.innerHTML = `
-
-
- `;
-
- div.addEventListener("mouseenter", () => {
- setActive(b.ma_id);
- const m = markersById.get(b.ma_id);
- if (m) m.openPopup?.();
- });
-
- div.addEventListener("click", () => {
- setActive(b.ma_id);
- const m = markersById.get(b.ma_id);
- if (m && m.getLatLng) {
- map.setView(m.getLatLng(), Math.max(map.getZoom(), 8), { animate: true });
- m.openPopup?.();
- } else if (Number.isFinite(b.lat) && Number.isFinite(b.lon)) {
- map.setView([b.lat, b.lon], Math.max(map.getZoom(), 10), { animate: true });
- }
- });
-
- list.appendChild(div);
- listElById.set(b.ma_id, div);
-
- // highlight visuel dans liste
- if (q && bandMatchesQuery(b, q)) {
- div.style.boxShadow = "0 0 0 1px rgba(198,26,26,0.15) inset";
- }
- }
-}
-
-function applyFilters() {
- const q = currentQuery();
-
- console.log("Applying filters...", {
- query: q,
- useViewportLoading: USE_VIEWPORT_LOADING
- });
-
- if (USE_VIEWPORT_LOADING) {
- // With viewport loading, trigger a reload from server
- loadViewportBands();
- } else {
- // Legacy mode: filter locally loaded data
- filtered = geocodedBands
- .filter(b => bandMatchesQuery(b, q))
- .filter(b => bandMatchesCountry(b))
- .filter(b => bandMatchesStatus(b))
- .filter(b => bandMatchesGenre(b))
- .filter(b => bandMatchesTheme(b))
- .filter(b => bandMatchesYear(b));
-
- console.log(`Filtered: ${filtered.length} bands match filters`);
-
- rebuildLayers();
- renderList(filtered);
- }
-}
-
-// --- Modal for no coords ---
-const modalBackdrop = $("modalBackdrop");
-const modalBody = $("modalBody");
-const modalClose = $("modalClose");
-const modalTitle = $("modalTitle");
-
-function openModal(title, bands) {
- modalTitle.textContent = title;
- modalBody.innerHTML = "";
-
- const q = currentQuery();
- const items = bands
- .filter(b => bandMatchesQuery(b, q))
- .filter(b => bandMatchesCountry(b))
- .filter(b => bandMatchesStatus(b))
- .filter(b => bandMatchesGenre(b))
- .filter(b => bandMatchesTheme(b))
- .filter(b => bandMatchesYear(b));
-
- if (!items.length) {
- modalBody.innerHTML = `Aucun résultat
`;
- } else {
- const sorted = sortBands(items);
- for (const b of sorted.slice(0, 8000)) {
- const div = document.createElement("div");
- div.className = "band";
- const maUrl = b.url || (b.ma_id ? `https://www.metal-archives.com/bands/x/${b.ma_id}` : "#");
- div.innerHTML = `
-
-
- `;
- modalBody.appendChild(div);
- }
- }
-
- modalBackdrop.classList.add("on");
- modalBackdrop.setAttribute("aria-hidden", "false");
-}
-
-async function loadNoLocationBands() {
- try {
- modalBody.innerHTML = "Chargement...
";
-
- const j = await apiGet("/api/bands?limit=10000&geocoded=0");
- const items = j.items || [];
-
- modalBody.innerHTML = "";
-
- if (!items.length) {
- modalBody.innerHTML = `Aucun groupe sans coordonnées
`;
- return;
- }
-
- for (const x of items.slice(0, 500)) {
- const div = document.createElement("div");
- div.className = "band";
- const maUrl = x.ma_id ? `https://www.metal-archives.com/bands/x/${x.ma_id}` : "#";
- div.innerHTML = `
-
-
- `;
- modalBody.appendChild(div);
- }
-
- if (items.length > 500) {
- const more = document.createElement("div");
- more.style.cssText = "padding:10px;color:rgba(162,176,194,0.85);text-align:center;";
- more.textContent = `... et ${items.length - 500} autres`;
- modalBody.appendChild(more);
- }
- } catch (e) {
- modalBody.innerHTML = `Erreur: ${escapeHtml(e.message)}
`;
- }
-}
-
-function closeModal() {
- modalBackdrop.classList.remove("on");
- modalBackdrop.setAttribute("aria-hidden", "true");
-}
-
-modalClose?.addEventListener("click", closeModal);
-modalBackdrop?.addEventListener("click", (e) => {
- if (e.target === modalBackdrop) closeModal();
-});
-
-// Info modal (legal/FAQ)
-const infoBackdrop = $("infoBackdrop");
-const infoBody = $("infoBody");
-const infoClose = $("infoClose");
-const infoTitle = $("infoTitle");
-
-// LEGAL_HTML and FAQ_HTML are now generated per-language in locales.js via t('legal_html') / t('faq_html')
-
-function openInfoModal(title, html) {
- if (!infoBackdrop || !infoBody || !infoTitle) return;
- infoTitle.textContent = title;
- infoBody.innerHTML = html;
- infoBackdrop.classList.add("on");
- infoBackdrop.setAttribute("aria-hidden", "false");
-}
-
-function closeInfoModal() {
- if (!infoBackdrop) return;
- infoBackdrop.classList.remove("on");
- infoBackdrop.setAttribute("aria-hidden", "true");
-}
-
-if (infoClose) infoClose.addEventListener("click", closeInfoModal);
-if (infoBackdrop) {
- infoBackdrop.addEventListener("click", (e) => {
- if (e.target === infoBackdrop) closeInfoModal();
- });
-}
-
-document.addEventListener("keydown", (e) => {
- if (e.key === "Escape") {
- closeModal();
- closeInfoModal();
- }
-});
-
-// Sidebar toggle
-function setSidebarCollapsed(collapsed) {
- if (!appEl) return;
- appEl.classList.toggle("sidebar-collapsed", collapsed);
- const toggle = $("sidebarToggle");
- if (toggle) {
- toggle.setAttribute("aria-expanded", String(!collapsed));
- // Update button text
- const label = toggle.querySelector(".label");
- if (label) {
- label.textContent = collapsed ? t("options_btn") : t("close_btn");
- }
- }
- // Refresh map size after sidebar animation
- setTimeout(() => map?.invalidateSize?.(), 220);
-}
-
-const sidebarToggle = $("sidebarToggle");
-if (sidebarToggle) {
- sidebarToggle.addEventListener("click", (e) => {
- e.preventDefault();
- e.stopPropagation();
- const collapsed = appEl.classList.contains("sidebar-collapsed");
- setSidebarCollapsed(!collapsed);
- });
-}
-
-// Note: Sidebar closing is now controlled only via the toggle button
-// We removed the overlay auto-close behavior since users want to interact
-// with the map while the sidebar is open
-
-// Collapse sidebar by default on smaller screens
-if (window.matchMedia("(max-width: 1200px)").matches) {
- setTimeout(() => {
- setSidebarCollapsed(true);
- }, 100);
-}
-
-// Language dropdown (flag-icons custom widget)
-const LANG_FLAGS = { en:"gb", fr:"fr", de:"de", es:"es", it:"it", pl:"pl", nl:"nl", ro:"ro", pt:"pt", cs:"cz", sv:"se", hu:"hu", da:"dk", fi:"fi", sk:"sk", hr:"hr", sl:"si", lt:"lt", lv:"lv", et:"ee", nb:"no" };
-const LANG_NAMES = { en:"English", fr:"Français", de:"Deutsch", es:"Español", it:"Italiano", pl:"Polski", nl:"Nederlands", ro:"Română", pt:"Português", cs:"Čeština", sv:"Svenska", hu:"Magyar", da:"Dansk", fi:"Suomi", sk:"Slovenčina", hr:"Hrvatski", sl:"Slovenščina", lt:"Lietuvių", lv:"Latviešu", et:"Eesti", nb:"Norsk bokmål" };
-
-function syncLangTrigger(lang) {
- const trigger = document.getElementById("langTrigger");
- if (!trigger) return;
- const flagEl = trigger.querySelector(".lang-flag");
- if (flagEl) flagEl.className = `fi fi-${LANG_FLAGS[lang] || "un"} lang-flag`;
- const codeEl = trigger.querySelector(".lang-code");
- if (codeEl) codeEl.textContent = lang.toUpperCase();
- document.querySelectorAll(".lang-opt").forEach(b =>
- b.classList.toggle("current", b.dataset.lang === lang)
- );
-}
-
-const langTrigger = document.getElementById("langTrigger");
-const langOptions = document.getElementById("langOptions");
-if (langTrigger && langOptions) {
- langTrigger.addEventListener("click", (e) => {
- e.stopPropagation();
- const open = langOptions.classList.toggle("hidden") === false;
- langTrigger.setAttribute("aria-expanded", String(open));
- });
- document.addEventListener("click", () => {
- langOptions.classList.add("hidden");
- langTrigger.setAttribute("aria-expanded", "false");
- });
- langOptions.querySelectorAll(".lang-opt").forEach(btn => {
- btn.addEventListener("click", (e) => {
- e.stopPropagation();
- currentLang = btn.dataset.lang;
- localStorage.setItem("lang", currentLang);
- langOptions.classList.add("hidden");
- langTrigger.setAttribute("aria-expanded", "false");
- syncLangTrigger(currentLang);
- applyI18n();
- });
- });
- syncLangTrigger(currentLang);
-}
-applyI18n();
-
-// Legal/FAQ links
-const openLegal = $("openLegal");
-if (openLegal) openLegal.addEventListener("click", () => openInfoModal(t("legal_title"), t("legal_html")));
-
-const openFaq = $("openFaq");
-if (openFaq) openFaq.addEventListener("click", () => openInfoModal(t("faq_title"), t("faq_html")));
-
-// Setup dropdowns
-setupDropdown("countryToggle", "countrySelect");
-setupDropdown("genreToggle", "genreSelect");
-setupDropdown("themeToggle", "themeSelect");
-
-// --- Buttons / controls ---
-$("btnNoLocation")?.addEventListener("click", () => {
- modalTitle.textContent = t("without_coords_modal");
- modalBackdrop.classList.add("on");
- modalBackdrop.setAttribute("aria-hidden", "false");
- loadNoLocationBands();
-});
-
-// Search with suggestions
-const searchInput = $("q");
-const suggestionsEl = $("searchSuggestions");
-
-function showSearchSuggestions(query) {
- if (!query || query.length < 2 || !allBands.length) {
- suggestionsEl?.classList.remove("visible");
- return;
- }
-
- const matches = allBands
- .filter(b => bandMatchesQuery(b, query))
- .slice(0, 8); // Show max 8 suggestions
-
- if (!matches.length) {
- suggestionsEl?.classList.remove("visible");
- return;
- }
-
- if (suggestionsEl) {
- suggestionsEl.innerHTML = matches.map(b => {
- const statusText = b.status || "Unknown";
- const yearText = Number.isFinite(b.formed_year) ? b.formed_year : "—";
- return `
-
-
${escapeHtml(b.name)}
-
- ${escapeHtml(b.country || "—")} • ${escapeHtml(statusText)} • ${yearText} • ${escapeHtml(b.genre || "—")}
-
-
- `;
- }).join("");
-
- // Add click handlers
- suggestionsEl.querySelectorAll(".suggestion-item").forEach(el => {
- el.addEventListener("click", () => {
- const bandId = Number(el.dataset.id);
- const band = allBands.find(b => b.ma_id === bandId);
- if (band && Number.isFinite(band.lat) && Number.isFinite(band.lon)) {
- const marker = markersById.get(bandId);
- if (marker && marker.getLatLng) {
- map.setView(marker.getLatLng(), 10, { animate: true });
- marker.openPopup();
- setActive(bandId);
- } else {
- map.setView([band.lat, band.lon], 12, { animate: true });
- }
- }
- suggestionsEl.classList.remove("visible");
- if (searchInput) searchInput.value = band?.name || "";
- });
- });
-
- suggestionsEl.classList.add("visible");
- }
-}
-
-const debouncedSearch = debounce(() => {
- applyFilters();
-}, 400); // 400ms delay
-
-const debouncedSuggestions = debounce((query) => {
- showSearchSuggestions(query);
-}, 200); // 200ms delay for suggestions
-
-searchInput?.addEventListener("input", (e) => {
- const query = e.target.value.toLowerCase().trim();
- debouncedSuggestions(query);
- debouncedSearch();
-});
-
-searchInput?.addEventListener("focus", () => {
- const query = searchInput.value.toLowerCase().trim();
- if (query.length >= 2) {
- showSearchSuggestions(query);
- }
-});
-
-// Close suggestions when clicking outside
-document.addEventListener("click", (e) => {
- if (!searchInput?.contains(e.target) && !suggestionsEl?.contains(e.target)) {
- suggestionsEl?.classList.remove("visible");
- }
-});
-
-$("sortSelect")?.addEventListener("change", () => {
- // Re-render list with current data
- const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c])
- .map(b => typeof b === 'string' ? JSON.parse(b) : b)
- .filter(Boolean);
- renderList(visibleBands.slice(0, 200));
-});
-
-const heatToggle = $("toggleHeat");
-if (heatToggle) {
- heatToggle.addEventListener("change", () => setMode(heatToggle.checked ? "heat" : "clusters"));
-}
-
-$("btnReset")?.addEventListener("click", () => {
- if (filtered.length) {
- const pts = filtered.map(b => L.latLng(b.lat, b.lon));
- const bounds = L.latLngBounds(pts);
- if (bounds.isValid()) map.fitBounds(bounds.pad(0.12));
- } else if (geocodedBands.length) {
- const pts = geocodedBands.map(b => L.latLng(b.lat, b.lon));
- const bounds = L.latLngBounds(pts);
- if (bounds.isValid()) map.fitBounds(bounds.pad(0.12));
- } else {
- map.setView([50.2, 10.2], 4, { animate: true });
- }
-});
-
-map.on("click", () => {
- const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c])
- .map(b => typeof b === 'string' ? JSON.parse(b) : b)
- .filter(Boolean);
- renderList(visibleBands.slice(0, 200));
-});
-
-// --- Boot ---
-function computeFacetCounts(arr, getKey) {
- const m = new Map();
- for (const b of arr) {
- const val = getKey(b);
- const k = val ? norm(val) : "Unknown";
- m.set(k, (m.get(k) || 0) + 1);
- }
- return [...m.entries()]
- .map(([key, count]) => ({ key, count }))
- .sort((a,b) => b.count - a.count);
-}
-
-function computeTokenFacetCounts(arr, getTokens) {
- const m = new Map();
- for (const b of arr) {
- const tokens = getTokens(b) || [];
- if (!tokens.length) {
- // Count bands without tokens as "Unknown"
- m.set("Unknown", (m.get("Unknown") || 0) + 1);
- } else {
- for (const t of tokens) {
- const k = norm(t) || "Unknown";
- m.set(k, (m.get(k) || 0) + 1);
- }
- }
- }
- return [...m.entries()]
- .map(([key, count]) => ({ key, count }))
- .sort((a,b) => b.count - a.count);
-}
-
-/**
- * Build timeline slider from year range (from facets or computed)
- */
-function buildTimelineFromRange() {
- const slider = $("yearSlider");
- slider.innerHTML = "";
-
- if (!yearRange.min || !yearRange.max) {
- $("yearMin").textContent = "—";
- $("yearMax").textContent = "—";
- $("yearHint").textContent = t("year_data_missing");
- slider.innerHTML = `${t("year_data_unavailable")}
`;
- return;
- }
-
- const { min, max } = yearRange;
-
- console.log(`Timeline: ${min} - ${max}`);
-
- $("yearMin").textContent = String(min);
- $("yearMax").textContent = String(max);
- $("yearHint").textContent = t("all_years_label");
-
- noUiSlider.create(slider, {
- start: [min, max],
- connect: true,
- step: 1,
- range: { min, max },
- behaviour: "tap-drag",
- tooltips: false,
- });
-
- slider.noUiSlider.on("update", (vals) => {
- const a = Math.round(Number(vals[0]));
- const b = Math.round(Number(vals[1]));
- yearFilter = { min: a, max: b };
- $("yearHint").textContent = (a === min && b === max) ? t("all_years_label") : `${a} → ${b}`;
- });
-
- slider.noUiSlider.on("change", () => {
- console.log(`Year filter changed: ${yearFilter.min} - ${yearFilter.max}`);
- applyFilters();
- });
-}
-
-async function boot() {
- try {
- console.log("Boot: Loading data...");
- console.log("Viewport loading mode:", USE_VIEWPORT_LOADING ? "ENABLED" : "DISABLED");
-
- // Wait for fonts to load (can affect layout)
- if (document.fonts && document.fonts.ready) {
- await document.fonts.ready;
- }
-
- // Load facets and stats first (fast)
- await Promise.all([
- loadFacets(),
- loadStats()
- ]);
- loadGoatCounterFooterStats(); // pas besoin d'await
- // Build filters from facets if available, otherwise load bands first
- let statuses, genres, countries, themes;
-
- if (facetData) {
- console.log("Using server-side facets for filters");
- statuses = facetData.statuses.map(s => ({ key: s.value, count: s.count }));
- countries = facetData.countries.map(c => ({ key: c.value, count: c.count }));
- genres = facetData.genres.map(g => ({ key: g.value, count: g.count }));
- themes = []; // Themes need to be computed from bands
-
- // Set year range from facets
- if (facetData.year_range) {
- yearRange = {
- min: facetData.year_range.min_year,
- max: facetData.year_range.max_year
- };
- yearFilter = { ...yearRange };
- }
- } else {
- // Fallback: load bands and compute facets locally
- console.log("Loading bands for local facet computation...");
- await loadBands();
- statuses = computeFacetCounts(allBands, b => b.status);
- genres = computeFacetCounts(allBands, b => b.genre);
- countries = computeFacetCounts(allBands, b => b.country);
- themes = computeTokenFacetCounts(allBands, b => b.themes);
-
- }
-
- console.log(`Boot: Facets ready - ${statuses.length} statuses, ${countries.length} countries, ${genres.length} genres`);
-
- // Init enabled maps = true
- for (const it of genres) genreEnabled.set(it.key, true);
- for (const it of countries) countryEnabled.set(it.key, true);
- for (const it of themes) themeEnabled.set(it.key, true);
-
- buildStatusToggles(statuses);
- buildMacroGenreButtons(genres);
- buildMultiSelect("genreSelect", genres, genreEnabled, (it) => it.key, { summaryEl: $("genreSummary") });
- buildMultiSelect("countrySelect", countries, countryEnabled, (it) => it.key, { summaryEl: $("countrySummary") });
-
- if (themes.length) {
- $("themeBox").style.display = "";
- buildMultiSelect("themeSelect", themes, themeEnabled, (it) => it.key, { summaryEl: $("themeSummary"), emptyLabel: "Aucun thème" });
- } else {
- $("themeBox").style.display = "none";
- }
-
- // Build timeline from facet data or computed range
- buildTimelineFromRange();
-
- // Set mode BEFORE applying filters
- setMode(heatToggle && heatToggle.checked ? "heat" : "clusters");
-
- // Force map size calculation
- console.log("Forcing map size recalculation...");
- if (map && map.invalidateSize) {
- map.invalidateSize(true);
- }
-
- // Initial data load
- console.log("Boot: Loading initial viewport data...");
-
- if (USE_VIEWPORT_LOADING) {
- // Load viewport data
- await loadViewportBands();
- } else {
- // Legacy: use all loaded bands
- if (!allBands.length) await loadBands();
- applyFilters();
- }
-
- console.log("Boot: Initial load complete");
-
- // Fit to Europe by default
- setTimeout(() => {
- if (map && map.invalidateSize) {
- map.invalidateSize(true);
- }
- // Default view: Europe
- map.setView([50.2, 10.2], 4);
- }, 100);
-
- // Load all bands in background for heatmap and search
- setTimeout(() => loadAllHeatPoints(), 1000);
-
- } catch (err) {
- console.error("Boot error:", err);
- $("list").innerHTML = `Erreur de chargement: ${escapeHtml(err.message || String(err))}
`;
- }
-}
-
-// Make sure DOM is ready before booting
-if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', boot);
-} else {
- boot();
-}
+/* Black Metal Map UI — clusters/heat/timeline/filter/sort/highlight */
+/* Performance-optimized with viewport-based loading */
+
+const API_BASE = "https://bm.nicolasfryder.ovh";
+
+// --- i18n ---
+const SUPPORTED_LANGS = ["fr", "en", "de", "es", "it", "pl", "nl", "ro", "pt", "cs", "sv", "hu", "da", "fi", "sk", "hr", "sl", "lt", "lv", "et", "nb"];
+
+function detectLang() {
+ const langs = navigator.languages?.length ? navigator.languages : [navigator.language || "en"];
+ for (const l of langs) {
+ const code = l.split("-")[0].toLowerCase();
+ if (SUPPORTED_LANGS.includes(code)) return code;
+ }
+ return "en";
+}
+
+let currentLang = localStorage.getItem("lang") || detectLang();
+
+function t(key) {
+ return window.LOCALES?.[currentLang]?.[key] ?? window.LOCALES?.["en"]?.[key] ?? key;
+}
+
+function applyI18n() {
+ document.documentElement.lang = currentLang;
+ document.querySelectorAll("[data-i18n]").forEach(el => {
+ el.textContent = t(el.dataset.i18n);
+ });
+ document.querySelectorAll("[data-i18n-placeholder]").forEach(el => {
+ el.placeholder = t(el.dataset.i18nPlaceholder);
+ });
+ // Sort select options (can't use data-i18n on options in all browsers)
+ const sel = document.getElementById("sortSelect");
+ if (sel) {
+ [["sort_az",0],["sort_status",1],["sort_genre",2],["sort_country",3],["sort_year",4]].forEach(([k,i]) => {
+ if (sel.options[i]) sel.options[i].text = t(k);
+ });
+ }
+}
+const DARK_TILES = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
+const DARK_ATTRIB = '© OpenStreetMap contributors © CARTO';
+
+// --- Configuration ---
+const USE_VIEWPORT_LOADING = true; // Enable viewport-based loading
+const INITIAL_LOAD_LIMIT = 5000; // Initial bands to load for filters
+const VIEWPORT_DEBOUNCE_MS = 300; // Debounce for viewport changes
+
+// --- State ---
+let allBands = []; // all loaded bands (geocoded + non geocoded)
+let geocodedBands = []; // subset lat/lon ok
+let noLocationBands = []; // subset missing coords
+let viewportBands = []; // bands in current viewport
+
+let filtered = [];
+const markersById = new Map(); // ma_id -> marker
+const listElById = new Map(); // ma_id -> element
+const coordIndex = new Map(); // "lat,lon" -> bands[]
+
+let heatLayer = null;
+let clusterLayer = null;
+let currentMode = "clusters";
+
+// All heat points (for full heatmap even when viewing clusters)
+let allHeatPoints = [];
+let heatPointsLoaded = false;
+
+const statusEnabled = new Map();
+const genreEnabled = new Map();
+const countryEnabled = new Map();
+const macroGenreEnabled = new Map();
+const themeEnabled = new Map();
+
+let yearRange = { min: null, max: null }; // global
+let yearFilter = { min: null, max: null }; // current
+
+let macroActiveCount = 0;
+
+// Facet data from server (for filters)
+let facetData = null;
+
+// Loading state
+let isLoadingViewport = false;
+let pendingViewportLoad = false;
+let currentViewportData = [];
+
+// --- Utils ---
+const $ = (id) => document.getElementById(id);
+const appEl = $("app");
+
+async function loadGoatCounterFooterStats() {
+ const totalEl = document.getElementById("gcTotal");
+ const monthEl = document.getElementById("gcMonth");
+ if (!totalEl || !monthEl) return;
+
+ // 1er jour du mois courant (ce mois-ci)
+ const d = new Date();
+ const yyyy = d.getFullYear();
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
+ const startOfMonth = `${yyyy}-${mm}-01`;
+
+ const base = "https://metalfromeurope.goatcounter.com/counter/";
+ const totalUrl = `${base}TOTAL.json`;
+ const monthUrl = `${base}TOTAL.json?start=${encodeURIComponent(startOfMonth)}`;
+
+ try {
+ const [t, m] = await Promise.all([
+ fetch(totalUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))),
+ fetch(monthUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))),
+ ]);
+
+ // GoatCounter renvoie `count` comme string formatée (séparateurs milliers). :contentReference[oaicite:2]{index=2}
+ totalEl.textContent = t.count ?? "—";
+ monthEl.textContent = m.count ?? "—";
+ } catch (e) {
+ console.warn("GoatCounter footer stats failed:", e);
+ totalEl.textContent = "—";
+ monthEl.textContent = "—";
+ }
+}
+
+// Debounce function for search
+let searchDebounceTimer = null;
+function debounce(func, delay) {
+ return function(...args) {
+ clearTimeout(searchDebounceTimer);
+ searchDebounceTimer = setTimeout(() => func.apply(this, args), delay);
+ };
+}
+
+// Les helpers purs vivent dans pure.js (testés unitairement). app.js garde
+// l'état (les Map de facettes cochées) et ne fait que le lui passer.
+const escapeHtml = BMPure.escapeHtml;
+const norm = BMPure.norm;
+const parseYear = BMPure.parseYear;
+const splitThemes = BMPure.splitThemes;
+
+function currentQuery() {
+ return norm($("q").value).toLowerCase();
+}
+
+function bandMatchesQuery(b, q) { return BMPure.matchesQuery(b, q); }
+function bandMatchesStatus(b) { return BMPure.matchesFacet(b.status, statusEnabled, "Unknown"); }
+function bandMatchesCountry(b) { return BMPure.matchesFacet(b.country, countryEnabled, "??"); }
+function bandMatchesMacroGenre(b) { return BMPure.matchesMacroGenre(b, MACRO_GENRES, macroGenreEnabled, macroActiveCount); }
+
+function bandMatchesGenre(b) {
+ if (!bandMatchesMacroGenre(b)) return false;
+ return BMPure.matchesFacet(b.genre, genreEnabled, "Unknown");
+}
+
+function bandMatchesTheme(b) { return BMPure.matchesTheme(b, themeEnabled); }
+function bandMatchesYear(b) { return BMPure.matchesYear(b, yearFilter, yearRange); }
+
+function keyFromLatLon(lat, lon) { return BMPure.keyFromLatLon(lat, lon); }
+
+const MACRO_GENRES = [
+ { key: "Black", terms: ["black"] },
+ { key: "Death", terms: ["death"] },
+ { key: "Doom/Stoner/Sludge", terms: ["doom", "stoner", "sludge"] },
+ { key: "Electronic/Industrial", terms: ["electronic", "industrial", "electro"] },
+ { key: "Experimental/Avant-garde", terms: ["experimental", "avant", "avant-garde", "avantgarde"] },
+ { key: "Folk/Viking/Pagan", terms: ["folk", "viking", "pagan"] },
+ { key: "Gothic", terms: ["gothic"] },
+ { key: "Grindcore", terms: ["grind"] },
+ { key: "Groove", terms: ["groove"] },
+ { key: "Heavy", terms: ["heavy"] },
+ { key: "Metalcore/Deathcore", terms: ["metalcore", "deathcore"] },
+ { key: "Power", terms: ["power"] },
+ { key: "Progressive", terms: ["progressive", "prog"] },
+ { key: "Speed", terms: ["speed"] },
+ { key: "Symphonic", terms: ["symphonic"] },
+ { key: "Thrash", terms: ["thrash"] },
+];
+
+// --- API ---
+async function apiGet(path) {
+ const url = `${API_BASE}${path}`;
+ const r = await fetch(url, { mode: "cors" });
+ if (!r.ok) throw new Error(`API ${path} failed: ${r.status}`);
+ return await r.json();
+}
+
+async function loadStats() {
+ // tolérant : si /api/stats n'existe pas encore, on ignore
+ try {
+ const j = await apiGet("/api/stats");
+ if (j && j.ok) {
+ $("total").textContent = String(j.total || 0);
+ $("geocoded").textContent = String(j.geocoded || 0);
+ $("noLocCount").textContent = String(j.no_location || 0);
+ }
+ } catch (_) {
+ // Silently ignore stats errors
+ }
+}
+
+/**
+ * Load facet data for filters (fast, minimal data)
+ */
+async function loadFacets() {
+ try {
+ const j = await apiGet("/api/facets");
+ if (j.ok) {
+ facetData = j;
+ console.log("Facets loaded:", {
+ statuses: j.statuses?.length,
+ countries: j.countries?.length,
+ genres: j.genres?.length,
+ yearRange: j.year_range
+ });
+ }
+ } catch (e) {
+ console.warn("Failed to load facets, will compute from bands:", e);
+ }
+}
+
+/**
+ * Parse band data from API response
+ */
+function parseBandData(x) {
+ const status = x.status || x.data?.status || x.data?.band_status || x.data?.status_label;
+
+ return {
+ ma_id: Number(x.ma_id),
+ name: norm(x.name),
+ url: x.url || (x.data?.url) || (x.data?.band_page?.url) || null,
+ country: norm(x.country || x.data?.country),
+ status: norm(status),
+ genre: norm(x.genre || x.data?.genre || x.data?.genres || x.data?.style),
+ themes: splitThemes(x.themes || x.data?.themes || x.data?.theme || x.data?.thematic),
+ location_text: norm(x.location_text || x.data?.location_text || x.data?.location),
+ formed_year: parseYear(x.formed_year ?? x.data?.formed_year ?? x.data?.formed ?? x.data?.formation_year ?? x.data?.year_formed),
+ lat: (x.lat == null ? null : Number(x.lat)),
+ lon: (x.lon == null ? null : Number(x.lon)),
+ // Localisation précise (band_locations) : présent quand le point vient
+ // d'un step géocodé plutôt que du point unique du groupe.
+ location_raw: x.location_raw != null ? norm(x.location_raw) : null,
+ step_label: x.step_label != null ? norm(x.step_label) : null,
+ data: x.data || {},
+ geocoded_at: x.geocoded_at || null,
+ };
+}
+
+/**
+ * Load initial sample of bands for quick startup
+ */
+async function loadBands() {
+ // Load a limited set initially for faster startup
+ const limit = USE_VIEWPORT_LOADING ? INITIAL_LOAD_LIMIT : 200000;
+ const j = await apiGet(`/api/bands?limit=${limit}`);
+ const items = j.items || j.bands || [];
+
+ console.log(`Loaded ${items.length} bands initially`);
+
+ allBands = items.map(parseBandData);
+
+ geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon));
+ noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon)));
+
+ // Update counts
+ $("total").textContent = String(allBands.length);
+ $("geocoded").textContent = String(geocodedBands.length);
+ $("noLocCount").textContent = String(noLocationBands.length);
+}
+
+/**
+ * Load all heat points for the full heatmap (done once in background)
+ */
+async function loadAllHeatPoints() {
+ if (heatPointsLoaded) return;
+
+ try {
+ console.log("Loading all bands for heatmap...");
+ const j = await apiGet("/api/bands?limit=200000");
+ const items = j.items || [];
+
+ allHeatPoints = [];
+
+ // If allBands is empty, populate it
+ if (!allBands.length) {
+ allBands = items.map(parseBandData);
+ geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon));
+ noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon)));
+ }
+
+ // Index by coords for heatmap intensity
+ const coordCounts = new Map();
+
+ for (const x of items) {
+ const lat = x.lat == null ? null : Number(x.lat);
+ const lon = x.lon == null ? null : Number(x.lon);
+
+ if (Number.isFinite(lat) && Number.isFinite(lon)) {
+ const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
+ coordCounts.set(key, (coordCounts.get(key) || 0) + 1);
+ }
+ }
+
+ // Convert to heat points
+ for (const [key, count] of coordCounts) {
+ const [lat, lon] = key.split(",").map(Number);
+ allHeatPoints.push([lat, lon, count]);
+ }
+
+ heatPointsLoaded = true;
+ console.log(`Loaded ${allHeatPoints.length} unique heat points from ${items.length} bands`);
+
+ // Update stats
+ $("total").textContent = String(items.length);
+ $("geocoded").textContent = String(geocodedBands.length);
+ $("noLocCount").textContent = String(noLocationBands.length);
+
+ // Rebuild heatmap if in heat mode
+ if (currentMode === "heat") {
+ buildHeatLayer(allHeatPoints);
+ }
+ } catch (e) {
+ console.error("Failed to load heat points:", e);
+ }
+}
+
+/**
+ * Load bands for current viewport from server
+ */
+async function loadViewportBands() {
+ if (!USE_VIEWPORT_LOADING) return;
+ if (isLoadingViewport) {
+ pendingViewportLoad = true;
+ return;
+ }
+
+ isLoadingViewport = true;
+
+ try {
+ const bounds = map.getBounds();
+ const zoom = map.getZoom();
+
+ // Expand bounds slightly to preload edges
+ const expandFactor = 0.2;
+ const latDiff = (bounds.getNorth() - bounds.getSouth()) * expandFactor;
+ const lonDiff = (bounds.getEast() - bounds.getWest()) * expandFactor;
+
+ const bbox = [
+ bounds.getWest() - lonDiff,
+ bounds.getSouth() - latDiff,
+ bounds.getEast() + lonDiff,
+ bounds.getNorth() + latDiff
+ ].join(",");
+
+ // Build query params for filters
+ const params = new URLSearchParams({
+ bbox,
+ zoom: String(Math.floor(zoom))
+ });
+
+ // Add active filters
+ const activeStatuses = Array.from(statusEnabled.entries())
+ .filter(([, v]) => v)
+ .map(([k]) => k);
+ if (activeStatuses.length && activeStatuses.length < statusEnabled.size) {
+ params.set("status", activeStatuses.join(","));
+ }
+
+ const activeCountries = Array.from(countryEnabled.entries())
+ .filter(([, v]) => v)
+ .map(([k]) => k);
+ if (activeCountries.length && activeCountries.length < countryEnabled.size) {
+ params.set("countries", activeCountries.join(","));
+ }
+
+ if (yearFilter.min != null && yearFilter.max != null) {
+ if (yearFilter.min !== yearRange.min) params.set("year_min", String(yearFilter.min));
+ if (yearFilter.max !== yearRange.max) params.set("year_max", String(yearFilter.max));
+ }
+
+ // Add genre filter if macro genres are active
+ if (macroActiveCount > 0) {
+ const activeTerms = MACRO_GENRES
+ .filter(m => macroGenreEnabled.get(m.key) === true)
+ .flatMap(m => m.terms);
+ if (activeTerms.length) {
+ params.set("genre", activeTerms[0]); // API supports single genre filter
+ }
+ }
+
+ const url = `/api/clusters?${params.toString()}`;
+ console.log("Loading viewport:", url);
+
+ const j = await apiGet(url);
+
+ if (j.ok) {
+ currentViewportData = j.items || [];
+
+ if (j.type === "bands") {
+ // High zoom: got individual bands
+ viewportBands = currentViewportData.map(parseBandData);
+ rebuildLayersFromBands(viewportBands);
+ } else {
+ // Low zoom: got clusters
+ rebuildLayersFromClusters(currentViewportData);
+ }
+
+ $("count").textContent = String(j.count || currentViewportData.length || 0);
+ }
+ } catch (e) {
+ console.error("Failed to load viewport bands:", e);
+ } finally {
+ isLoadingViewport = false;
+
+ if (pendingViewportLoad) {
+ pendingViewportLoad = false;
+ setTimeout(loadViewportBands, 100);
+ }
+ }
+}
+
+/**
+ * Create a cluster marker (zoomable, not final location)
+ * These have a dashed border to indicate they can be zoomed
+ * Burgundy/dark red color, always white text
+ */
+function createClusterMarker(lat, lon, count, bands) {
+ // Size based on count (logarithmic scale)
+ const size = Math.min(24 + Math.log2(count + 1) * 8, 55);
+
+ // Constant burgundy/dark red color for all clusters
+ const color = 'rgba(120, 40, 60, 0.92)';
+ const borderColor = 'rgba(180, 80, 100, 0.9)';
+ const textColor = '#fff';
+
+ // Create custom div icon with count - dashed border indicates "zoomable"
+ const icon = L.divIcon({
+ className: 'cluster-marker cluster-zoomable',
+ html: `${count > 999 ? Math.round(count/1000) + 'k' : count}
`,
+ iconSize: [size, size],
+ iconAnchor: [size/2, size/2]
+ });
+
+ const marker = L.marker([lat, lon], { icon });
+
+ // Store data for click handling
+ marker.__bands = bands;
+ marker.__count = count;
+ marker.__isCluster = true; // Flag to identify as zoomable cluster
+
+ // Click handler - always zoom for clusters
+ marker.on("click", (e) => {
+ L.DomEvent.stopPropagation(e);
+ map.setView([lat, lon], map.getZoom() + 2, { animate: true });
+ });
+
+ return marker;
+}
+
+/**
+ * Create a location marker (final level - shows popup with all bands)
+ * These have a solid border and bright red color to indicate they show details
+ */
+function createLocationMarker(lat, lon, count, bands, locationName) {
+ // Size based on count (smaller than clusters)
+ const size = Math.min(18 + Math.log2(count + 1) * 6, 42);
+
+ // Bright red color for final locations
+ let color, borderColor;
+ if (count === 1) {
+ color = 'rgba(180, 30, 30, 0.9)';
+ borderColor = 'rgba(255, 100, 100, 0.9)';
+ } else if (count < 5) {
+ color = 'rgba(198, 26, 26, 0.9)';
+ borderColor = 'rgba(255, 120, 120, 0.9)';
+ } else if (count < 20) {
+ color = 'rgba(210, 50, 50, 0.9)';
+ borderColor = 'rgba(255, 150, 150, 0.9)';
+ } else {
+ color = 'rgba(220, 80, 80, 0.9)';
+ borderColor = 'rgba(255, 180, 180, 0.95)';
+ }
+
+ // Create custom div icon with count - solid border indicates "clickable for details"
+ const icon = L.divIcon({
+ className: 'cluster-marker cluster-location',
+ html: `${count}
`,
+ iconSize: [size, size],
+ iconAnchor: [size/2, size/2]
+ });
+
+ const marker = L.marker([lat, lon], { icon });
+
+ // Store data
+ marker.__bands = bands;
+ marker.__count = count;
+ marker.__isCluster = false; // Flag to identify as final location
+ marker.__locationName = locationName;
+
+ // Build popup HTML with ALL bands (scrollable)
+ const bandListHtml = bands.map(b => {
+ const maUrl = b.url || (b.ma_id ? `https://www.metal-archives.com/bands/x/${b.ma_id}` : null);
+ const genre = b.genre || '—';
+ const status = b.status || '—';
+ const year = Number.isFinite(b.formed_year) ? b.formed_year : '—';
+
+ if (maUrl) {
+ return `
+ ${escapeHtml(b.name || 'Unknown')}
+ ${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}
+ `;
+ }
+ return `
+
${escapeHtml(b.name || 'Unknown')}
+
${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}
+
`;
+ }).join("");
+
+ const popupHtml = `
+
+
+ 📍 ${escapeHtml(locationName || t('location_fallback'))}
+ ${count} ${count === 1 ? t('group_s') : t('group_p')}
+
+
+ ${bandListHtml}
+
+
+ `;
+
+ // Bind popup
+ marker.bindPopup(popupHtml, {
+ maxWidth: 380,
+ maxHeight: 400,
+ className: 'location-popup'
+ });
+
+ // Click handler - update sidebar list AND open popup
+ marker.on("click", (e) => {
+ L.DomEvent.stopPropagation(e);
+ const gLabel = count === 1 ? t('group_s') : t('group_p');
+ const hint = locationName ? `${count} ${gLabel} — ${locationName}` : `${count} ${gLabel}`;
+ renderList(bands, hint);
+ // Small delay to ensure popup opens correctly
+ setTimeout(() => marker.openPopup(), 10);
+ });
+
+ return marker;
+}
+
+/**
+ * Check if all bands share the same location_text
+ */
+function bandsShareLocation(bands) {
+ if (!bands || bands.length === 0) return false;
+ if (bands.length === 1) return true;
+
+ const firstLoc = norm(bands[0]?.location_text || '');
+ if (!firstLoc) return false;
+
+ return bands.every(b => norm(b?.location_text || '') === firstLoc);
+}
+
+/**
+ * Rebuild layers from cluster data (server-side aggregated)
+ */
+function rebuildLayersFromClusters(clusters) {
+ markersById.clear();
+ coordIndex.clear();
+ clusterLayer.clearLayers();
+
+ for (const cluster of clusters) {
+ const { lat, lon, count, sample_bands, location_text } = cluster;
+
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
+
+ // Parse sample bands
+ const bands = (sample_bands || []).map(b => {
+ if (typeof b === 'string') {
+ try { return JSON.parse(b); } catch { return null; }
+ }
+ return b;
+ }).filter(Boolean);
+
+ const bandCount = count || bands.length;
+
+ // Check if all bands share the same location
+ const allSameLocation = bandsShareLocation(bands);
+ const locationName = allSameLocation ? (location_text || bands[0]?.location_text || null) : null;
+
+ // Use location marker (popup) if all bands share same location
+ // Use cluster marker (zoom) if bands come from different locations
+ const useLocationMarker = allSameLocation;
+
+ const marker = useLocationMarker
+ ? createLocationMarker(lat, lon, bandCount, bands, locationName)
+ : createClusterMarker(lat, lon, bandCount, bands);
+
+ clusterLayer.addLayer(marker);
+
+ // Store reference for each band in cluster
+ for (const b of bands) {
+ if (b.ma_id) markersById.set(b.ma_id, marker);
+ }
+ }
+
+ if (currentMode === "clusters") {
+ if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
+ }
+
+ $("unique").textContent = String(clusters.length);
+
+ // Render list with visible bands
+ const visibleBands = clusters.flatMap(c => c.sample_bands || [])
+ .map(b => typeof b === 'string' ? JSON.parse(b) : b)
+ .filter(Boolean);
+ renderList(visibleBands.slice(0, 200));
+}
+
+/**
+ * Rebuild layers from individual band data
+ */
+function rebuildLayersFromBands(bands) {
+ markersById.clear();
+ coordIndex.clear();
+ clusterLayer.clearLayers();
+
+ // Index by coords
+ for (const b of bands) {
+ if (!Number.isFinite(b.lat) || !Number.isFinite(b.lon)) continue;
+ const k = keyFromLatLon(b.lat, b.lon);
+ if (!coordIndex.has(k)) coordIndex.set(k, []);
+ coordIndex.get(k).push(b);
+ }
+
+ for (const [k, coordBands] of coordIndex.entries()) {
+ const [la, lo] = k.split(",").map(Number);
+
+ if (!Number.isFinite(la) || !Number.isFinite(lo)) continue;
+
+ const locationName = coordBands[0]?.location_text || null;
+
+ // Always use location marker for individual bands (final level)
+ const marker = createLocationMarker(la, lo, coordBands.length, coordBands, locationName);
+
+ for (const b of coordBands) markersById.set(b.ma_id, marker);
+
+ clusterLayer.addLayer(marker);
+ }
+
+ if (currentMode === "clusters") {
+ if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
+ }
+
+ $("count").textContent = String(bands.length);
+ $("unique").textContent = String(coordIndex.size);
+ renderList(bands);
+}
+
+/**
+ * Rebuild layers from locally filtered data (legacy mode)
+ */
+function rebuildLayers() {
+ markersById.clear();
+ coordIndex.clear();
+
+ clusterLayer.clearLayers();
+
+ if (currentMode === "clusters") {
+ if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
+ } else if (map.hasLayer(clusterLayer)) {
+ map.removeLayer(clusterLayer);
+ }
+
+ // index by coords
+ for (const b of filtered) {
+ const k = keyFromLatLon(b.lat, b.lon);
+ if (!coordIndex.has(k)) coordIndex.set(k, []);
+ coordIndex.get(k).push(b);
+ }
+
+ console.log(`Rebuilding layers: ${filtered.length} bands, ${coordIndex.size} unique coords`);
+
+ let markerCount = 0;
+ const heatPoints = [];
+ let heatMax = 1;
+ for (const [k, bands] of coordIndex.entries()) {
+ const [la, lo] = k.split(",").map(Number);
+
+ // Validate coordinates
+ if (!Number.isFinite(la) || !Number.isFinite(lo)) {
+ console.warn(`Invalid coords for key ${k}:`, { la, lo });
+ continue;
+ }
+
+ const intensity = bands.length;
+ heatPoints.push([la, lo, intensity]);
+ if (intensity > heatMax) heatMax = intensity;
+
+ const locationName = bands[0]?.location_text || null;
+
+ // Use location marker (final level) for all in legacy mode
+ const marker = createLocationMarker(la, lo, bands.length, bands, locationName);
+ markerCount++;
+
+ for (const b of bands) markersById.set(b.ma_id, marker);
+
+ clusterLayer.addLayer(marker);
+ }
+
+ console.log(`Added ${markerCount} markers to clusterLayer`);
+
+ // heatmap (use all points if loaded, otherwise use filtered)
+ if (heatPointsLoaded) {
+ buildHeatLayer(allHeatPoints);
+ } else {
+ buildHeatLayer(heatPoints, heatMax);
+ }
+
+ if (currentMode === "heat") {
+ if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer);
+ } else if (heatLayer && map.hasLayer(heatLayer)) {
+ map.removeLayer(heatLayer);
+ }
+
+ // counters
+ $("count").textContent = String(filtered.length);
+ $("unique").textContent = String(coordIndex.size);
+}
+
+// --- Map init ---
+const map = L.map("map", {
+ preferCanvas: true,
+ tap: true, // Enable tap for mobile
+ touchZoom: true,
+ dragging: true,
+ zoomControl: false // Disable default, we'll add our own on the right
+}).setView([50.2, 10.2], 4);
+
+// Add zoom control on the right side
+L.control.zoom({
+ position: 'topright'
+}).addTo(map);
+
+L.tileLayer(DARK_TILES, { maxZoom: 19, attribution: DARK_ATTRIB }).addTo(map);
+
+// Force map to render properly on mobile
+setTimeout(() => {
+ if (map && map.invalidateSize) {
+ map.invalidateSize(true);
+ }
+}, 100);
+
+// Also invalidate on window resize
+window.addEventListener('resize', () => {
+ if (map && map.invalidateSize) {
+ map.invalidateSize();
+ }
+});
+
+// Invalidate when page becomes visible (mobile browsers)
+document.addEventListener('visibilitychange', () => {
+ if (!document.hidden && map && map.invalidateSize) {
+ setTimeout(() => map.invalidateSize(true), 100);
+ }
+});
+
+// Layer for custom cluster markers
+clusterLayer = L.layerGroup();
+map.addLayer(clusterLayer);
+
+// Debounced viewport change handler
+let viewportDebounceTimer = null;
+function onViewportChange() {
+ if (!USE_VIEWPORT_LOADING) return;
+
+ clearTimeout(viewportDebounceTimer);
+ viewportDebounceTimer = setTimeout(() => {
+ loadViewportBands();
+ }, VIEWPORT_DEBOUNCE_MS);
+}
+
+// Listen for map movements
+map.on("moveend", onViewportChange);
+map.on("zoomend", onViewportChange);
+
+function buildHeatLayer(points, maxIntensity) {
+ if (!points || !points.length) {
+ if (heatLayer) map.removeLayer(heatLayer);
+ return;
+ }
+
+ // Calcul des statistiques pour calibrer l'échelle
+ const intensities = points.map(p => p[2]).sort((a, b) => b - a);
+ const actualMax = intensities[0] || 1;
+ const p95 = intensities[Math.floor(intensities.length * 0.05)] || 1; // 95e percentile
+ const p90 = intensities[Math.floor(intensities.length * 0.10)] || 1; // 90e percentile
+ const p75 = intensities[Math.floor(intensities.length * 0.25)] || 1; // 75e percentile
+ const p50 = intensities[Math.floor(intensities.length * 0.5)] || 1; // Médiane
+
+ // Utilise le 75e percentile pour une heatmap moins saturée
+ // Multiplié par 2 pour étaler davantage les couleurs
+ const max = Math.max(15, (maxIntensity || p75) * 2);
+
+ console.log(`Heatmap: ${points.length} points, actualMax=${actualMax}, p95=${p95}, p90=${p90}, p75=${p75}, p50=${p50}, using max=${max}`);
+
+ if (heatLayer) map.removeLayer(heatLayer);
+ heatLayer = L.heatLayer(points, {
+ radius: 20, // Réduit de 28 à 20 pour moins de chevauchement
+ blur: 18, // Réduit de 22 à 18
+ maxZoom: 10, // Augmenté de 8 à 10 pour garder l'effet plus longtemps
+ minOpacity: 0.15, // Réduit de 0.25 à 0.15 pour moins de saturation
+ max: max,
+ gradient: {
+ 0.0: '#000033', // Bleu très foncé (presque invisible)
+ 0.15: '#000066', // Bleu foncé
+ 0.3: '#0066cc', // Bleu moyen
+ 0.45: '#00ccff', // Cyan
+ 0.58: '#44ff44', // Vert
+ 0.72: '#ffff00', // Jaune
+ 0.86: '#ff8800', // Orange
+ 1.0: '#ff0000' // Rouge
+ }
+ });
+
+ if (currentMode === "heat") {
+ map.addLayer(heatLayer);
+ }
+}
+
+function setMode(mode) {
+ // mode: "clusters" | "heat"
+ currentMode = mode;
+ const cOn = mode === "clusters";
+
+ if (cOn) {
+ if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
+ if (heatLayer && map.hasLayer(heatLayer)) map.removeLayer(heatLayer);
+ } else {
+ if (map.hasLayer(clusterLayer)) map.removeLayer(clusterLayer);
+ if (!heatPointsLoaded) {
+ loadAllHeatPoints();
+ } else {
+ buildHeatLayer(allHeatPoints);
+ if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer);
+ }
+ }
+}
+
+// --- UI widgets (multiselect) ---
+function buildMultiSelect(containerId, items, enabledMap, labelFn, options = {}) {
+ const container = $(containerId);
+ if (!container) return;
+
+ const summaryEl = options.summaryEl || null;
+ const emptyLabel = options.emptyLabel || "Aucun élément";
+
+ container.innerHTML = `
+
+
+ `;
+
+ const searchEl = container.querySelector(".ms-search");
+ const listEl = container.querySelector(".ms-list");
+
+ function updateSummary() {
+ if (!summaryEl) return;
+ const total = items.length;
+ if (!total) {
+ summaryEl.textContent = "Aucun";
+ return;
+ }
+ const selected = items.filter(it => enabledMap.get(it.key) !== false).length;
+ if (selected === total) summaryEl.textContent = "Tous";
+ else if (selected === 0) summaryEl.textContent = "Aucun";
+ else summaryEl.textContent = `${selected}/${total}`;
+ }
+
+ function render() {
+ const q = norm(searchEl.value).toLowerCase();
+ listEl.innerHTML = "";
+
+ if (!items.length) {
+ listEl.innerHTML = `${escapeHtml(emptyLabel)}
`;
+ updateSummary();
+ return;
+ }
+
+ const filteredItems = items.filter(it => labelFn(it).toLowerCase().includes(q));
+ if (!filteredItems.length) {
+ listEl.innerHTML = `Aucun résultat
`;
+ updateSummary();
+ return;
+ }
+
+ for (const it of filteredItems) {
+ const key = it.key;
+ const on = enabledMap.get(key) === true;
+ const div = document.createElement("div");
+ div.className = `ms-item ${on ? "on" : ""}`;
+ div.dataset.key = key;
+ div.innerHTML = `
+ ${escapeHtml(labelFn(it))}
+ ${it.count}
+ `;
+ div.addEventListener("click", () => {
+ enabledMap.set(key, !(enabledMap.get(key) === true));
+ render();
+ applyFilters();
+ });
+ listEl.appendChild(div);
+ }
+
+ updateSummary();
+ }
+
+ container.querySelector('[data-act="all"]').addEventListener("click", () => {
+ for (const it of items) enabledMap.set(it.key, true);
+ render();
+ applyFilters();
+ });
+ container.querySelector('[data-act="none"]').addEventListener("click", () => {
+ for (const it of items) enabledMap.set(it.key, false);
+ render();
+ applyFilters();
+ });
+ searchEl.addEventListener("input", render);
+
+ render();
+}
+
+function buildStatusToggles(statusItems) {
+ const grid = $("statusGrid");
+ grid.innerHTML = "";
+ for (const it of statusItems) {
+ statusEnabled.set(it.key, true);
+ const btn = document.createElement("div");
+ btn.className = "toggle on";
+ btn.dataset.status = it.key;
+ btn.innerHTML = `${escapeHtml(it.key)} ${it.count} `;
+ btn.addEventListener("click", () => {
+ const cur = statusEnabled.get(it.key) === true;
+ statusEnabled.set(it.key, !cur);
+ btn.classList.toggle("on", !cur);
+ applyFilters();
+ });
+ grid.appendChild(btn);
+ }
+}
+
+function buildMacroGenreButtons(genreFacets = []) {
+ const container = $("macroGenres");
+ if (!container) return;
+ container.innerHTML = "";
+ macroActiveCount = 0;
+
+ const counts = new Map();
+ for (const m of MACRO_GENRES) counts.set(m.key, 0);
+
+ // Calculate macro genre counts from genre facets
+ for (const facet of genreFacets) {
+ const g = (facet.key || "").toLowerCase();
+ if (!g) continue;
+ for (const m of MACRO_GENRES) {
+ if (m.terms.some(t => g.includes(t))) {
+ counts.set(m.key, (counts.get(m.key) || 0) + facet.count);
+ }
+ }
+ }
+
+ for (const m of MACRO_GENRES) {
+ macroGenreEnabled.set(m.key, false);
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "macro-btn";
+ btn.dataset.key = m.key;
+ btn.innerHTML = `${escapeHtml(m.key)} ${counts.get(m.key) || 0} `;
+ btn.addEventListener("click", () => {
+ const cur = macroGenreEnabled.get(m.key) === true;
+ macroGenreEnabled.set(m.key, !cur);
+ btn.classList.toggle("on", !cur);
+ macroActiveCount += cur ? -1 : 1;
+ applyFilters();
+ });
+ container.appendChild(btn);
+ }
+}
+
+function setupDropdown(triggerId, panelId) {
+ const trigger = $(triggerId);
+ const panel = $(panelId);
+ if (!trigger || !panel) return;
+ trigger.addEventListener("click", () => {
+ const nowCollapsed = panel.classList.toggle("is-collapsed");
+ trigger.setAttribute("aria-expanded", String(!nowCollapsed));
+ });
+}
+
+// --- List + details + highlight ---
+function sortBands(arr) {
+ return BMPure.sortBands(arr, $("sortSelect")?.value || "az");
+}
+
+function setActive(ma_id) {
+ // list highlight
+ for (const [id, el] of listElById.entries()) el.classList.toggle("active", id === ma_id);
+
+ // marker highlight
+ const m = markersById.get(ma_id);
+ if (m && m.setStyle) {
+ m.setStyle({ radius: 8, fillOpacity: 0.85, weight: 2 });
+ // reset others
+ for (const [id, mm] of markersById.entries()) {
+ if (id !== ma_id && mm.setStyle) mm.setStyle({ radius: 6, fillOpacity: 0.55, weight: 1 });
+ }
+ }
+}
+
+function renderList(items, hintOverride) {
+ const list = $("list");
+ list.innerHTML = "";
+ listElById.clear();
+
+ const sorted = sortBands(items);
+ $("selectionHint").textContent = hintOverride || `${sorted.length} ${t("results_label")}`;
+
+ const q = currentQuery();
+
+ for (const b of sorted.slice(0, 4000)) {
+ const div = document.createElement("div");
+ div.className = "band";
+ div.dataset.id = String(b.ma_id);
+
+ const name = escapeHtml(b.name || "Unknown");
+ const genre = escapeHtml(b.genre || "—");
+ const loc = escapeHtml(b.location_text || "—");
+ const st = escapeHtml(b.status || "—");
+ const yr = Number.isFinite(b.formed_year) ? String(b.formed_year) : "—";
+
+ div.innerHTML = `
+
+
+ `;
+
+ div.addEventListener("mouseenter", () => {
+ setActive(b.ma_id);
+ const m = markersById.get(b.ma_id);
+ if (m) m.openPopup?.();
+ });
+
+ div.addEventListener("click", () => {
+ setActive(b.ma_id);
+ const m = markersById.get(b.ma_id);
+ if (m && m.getLatLng) {
+ map.setView(m.getLatLng(), Math.max(map.getZoom(), 8), { animate: true });
+ m.openPopup?.();
+ } else if (Number.isFinite(b.lat) && Number.isFinite(b.lon)) {
+ map.setView([b.lat, b.lon], Math.max(map.getZoom(), 10), { animate: true });
+ }
+ });
+
+ list.appendChild(div);
+ listElById.set(b.ma_id, div);
+
+ // highlight visuel dans liste
+ if (q && bandMatchesQuery(b, q)) {
+ div.style.boxShadow = "0 0 0 1px rgba(198,26,26,0.15) inset";
+ }
+ }
+}
+
+function applyFilters() {
+ const q = currentQuery();
+
+ console.log("Applying filters...", {
+ query: q,
+ useViewportLoading: USE_VIEWPORT_LOADING
+ });
+
+ if (USE_VIEWPORT_LOADING) {
+ // With viewport loading, trigger a reload from server
+ loadViewportBands();
+ } else {
+ // Legacy mode: filter locally loaded data
+ filtered = geocodedBands
+ .filter(b => bandMatchesQuery(b, q))
+ .filter(b => bandMatchesCountry(b))
+ .filter(b => bandMatchesStatus(b))
+ .filter(b => bandMatchesGenre(b))
+ .filter(b => bandMatchesTheme(b))
+ .filter(b => bandMatchesYear(b));
+
+ console.log(`Filtered: ${filtered.length} bands match filters`);
+
+ rebuildLayers();
+ renderList(filtered);
+ }
+}
+
+// --- Modal for no coords ---
+const modalBackdrop = $("modalBackdrop");
+const modalBody = $("modalBody");
+const modalClose = $("modalClose");
+const modalTitle = $("modalTitle");
+
+async function loadNoLocationBands() {
+ try {
+ modalBody.innerHTML = "Chargement...
";
+
+ const j = await apiGet("/api/bands?limit=10000&geocoded=0");
+ const items = j.items || [];
+
+ modalBody.innerHTML = "";
+
+ if (!items.length) {
+ modalBody.innerHTML = `Aucun groupe sans coordonnées
`;
+ return;
+ }
+
+ for (const x of items.slice(0, 500)) {
+ const div = document.createElement("div");
+ div.className = "band";
+ const maUrl = x.ma_id ? `https://www.metal-archives.com/bands/x/${x.ma_id}` : "#";
+ div.innerHTML = `
+
+
+ `;
+ modalBody.appendChild(div);
+ }
+
+ if (items.length > 500) {
+ const more = document.createElement("div");
+ more.style.cssText = "padding:10px;color:rgba(162,176,194,0.85);text-align:center;";
+ more.textContent = `... et ${items.length - 500} autres`;
+ modalBody.appendChild(more);
+ }
+ } catch (e) {
+ modalBody.innerHTML = `Erreur: ${escapeHtml(e.message)}
`;
+ }
+}
+
+function closeModal() {
+ modalBackdrop.classList.remove("on");
+ modalBackdrop.setAttribute("aria-hidden", "true");
+}
+
+modalClose?.addEventListener("click", closeModal);
+modalBackdrop?.addEventListener("click", (e) => {
+ if (e.target === modalBackdrop) closeModal();
+});
+
+// Info modal (legal/FAQ)
+const infoBackdrop = $("infoBackdrop");
+const infoBody = $("infoBody");
+const infoClose = $("infoClose");
+const infoTitle = $("infoTitle");
+
+// LEGAL_HTML and FAQ_HTML are now generated per-language in locales.js via t('legal_html') / t('faq_html')
+
+function openInfoModal(title, html) {
+ if (!infoBackdrop || !infoBody || !infoTitle) return;
+ infoTitle.textContent = title;
+ infoBody.innerHTML = html;
+ infoBackdrop.classList.add("on");
+ infoBackdrop.setAttribute("aria-hidden", "false");
+}
+
+function closeInfoModal() {
+ if (!infoBackdrop) return;
+ infoBackdrop.classList.remove("on");
+ infoBackdrop.setAttribute("aria-hidden", "true");
+}
+
+if (infoClose) infoClose.addEventListener("click", closeInfoModal);
+if (infoBackdrop) {
+ infoBackdrop.addEventListener("click", (e) => {
+ if (e.target === infoBackdrop) closeInfoModal();
+ });
+}
+
+document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ closeModal();
+ closeInfoModal();
+ }
+});
+
+// Sidebar toggle
+function setSidebarCollapsed(collapsed) {
+ if (!appEl) return;
+ appEl.classList.toggle("sidebar-collapsed", collapsed);
+ const toggle = $("sidebarToggle");
+ if (toggle) {
+ toggle.setAttribute("aria-expanded", String(!collapsed));
+ // Update button text
+ const label = toggle.querySelector(".label");
+ if (label) {
+ label.textContent = collapsed ? t("options_btn") : t("close_btn");
+ }
+ }
+ // Refresh map size after sidebar animation
+ setTimeout(() => map?.invalidateSize?.(), 220);
+}
+
+const sidebarToggle = $("sidebarToggle");
+if (sidebarToggle) {
+ sidebarToggle.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const collapsed = appEl.classList.contains("sidebar-collapsed");
+ setSidebarCollapsed(!collapsed);
+ });
+}
+
+// Note: Sidebar closing is now controlled only via the toggle button
+// We removed the overlay auto-close behavior since users want to interact
+// with the map while the sidebar is open
+
+// Collapse sidebar by default on smaller screens
+if (window.matchMedia("(max-width: 1200px)").matches) {
+ setTimeout(() => {
+ setSidebarCollapsed(true);
+ }, 100);
+}
+
+// Language dropdown (flag-icons custom widget)
+const LANG_FLAGS = { en:"gb", fr:"fr", de:"de", es:"es", it:"it", pl:"pl", nl:"nl", ro:"ro", pt:"pt", cs:"cz", sv:"se", hu:"hu", da:"dk", fi:"fi", sk:"sk", hr:"hr", sl:"si", lt:"lt", lv:"lv", et:"ee", nb:"no" };
+
+function syncLangTrigger(lang) {
+ const trigger = document.getElementById("langTrigger");
+ if (!trigger) return;
+ const flagEl = trigger.querySelector(".lang-flag");
+ if (flagEl) flagEl.className = `fi fi-${LANG_FLAGS[lang] || "un"} lang-flag`;
+ const codeEl = trigger.querySelector(".lang-code");
+ if (codeEl) codeEl.textContent = lang.toUpperCase();
+ document.querySelectorAll(".lang-opt").forEach(b => {
+ const isCurrent = b.dataset.lang === lang;
+ b.classList.toggle("current", isCurrent);
+ // Le conteneur est un role="listbox" : les lecteurs d'écran s'appuient sur
+ // aria-selected, pas sur la classe CSS, pour annoncer la langue active.
+ b.setAttribute("aria-selected", String(isCurrent));
+ });
+}
+
+const langTrigger = document.getElementById("langTrigger");
+const langOptions = document.getElementById("langOptions");
+if (langTrigger && langOptions) {
+ langTrigger.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const open = langOptions.classList.toggle("hidden") === false;
+ langTrigger.setAttribute("aria-expanded", String(open));
+ });
+ document.addEventListener("click", () => {
+ langOptions.classList.add("hidden");
+ langTrigger.setAttribute("aria-expanded", "false");
+ });
+ langOptions.querySelectorAll(".lang-opt").forEach(btn => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ currentLang = btn.dataset.lang;
+ localStorage.setItem("lang", currentLang);
+ langOptions.classList.add("hidden");
+ langTrigger.setAttribute("aria-expanded", "false");
+ syncLangTrigger(currentLang);
+ applyI18n();
+ });
+ });
+ syncLangTrigger(currentLang);
+}
+applyI18n();
+
+// Legal/FAQ links
+const openLegal = $("openLegal");
+if (openLegal) openLegal.addEventListener("click", () => openInfoModal(t("legal_title"), t("legal_html")));
+
+const openFaq = $("openFaq");
+if (openFaq) openFaq.addEventListener("click", () => openInfoModal(t("faq_title"), t("faq_html")));
+
+// Setup dropdowns
+setupDropdown("countryToggle", "countrySelect");
+setupDropdown("genreToggle", "genreSelect");
+setupDropdown("themeToggle", "themeSelect");
+
+// --- Buttons / controls ---
+$("btnNoLocation")?.addEventListener("click", () => {
+ modalTitle.textContent = t("without_coords_modal");
+ modalBackdrop.classList.add("on");
+ modalBackdrop.setAttribute("aria-hidden", "false");
+ loadNoLocationBands();
+});
+
+// Search with suggestions
+const searchInput = $("q");
+const suggestionsEl = $("searchSuggestions");
+
+function showSearchSuggestions(query) {
+ if (!query || query.length < 2 || !allBands.length) {
+ suggestionsEl?.classList.remove("visible");
+ return;
+ }
+
+ const matches = allBands
+ .filter(b => bandMatchesQuery(b, query))
+ .slice(0, 8); // Show max 8 suggestions
+
+ if (!matches.length) {
+ suggestionsEl?.classList.remove("visible");
+ return;
+ }
+
+ if (suggestionsEl) {
+ suggestionsEl.innerHTML = matches.map(b => {
+ const statusText = b.status || "Unknown";
+ const yearText = Number.isFinite(b.formed_year) ? b.formed_year : "—";
+ return `
+
+
${escapeHtml(b.name)}
+
+ ${escapeHtml(b.country || "—")} • ${escapeHtml(statusText)} • ${yearText} • ${escapeHtml(b.genre || "—")}
+
+
+ `;
+ }).join("");
+
+ // Add click handlers
+ suggestionsEl.querySelectorAll(".suggestion-item").forEach(el => {
+ el.addEventListener("click", () => {
+ const bandId = Number(el.dataset.id);
+ const band = allBands.find(b => b.ma_id === bandId);
+ if (band && Number.isFinite(band.lat) && Number.isFinite(band.lon)) {
+ const marker = markersById.get(bandId);
+ if (marker && marker.getLatLng) {
+ map.setView(marker.getLatLng(), 10, { animate: true });
+ marker.openPopup();
+ setActive(bandId);
+ } else {
+ map.setView([band.lat, band.lon], 12, { animate: true });
+ }
+ }
+ suggestionsEl.classList.remove("visible");
+ if (searchInput) searchInput.value = band?.name || "";
+ });
+ });
+
+ suggestionsEl.classList.add("visible");
+ }
+}
+
+const debouncedSearch = debounce(() => {
+ applyFilters();
+}, 400); // 400ms delay
+
+const debouncedSuggestions = debounce((query) => {
+ showSearchSuggestions(query);
+}, 200); // 200ms delay for suggestions
+
+searchInput?.addEventListener("input", (e) => {
+ const query = e.target.value.toLowerCase().trim();
+ debouncedSuggestions(query);
+ debouncedSearch();
+});
+
+searchInput?.addEventListener("focus", () => {
+ const query = searchInput.value.toLowerCase().trim();
+ if (query.length >= 2) {
+ showSearchSuggestions(query);
+ }
+});
+
+// Close suggestions when clicking outside
+document.addEventListener("click", (e) => {
+ if (!searchInput?.contains(e.target) && !suggestionsEl?.contains(e.target)) {
+ suggestionsEl?.classList.remove("visible");
+ }
+});
+
+$("sortSelect")?.addEventListener("change", () => {
+ // Re-render list with current data
+ const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c])
+ .map(b => typeof b === 'string' ? JSON.parse(b) : b)
+ .filter(Boolean);
+ renderList(visibleBands.slice(0, 200));
+});
+
+const heatToggle = $("toggleHeat");
+if (heatToggle) {
+ heatToggle.addEventListener("change", () => setMode(heatToggle.checked ? "heat" : "clusters"));
+}
+
+$("btnReset")?.addEventListener("click", () => {
+ if (filtered.length) {
+ const pts = filtered.map(b => L.latLng(b.lat, b.lon));
+ const bounds = L.latLngBounds(pts);
+ if (bounds.isValid()) map.fitBounds(bounds.pad(0.12));
+ } else if (geocodedBands.length) {
+ const pts = geocodedBands.map(b => L.latLng(b.lat, b.lon));
+ const bounds = L.latLngBounds(pts);
+ if (bounds.isValid()) map.fitBounds(bounds.pad(0.12));
+ } else {
+ map.setView([50.2, 10.2], 4, { animate: true });
+ }
+});
+
+map.on("click", () => {
+ const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c])
+ .map(b => typeof b === 'string' ? JSON.parse(b) : b)
+ .filter(Boolean);
+ renderList(visibleBands.slice(0, 200));
+});
+
+// --- Boot ---
+function computeFacetCounts(arr, getKey) {
+ const m = new Map();
+ for (const b of arr) {
+ const val = getKey(b);
+ const k = val ? norm(val) : "Unknown";
+ m.set(k, (m.get(k) || 0) + 1);
+ }
+ return [...m.entries()]
+ .map(([key, count]) => ({ key, count }))
+ .sort((a,b) => b.count - a.count);
+}
+
+function computeTokenFacetCounts(arr, getTokens) {
+ const m = new Map();
+ for (const b of arr) {
+ const tokens = getTokens(b) || [];
+ if (!tokens.length) {
+ // Count bands without tokens as "Unknown"
+ m.set("Unknown", (m.get("Unknown") || 0) + 1);
+ } else {
+ for (const t of tokens) {
+ const k = norm(t) || "Unknown";
+ m.set(k, (m.get(k) || 0) + 1);
+ }
+ }
+ }
+ return [...m.entries()]
+ .map(([key, count]) => ({ key, count }))
+ .sort((a,b) => b.count - a.count);
+}
+
+/**
+ * Build timeline slider from year range (from facets or computed)
+ */
+function buildTimelineFromRange() {
+ const slider = $("yearSlider");
+ slider.innerHTML = "";
+
+ if (!yearRange.min || !yearRange.max) {
+ $("yearMin").textContent = "—";
+ $("yearMax").textContent = "—";
+ $("yearHint").textContent = t("year_data_missing");
+ slider.innerHTML = `${t("year_data_unavailable")}
`;
+ return;
+ }
+
+ const { min, max } = yearRange;
+
+ console.log(`Timeline: ${min} - ${max}`);
+
+ $("yearMin").textContent = String(min);
+ $("yearMax").textContent = String(max);
+ $("yearHint").textContent = t("all_years_label");
+
+ noUiSlider.create(slider, {
+ start: [min, max],
+ connect: true,
+ step: 1,
+ range: { min, max },
+ behaviour: "tap-drag",
+ tooltips: false,
+ });
+
+ slider.noUiSlider.on("update", (vals) => {
+ const a = Math.round(Number(vals[0]));
+ const b = Math.round(Number(vals[1]));
+ yearFilter = { min: a, max: b };
+ $("yearHint").textContent = (a === min && b === max) ? t("all_years_label") : `${a} → ${b}`;
+ });
+
+ slider.noUiSlider.on("change", () => {
+ console.log(`Year filter changed: ${yearFilter.min} - ${yearFilter.max}`);
+ applyFilters();
+ });
+}
+
+async function boot() {
+ try {
+ console.log("Boot: Loading data...");
+ console.log("Viewport loading mode:", USE_VIEWPORT_LOADING ? "ENABLED" : "DISABLED");
+
+ // Wait for fonts to load (can affect layout)
+ if (document.fonts && document.fonts.ready) {
+ await document.fonts.ready;
+ }
+
+ // Load facets and stats first (fast)
+ await Promise.all([
+ loadFacets(),
+ loadStats()
+ ]);
+ loadGoatCounterFooterStats(); // pas besoin d'await
+ // Build filters from facets if available, otherwise load bands first
+ let statuses, genres, countries, themes;
+
+ if (facetData) {
+ console.log("Using server-side facets for filters");
+ statuses = facetData.statuses.map(s => ({ key: s.value, count: s.count }));
+ countries = facetData.countries.map(c => ({ key: c.value, count: c.count }));
+ genres = facetData.genres.map(g => ({ key: g.value, count: g.count }));
+ themes = []; // Themes need to be computed from bands
+
+ // Set year range from facets
+ if (facetData.year_range) {
+ yearRange = {
+ min: facetData.year_range.min_year,
+ max: facetData.year_range.max_year
+ };
+ yearFilter = { ...yearRange };
+ }
+ } else {
+ // Fallback: load bands and compute facets locally
+ console.log("Loading bands for local facet computation...");
+ await loadBands();
+ statuses = computeFacetCounts(allBands, b => b.status);
+ genres = computeFacetCounts(allBands, b => b.genre);
+ countries = computeFacetCounts(allBands, b => b.country);
+ themes = computeTokenFacetCounts(allBands, b => b.themes);
+
+ }
+
+ console.log(`Boot: Facets ready - ${statuses.length} statuses, ${countries.length} countries, ${genres.length} genres`);
+
+ // Init enabled maps = true
+ for (const it of genres) genreEnabled.set(it.key, true);
+ for (const it of countries) countryEnabled.set(it.key, true);
+ for (const it of themes) themeEnabled.set(it.key, true);
+
+ buildStatusToggles(statuses);
+ buildMacroGenreButtons(genres);
+ buildMultiSelect("genreSelect", genres, genreEnabled, (it) => it.key, { summaryEl: $("genreSummary") });
+ buildMultiSelect("countrySelect", countries, countryEnabled, (it) => it.key, { summaryEl: $("countrySummary") });
+
+ if (themes.length) {
+ $("themeBox").style.display = "";
+ buildMultiSelect("themeSelect", themes, themeEnabled, (it) => it.key, { summaryEl: $("themeSummary"), emptyLabel: "Aucun thème" });
+ } else {
+ $("themeBox").style.display = "none";
+ }
+
+ // Build timeline from facet data or computed range
+ buildTimelineFromRange();
+
+ // Set mode BEFORE applying filters
+ setMode(heatToggle && heatToggle.checked ? "heat" : "clusters");
+
+ // Force map size calculation
+ console.log("Forcing map size recalculation...");
+ if (map && map.invalidateSize) {
+ map.invalidateSize(true);
+ }
+
+ // Initial data load
+ console.log("Boot: Loading initial viewport data...");
+
+ if (USE_VIEWPORT_LOADING) {
+ // Load viewport data
+ await loadViewportBands();
+ } else {
+ // Legacy: use all loaded bands
+ if (!allBands.length) await loadBands();
+ applyFilters();
+ }
+
+ console.log("Boot: Initial load complete");
+
+ // Fit to Europe by default
+ setTimeout(() => {
+ if (map && map.invalidateSize) {
+ map.invalidateSize(true);
+ }
+ // Default view: Europe
+ map.setView([50.2, 10.2], 4);
+ }, 100);
+
+ // Load all bands in background for heatmap and search
+ setTimeout(() => loadAllHeatPoints(), 1000);
+
+ } catch (err) {
+ console.error("Boot error:", err);
+ $("list").innerHTML = `Erreur de chargement: ${escapeHtml(err.message || String(err))}
`;
+ }
+}
+
+// Make sure DOM is ready before booting
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', boot);
+} else {
+ boot();
+}
diff --git a/apps/web/site/index.html b/apps/web/site/index.html
index aaeefa0..6c77e40 100644
--- a/apps/web/site/index.html
+++ b/apps/web/site/index.html
@@ -48,18 +48,18 @@
EN
▾
-
-
English
-
Français
-
Deutsch
-
Español
-
Italiano
-
Polski
-
Nederlands
-
Română
-
Português
-
Čeština
-
Svenska
+
+ English
+ Français
+ Deutsch
+ Español
+ Italiano
+ Polski
+ Nederlands
+ Română
+ Português
+ Čeština
+ Svenska
@@ -257,6 +257,8 @@
+
+