feat(admin): refonte du panneau par intention, vue Localisations, tests e2e
Le panneau était découpé par table SQL, pas par question que se pose l'admin. Conséquence : « je constate ici, j'agis dans un autre onglet », et aucun moyen de voir ce qui coince réellement. Redécoupage — un onglet = une question - Pilotage : est-ce que ça tourne ? (fusionne l'ancien « Actions ») - Groupes : trouver et corriger - Localisations : qu'est-ce qui coince dans le géocodage ? ← NOUVEAU - Journal : que s'est-il passé ? - LLM & coûts : combien ça coûte ? Pilotage : chaque chiffre problématique porte son action - Bandeau de santé (groupes, géocodage, file, bloqués, coût) coloré par état - Alertes actionnables : « 12 localisations en erreur » + [Examiner] + [Tout remettre en file], au lieu d'un mur de boutons dans un autre onglet - Déclencheurs de traitements inline, opérations destructives repliées - L'ancien bloc « Éléments de genre » (des centaines de mots-clés jamais consultés) est retiré Localisations : la vue qui manquait totalement L'admin ne disposait que d'actions EN MASSE sur band_locations et d'aucun moyen de voir CE qui échouait. Débloquer un seul lieu supposait de relancer des milliers d'appels Geoapify/Groq facturés. - GET /admin/api/locations liste filtrable (statut, lieu, groupe, pays) - POST /admin/api/locations/:id/requeue relance UNE ligne - PATCH /admin/api/locations/:id saisie manuelle des coordonnées - Diagnostic lisible sans clic : lieu brut, statut, essais, erreur réelle Groupes : recherche d'abord Une barre de recherche et des filtres rapides en chips remplacent les 8 champs texte ; les filtres avancés sont repliés. Accessibilité - Lignes de tableau activables au clavier (role=button, tabindex, Entrée) - Modales : Échap ferme, focus piégé, focus rendu à l'élément d'origine - Contraste : --err (#c61a1a) échouait WCAG AA en texte sur fond sombre (3,4:1). Les messages d'erreur étaient difficiles à lire. Ajout de --err-text / --ok-text (~6,5:1) pour les usages en couleur de texte. Détecté par les nouveaux tests axe sur les vues rendues. - Statuts affichés en français au lieu des valeurs brutes de la base Tests - 36 tests API sur les trois nouveaux endpoints (allowlist de statuts, bornes des coordonnées, audit, 404) - 53 parcours Playwright sur la VRAIE app Fastify + faux pool, dans une topologie identique à la production (statique servi + /admin/* proxifié) - Tests axe sur les vues RENDUES : le test jsdom existant ne voyait que la coquille vide du dashboard, tout étant construit en JavaScript - e2e branché sur le hook pre-push, pas sur `check` : la boucle de développement reste à 6 s, le push coûte 28 s Correction trouvée par les tests Le routeur ne séparait pas la query string du nom de vue : « #/locations?status=error » ne correspondait à aucun alias et retombait sur Pilotage — les liens des alertes ne fonctionnaient pas. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
366bb8630d
commit
f14060720b
17 changed files with 2571 additions and 854 deletions
|
|
@ -11,10 +11,12 @@
|
||||||
#
|
#
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "▶ pre-push : porte de qualité (~6 s)…"
|
echo "▶ pre-push : porte de qualité + parcours e2e (~30 s)…"
|
||||||
|
|
||||||
start=$(date +%s)
|
start=$(date +%s)
|
||||||
|
|
||||||
|
# `check` d'abord (6 s) : la majorité des régressions y tombent, autant échouer
|
||||||
|
# vite. Les parcours Playwright (~22 s) ensuite, seulement si le reste est vert.
|
||||||
if ! npm run --silent check; then
|
if ! npm run --silent check; then
|
||||||
cat >&2 <<'MSG'
|
cat >&2 <<'MSG'
|
||||||
|
|
||||||
|
|
@ -29,4 +31,18 @@ MSG
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! npm run --silent test:e2e; then
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
|
||||||
|
✗ Les parcours e2e ont échoué — push interrompu.
|
||||||
|
|
||||||
|
Rejouer en mode interactif pour comprendre :
|
||||||
|
npm run test:e2e:ui
|
||||||
|
|
||||||
|
Sur un clone neuf, récupérer d'abord le navigateur :
|
||||||
|
npm run test:e2e:install
|
||||||
|
MSG
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✓ pre-push : tout est vert ($(( $(date +%s) - start ))s)"
|
echo "✓ pre-push : tout est vert ($(( $(date +%s) - start ))s)"
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -25,3 +25,7 @@ CLAUDE.md
|
||||||
|
|
||||||
# Caches d outillage (lint, tsc)
|
# Caches d outillage (lint, tsc)
|
||||||
node_modules/.cache/
|
node_modules/.cache/
|
||||||
|
|
||||||
|
# Artefacts Playwright
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
|
||||||
37
README-CI.md
37
README-CI.md
|
|
@ -7,10 +7,15 @@ Coolify redéploie sur webhook à **chaque** push (`dev` → dev.metalfrom.eu,
|
||||||
**locale**, dans le hook `pre-push`.
|
**locale**, dans le hook `pre-push`.
|
||||||
|
|
||||||
```
|
```
|
||||||
git push ──▶ [hook pre-push : npm run check] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy
|
git push ──▶ [pre-push : npm run check + test:e2e] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy
|
||||||
↑ 6 s, bloquant
|
↑ 6 s + 22 s, bloquant
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Deux granularités volontaires : `check` (6 s) tourne pendant qu'on code, les
|
||||||
|
parcours Playwright (22 s) seulement au moment de pousser. Mettre l'e2e dans
|
||||||
|
`check` ferait passer la boucle de 6 à 22 s pour un gain marginal — la plupart
|
||||||
|
des régressions tombent déjà dans les tests unitaires.
|
||||||
|
|
||||||
## Installation (une fois par clone)
|
## Installation (une fois par clone)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -27,7 +32,9 @@ pip install -r requirements-dev.txt
|
||||||
| `npm run check:sequential` | Idem, en série (pour isoler un échec) | 14 s | — |
|
| `npm run check:sequential` | Idem, en série (pour isoler un échec) | 14 s | — |
|
||||||
| `npm run test:mutation` | Mutation, logique pure (352 mutants) | 33 s | ~5 s |
|
| `npm run test:mutation` | Mutation, logique pure (352 mutants) | 33 s | ~5 s |
|
||||||
| `npm run test:mutation:full` | Mutation, toute l'API (1511 mutants) | 3 min 40 | **12 s** |
|
| `npm run test:mutation:full` | Mutation, toute l'API (1511 mutants) | 3 min 40 | **12 s** |
|
||||||
| `npm run check:full` | `check` + audits de dépendances + mutation complète | — | ~40 s |
|
| `npm run test:e2e` | 53 parcours Playwright (dashboard admin) | 22 s | — |
|
||||||
|
| `npm run test:e2e:install` | Récupère Chromium (clone neuf) | — | — |
|
||||||
|
| `npm run check:full` | `check` + e2e + audits + mutation complète | — | ~1 min |
|
||||||
| `npm run check:clean` | Purge les caches ESLint / tsc | — | — |
|
| `npm run check:clean` | Purge les caches ESLint / tsc | — | — |
|
||||||
|
|
||||||
### Où passe le temps
|
### Où passe le temps
|
||||||
|
|
@ -77,6 +84,8 @@ Le plancher de ~3 min à froid est celui de Stryker sur 1511 mutants, assumé.
|
||||||
| **Frontend** | `apps/web/test/pure.test.js` | Filtrage, tri, échappement HTML |
|
| **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 |
|
| **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 |
|
| **Python** | `apps/geocoder/tests/`, `apps/crawler/tests/` | Parseur de localisations, annulation |
|
||||||
|
| **Parcours (e2e)** | `apps/admin/test/e2e/admin.spec.js` | Playwright sur la VRAIE app Fastify + faux pool : connexion, recherche, édition, déblocage d'une localisation, annulation d'un traitement |
|
||||||
|
| **a11y des vues rendues** | `apps/admin/test/e2e/a11y.spec.js` | axe-core dans un vrai navigateur — contraste inclus, ce que jsdom ne sait pas calculer |
|
||||||
| **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 65 % (API complète) |
|
| **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 65 % (API complète) |
|
||||||
|
|
||||||
## Principe des tests
|
## Principe des tests
|
||||||
|
|
@ -110,10 +119,28 @@ dit que « cette ligne a été exécutée ».
|
||||||
Les seuils d'échec (70 % et 55 %) sont des cliquets anti-régression, pas des
|
Les seuils d'échec (70 % et 55 %) sont des cliquets anti-régression, pas des
|
||||||
objectifs.
|
objectifs.
|
||||||
|
|
||||||
|
## Les parcours e2e
|
||||||
|
|
||||||
|
`apps/admin/test/e2e/server.mjs` reproduit la topologie de production : un
|
||||||
|
serveur HTTP sert les fichiers statiques et proxifie `/admin/*` vers la VRAIE
|
||||||
|
application Fastify (comme `apps/admin/nginx.conf`), avec un faux pool à la
|
||||||
|
place de Postgres.
|
||||||
|
|
||||||
|
Sont donc réellement exercés : le routage Fastify, les cookies, le JWT, bcrypt,
|
||||||
|
la validation des entrées et la génération SQL. Ne le sont pas : la validité du
|
||||||
|
SQL pour Postgres et le comportement de PostGIS — affaire de tests
|
||||||
|
d'intégration, pas de tests d'interface.
|
||||||
|
|
||||||
|
Démarrage ~1 s, aucun conteneur. Une session est enregistrée une fois
|
||||||
|
(`auth.setup.js`) et partagée : sans ça, chacun des 53 scénarios repayait un
|
||||||
|
aller-retour de connexion complet. 4 workers est l'optimum mesuré (8 et 12 sont
|
||||||
|
plus lents — contention au démarrage des navigateurs).
|
||||||
|
|
||||||
### Ce qui n'est pas couvert
|
### Ce qui n'est pas couvert
|
||||||
|
|
||||||
- Règles a11y exigeant un moteur de rendu (contraste, cibles tactiles) : jsdom
|
- Règles a11y exigeant un moteur de rendu sur le **site public** : le test jsdom
|
||||||
ne calcule pas de styles. À compléter par un audit Lighthouse manuel.
|
ne calcule pas les styles. Le dashboard admin, lui, est couvert par axe dans
|
||||||
|
un vrai navigateur.
|
||||||
- `migrate.js` : s'exécute au démarrage du conteneur et appelle `process.exit`.
|
- `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
|
- 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.
|
couverts, le reste est de l'I/O réseau et base.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -29,12 +29,12 @@
|
||||||
<div id="app" class="app hidden">
|
<div id="app" class="app hidden">
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<div class="brand">Admin</div>
|
<div class="brand">Admin</div>
|
||||||
<nav id="nav" class="nav">
|
<nav id="nav" class="nav" aria-label="Sections">
|
||||||
<a href="#/dashboard" data-view="dashboard">Vue d'ensemble</a>
|
<a href="#/pilotage" data-view="pilotage">Pilotage</a>
|
||||||
<a href="#/bands" data-view="bands">Groupes</a>
|
<a href="#/bands" data-view="bands">Groupes</a>
|
||||||
<a href="#/activity" data-view="activity">Activité</a>
|
<a href="#/locations" data-view="locations">Localisations</a>
|
||||||
<a href="#/actions" data-view="actions">Actions</a>
|
<a href="#/activity" data-view="activity">Journal</a>
|
||||||
<a href="#/llm" data-view="llm">LLM</a>
|
<a href="#/llm" data-view="llm">LLM & coûts</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<span id="whoami" class="whoami"></span>
|
<span id="whoami" class="whoami"></span>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@
|
||||||
--ok: #3fae5a;
|
--ok: #3fae5a;
|
||||||
--warn: #d99a2b;
|
--warn: #d99a2b;
|
||||||
--err: #c61a1a;
|
--err: #c61a1a;
|
||||||
|
/* --err sert aux bordures et aux fonds. Pour du TEXTE sur fond sombre il
|
||||||
|
échoue au contraste WCAG AA (3,4:1) : les messages d'erreur du dashboard
|
||||||
|
étaient difficiles à lire. --err-text monte à ~6,5:1. Détecté par les
|
||||||
|
tests axe sur les vues rendues (apps/admin/test/e2e/a11y.spec.js). */
|
||||||
|
--err-text: #ff6b6b;
|
||||||
|
--ok-text: #5fd47c;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
@ -99,7 +105,7 @@ a { color: inherit; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-error {
|
.login-error {
|
||||||
color: var(--err);
|
color: var(--err-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
min-height: 14px;
|
min-height: 14px;
|
||||||
|
|
@ -207,9 +213,9 @@ a { color: inherit; }
|
||||||
|
|
||||||
.stat-card .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; font-weight: 700; }
|
.stat-card .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; font-weight: 700; }
|
||||||
.stat-card .value { font-size: 30px; font-weight: 800; margin-top: 6px; color: var(--bone); }
|
.stat-card .value { font-size: 30px; font-weight: 800; margin-top: 6px; color: var(--bone); }
|
||||||
.stat-card .value.ok { color: var(--ok); }
|
.stat-card .value.ok { color: var(--ok-text); }
|
||||||
.stat-card .value.warn { color: var(--warn); }
|
.stat-card .value.warn { color: var(--warn); }
|
||||||
.stat-card .value.err { color: var(--err); }
|
.stat-card .value.err { color: var(--err-text); }
|
||||||
|
|
||||||
h2 { font-size: 16px; margin: 0 0 14px; color: var(--bone); }
|
h2 { font-size: 16px; margin: 0 0 14px; color: var(--bone); }
|
||||||
|
|
||||||
|
|
@ -254,9 +260,9 @@ tbody tr:hover { background: rgba(198,26,26,0.05); }
|
||||||
tbody tr.clickable { cursor: pointer; }
|
tbody tr.clickable { cursor: pointer; }
|
||||||
|
|
||||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }
|
||||||
.badge.ok { background: rgba(63,174,90,0.15); color: var(--ok); }
|
.badge.ok { background: rgba(63,174,90,0.15); color: var(--ok-text); }
|
||||||
.badge.warn { background: rgba(217,154,43,0.15); color: var(--warn); }
|
.badge.warn { background: rgba(217,154,43,0.15); color: var(--warn); }
|
||||||
.badge.err { background: rgba(198,26,26,0.15); color: var(--err); }
|
.badge.err { background: rgba(198,26,26,0.15); color: var(--err-text); }
|
||||||
.badge.muted { background: rgba(162,176,194,0.15); color: var(--muted); }
|
.badge.muted { background: rgba(162,176,194,0.15); color: var(--muted); }
|
||||||
|
|
||||||
.pager { display: flex; align-items: center; gap: 10px; margin-top: 14px; font-size: 12px; color: var(--muted); }
|
.pager { display: flex; align-items: center; gap: 10px; margin-top: 14px; font-size: 12px; color: var(--muted); }
|
||||||
|
|
@ -279,7 +285,7 @@ tbody tr.clickable { cursor: pointer; }
|
||||||
font-size: 13px; outline: none; font-family: inherit;
|
font-size: 13px; outline: none; font-family: inherit;
|
||||||
}
|
}
|
||||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
|
||||||
.modal-error { color: var(--err); font-size: 12px; margin-top: 10px; }
|
.modal-error { color: var(--err-text); font-size: 12px; margin-top: 10px; }
|
||||||
.modal-close { float: right; cursor: pointer; color: var(--muted); font-size: 18px; line-height: 1; }
|
.modal-close { float: right; cursor: pointer; color: var(--muted); font-size: 18px; line-height: 1; }
|
||||||
|
|
||||||
.log-line { font-family: ui-monospace, Consolas, monospace; font-size: 12px; padding: 4px 0; border-top: 1px solid rgba(255,255,255,0.04); display: flex; gap: 10px; }
|
.log-line { font-family: ui-monospace, Consolas, monospace; font-size: 12px; padding: 4px 0; border-top: 1px solid rgba(255,255,255,0.04); display: flex; gap: 10px; }
|
||||||
|
|
@ -287,7 +293,7 @@ tbody tr.clickable { cursor: pointer; }
|
||||||
.log-line .lvl { flex-shrink: 0; width: 56px; font-weight: 700; }
|
.log-line .lvl { flex-shrink: 0; width: 56px; font-weight: 700; }
|
||||||
.log-line .lvl.info { color: var(--muted); }
|
.log-line .lvl.info { color: var(--muted); }
|
||||||
.log-line .lvl.warning { color: var(--warn); }
|
.log-line .lvl.warning { color: var(--warn); }
|
||||||
.log-line .lvl.error { color: var(--err); }
|
.log-line .lvl.error { color: var(--err-text); }
|
||||||
|
|
||||||
.empty { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
|
.empty { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
|
||||||
.loading { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
|
.loading { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
|
||||||
|
|
@ -349,3 +355,178 @@ tbody tr.clickable { cursor: pointer; }
|
||||||
border-top: 1px solid rgba(255,255,255,0.05);
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
.mon-run-row:first-child { border-top: none; padding-top: 0; }
|
.mon-run-row:first-child { border-top: none; padding-top: 0; }
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
Refonte : composants du découpage par intention
|
||||||
|
================================================================== */
|
||||||
|
|
||||||
|
/* --- En-tête de vue --- */
|
||||||
|
.view-head {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between;
|
||||||
|
gap: 16px; margin-bottom: 4px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.view-head h1 { font-size: 20px; margin: 0 0 8px; color: var(--bone); letter-spacing: .2px; }
|
||||||
|
.refresh-note { font-size: 11px; color: var(--muted); }
|
||||||
|
.muted-note { font-size: 12px; color: var(--muted); margin: 0 0 14px; line-height: 1.5; }
|
||||||
|
.mt { margin-top: 18px; }
|
||||||
|
.count { font-weight: 400; color: var(--muted); }
|
||||||
|
.sub { font-size: 11px; color: var(--muted); }
|
||||||
|
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
.truncate { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.err-text { color: var(--err-text); }
|
||||||
|
.warn-text { color: var(--warn); }
|
||||||
|
.empty { padding: 18px; text-align: center; color: var(--muted); font-size: 13px; }
|
||||||
|
.err-box { color: var(--err-text); }
|
||||||
|
.loading { padding: 18px; color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* --- Bandeau de santé --- */
|
||||||
|
.health-strip {
|
||||||
|
display: grid; gap: 12px; margin-bottom: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
.health-card {
|
||||||
|
background: rgba(255,255,255,0.03); border: 1px solid var(--border);
|
||||||
|
border-radius: 14px; padding: 12px 14px;
|
||||||
|
border-left: 3px solid var(--muted);
|
||||||
|
}
|
||||||
|
.health-card.ok { border-left-color: var(--ok); }
|
||||||
|
.health-card.warn { border-left-color: var(--warn); }
|
||||||
|
.health-card.err { border-left-color: var(--err); }
|
||||||
|
.health-card .h-label {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
|
||||||
|
color: var(--muted); font-weight: 700;
|
||||||
|
}
|
||||||
|
.health-card .h-value { font-size: 24px; font-weight: 800; color: var(--bone); margin: 4px 0 2px; }
|
||||||
|
.health-card .h-sub { font-size: 11px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* --- Alertes actionnables : le chiffre et son remède côte à côte --- */
|
||||||
|
.alerts { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
|
||||||
|
.alert {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 14px; border-radius: 12px; font-size: 13px;
|
||||||
|
background: rgba(255,255,255,0.03); border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--muted);
|
||||||
|
}
|
||||||
|
.alert-err { border-left-color: var(--err); background: rgba(198,26,26,0.07); }
|
||||||
|
.alert-warn { border-left-color: var(--warn); background: rgba(217,154,43,0.06); }
|
||||||
|
.alert-ok { border-left-color: var(--ok); background: rgba(63,174,90,0.06); }
|
||||||
|
.alert-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
|
.alert-actions .btn { text-decoration: none; }
|
||||||
|
|
||||||
|
/* --- Boutons --- */
|
||||||
|
.btn-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.btn-col { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.btn-col .btn { justify-content: flex-start; text-align: left; }
|
||||||
|
.btn .hint { font-weight: 400; font-size: 11px; color: var(--muted); margin-left: 8px; }
|
||||||
|
.btn-danger { border-color: rgba(198,26,26,0.5); color: #f0b8b8; }
|
||||||
|
.btn-danger:hover { background: rgba(198,26,26,0.18); }
|
||||||
|
.btn-primary-sm {
|
||||||
|
background: rgba(198,26,26,0.18); border-color: rgba(198,26,26,0.45);
|
||||||
|
padding: 8px 14px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; }
|
||||||
|
.link-btn {
|
||||||
|
background: none; border: 0; padding: 0; cursor: pointer;
|
||||||
|
color: var(--bone); font: inherit; text-align: left; text-decoration: underline;
|
||||||
|
text-decoration-color: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
.link-btn:hover { color: #fff; }
|
||||||
|
.th-sort {
|
||||||
|
background: none; border: 0; padding: 0; cursor: pointer;
|
||||||
|
color: inherit; font: inherit; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Recherche et chips --- */
|
||||||
|
.search-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.search-bar input[type="search"], .search-bar input[type="text"], .search-bar select {
|
||||||
|
flex: 1 1 240px; min-width: 140px;
|
||||||
|
background: rgba(255,255,255,0.04); border: 1px solid var(--border);
|
||||||
|
color: var(--text); padding: 9px 12px; border-radius: 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.search-bar input:focus, .search-bar select:focus {
|
||||||
|
outline: none; border-color: rgba(198,26,26,0.45);
|
||||||
|
}
|
||||||
|
.inline-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; align-items: center; }
|
||||||
|
.chip {
|
||||||
|
background: rgba(255,255,255,0.04); border: 1px solid var(--border);
|
||||||
|
color: var(--muted); padding: 6px 12px; border-radius: 999px;
|
||||||
|
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--text); border-color: rgba(255,255,255,0.2); }
|
||||||
|
.chip.on {
|
||||||
|
background: rgba(198,26,26,0.18); border-color: rgba(198,26,26,0.5); color: var(--bone);
|
||||||
|
}
|
||||||
|
.chip-clear { margin-left: auto; }
|
||||||
|
.chip-sep { width: 1px; height: 20px; background: var(--border); }
|
||||||
|
|
||||||
|
/* --- Traitements en cours --- */
|
||||||
|
.run-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 10px 0; border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.run-row:first-of-type { border-top: 0; }
|
||||||
|
.run-meta { font-size: 11px; color: var(--muted); margin-top: 5px; }
|
||||||
|
.run-cancel-note { font-size: 11px; color: var(--warn); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* --- Zone dangereuse --- */
|
||||||
|
.danger-zone { border-color: rgba(198,26,26,0.3); margin-top: 20px; }
|
||||||
|
.danger-zone summary {
|
||||||
|
cursor: pointer; font-weight: 700; color: var(--err-text); font-size: 13px;
|
||||||
|
}
|
||||||
|
.danger-zone[open] summary { margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.feedback { font-size: 12px; margin-top: 10px; color: var(--muted); min-height: 1em; }
|
||||||
|
.feedback.ok { color: var(--ok-text); }
|
||||||
|
.feedback.err { color: var(--err-text); }
|
||||||
|
|
||||||
|
/* --- Tables : lignes activables au clavier --- */
|
||||||
|
tr.clickable { cursor: pointer; }
|
||||||
|
tr.clickable:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
tr.clickable:focus-visible { outline: 2px solid var(--blood); outline-offset: -2px; }
|
||||||
|
.lock { color: var(--blood); }
|
||||||
|
|
||||||
|
/* --- Modales --- */
|
||||||
|
.modal-wide { max-width: 900px; }
|
||||||
|
.modal-section { margin-bottom: 20px; }
|
||||||
|
.modal-section > h4 { font-size: 13px; margin: 0 0 10px; color: var(--bone); }
|
||||||
|
.modal-section > summary { cursor: pointer; font-weight: 700; color: var(--muted); font-size: 13px; }
|
||||||
|
.modal-section.conflicts {
|
||||||
|
border: 1px solid rgba(198,26,26,0.35); border-radius: 12px; padding: 12px;
|
||||||
|
}
|
||||||
|
.modal-section.conflicts h4 { color: var(--err-text); }
|
||||||
|
.field-grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
||||||
|
.field-grid label { margin-bottom: 0; }
|
||||||
|
.src-grid { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
|
||||||
|
.src-grid > div { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }
|
||||||
|
.src-grid > div > span:first-child { color: var(--muted); }
|
||||||
|
.modal-close {
|
||||||
|
float: right; background: none; border: 0; cursor: pointer;
|
||||||
|
color: var(--muted); font-size: 22px; line-height: 1; padding: 0 4px;
|
||||||
|
}
|
||||||
|
.modal-close:hover { color: var(--text); }
|
||||||
|
.json, .llm-detail {
|
||||||
|
font-size: 11px; white-space: pre-wrap; word-break: break-word;
|
||||||
|
background: rgba(255,255,255,0.03); padding: 8px; border-radius: 6px;
|
||||||
|
max-height: 300px; overflow: auto; color: var(--muted); margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
.llm-item { margin: 4px 0; background: rgba(255,255,255,0.03); border-radius: 6px; padding: 6px 8px; }
|
||||||
|
.llm-item summary { cursor: pointer; font-size: 12px; }
|
||||||
|
.detail-row td { padding-top: 0; }
|
||||||
|
.detail-row summary { cursor: pointer; font-size: 11px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* --- Logs --- */
|
||||||
|
.log-stream { max-height: 400px; overflow-y: auto; }
|
||||||
|
.log-line .msg { word-break: break-word; }
|
||||||
|
.lvl-error { color: var(--err-text); }
|
||||||
|
.lvl-warning { color: var(--warn); }
|
||||||
|
.lvl-info { color: var(--muted); }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.view-head { flex-direction: column; align-items: flex-start; }
|
||||||
|
.alert { flex-direction: column; align-items: flex-start; }
|
||||||
|
.truncate { max-width: 140px; }
|
||||||
|
}
|
||||||
|
|
|
||||||
89
apps/admin/test/e2e/a11y.spec.js
Normal file
89
apps/admin/test/e2e/a11y.spec.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessibilité des vues RENDUES.
|
||||||
|
*
|
||||||
|
* Le test statique (apps/web/test/a11y.test.js) analyse le HTML livré : pour le
|
||||||
|
* dashboard, ce n'est qu'une coquille vide, puisque tout est construit en
|
||||||
|
* JavaScript. C'est donc ici, dans un vrai navigateur après rendu, que
|
||||||
|
* l'accessibilité du dashboard est réellement vérifiée — contraste des couleurs
|
||||||
|
* compris, ce que jsdom ne sait pas calculer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VIEWS = [
|
||||||
|
["pilotage", "Pilotage"],
|
||||||
|
["bands", "Groupes"],
|
||||||
|
["locations", "Localisations"],
|
||||||
|
["activity", "Journal"],
|
||||||
|
["llm", "LLM & coûts"],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function analyse(page) {
|
||||||
|
return new AxeBuilder({ page })
|
||||||
|
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||||
|
.analyze();
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeViolations(results) {
|
||||||
|
return results.violations.map((v) =>
|
||||||
|
`[${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.map((n) => n.target.join(" ")).join("\n ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Accessibilité des vues", () => {
|
||||||
|
for (const [view, title] of VIEWS) {
|
||||||
|
test(`${title} — aucune violation critique ou sérieuse`, async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click(`.nav a[data-view="${view}"]`);
|
||||||
|
await expect(page.locator(".view-head h1")).toHaveText(title);
|
||||||
|
// Laisse les chargements asynchrones peindre leur contenu.
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const results = await analyse(page);
|
||||||
|
const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact));
|
||||||
|
expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("l'écran de connexion est accessible", async ({ page }) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator("#login-screen")).toBeVisible();
|
||||||
|
const results = await analyse(page);
|
||||||
|
const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact));
|
||||||
|
expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la fiche d'un groupe reste accessible une fois ouverte", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click('.nav a[data-view="bands"]');
|
||||||
|
await page.locator('tr[data-ma-id="1"]').click();
|
||||||
|
await expect(page.locator(".modal-backdrop .modal")).toBeVisible();
|
||||||
|
|
||||||
|
const results = await analyse(page);
|
||||||
|
const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact));
|
||||||
|
expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Navigation au clavier", () => {
|
||||||
|
test("on atteint la navigation puis le contenu à la tabulation", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
const firstFocus = await page.evaluate(() => document.activeElement?.getAttribute("data-view"));
|
||||||
|
expect(firstFocus).toBe("pilotage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le focus est piégé dans la fiche ouverte", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click('.nav a[data-view="bands"]');
|
||||||
|
await page.locator('tr[data-ma-id="1"]').click();
|
||||||
|
await expect(page.locator(".modal-backdrop")).toBeVisible();
|
||||||
|
|
||||||
|
// Vingt tabulations doivent toutes rester à l'intérieur de la modale :
|
||||||
|
// sinon le focus repart derrière le voile, invisible pour l'utilisateur.
|
||||||
|
for (let i = 0; i < 20; i++) await page.keyboard.press("Tab");
|
||||||
|
const inside = await page.evaluate(() => !!document.activeElement?.closest(".modal-backdrop"));
|
||||||
|
expect(inside).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
431
apps/admin/test/e2e/admin.spec.js
Normal file
431
apps/admin/test/e2e/admin.spec.js
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parcours du dashboard admin.
|
||||||
|
*
|
||||||
|
* Ces tests décrivent ce qu'un administrateur vient faire, dans l'ordre où il
|
||||||
|
* le fait — c'est le critère qui a guidé le redécoupage des onglets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const USER = "nico";
|
||||||
|
const PASSWORD = "motdepasse-e2e";
|
||||||
|
|
||||||
|
/** Connexion explicite — utilisée uniquement par le bloc Authentification. */
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.fill("#login-username", USER);
|
||||||
|
await page.fill("#login-password", PASSWORD);
|
||||||
|
await page.click('#login-form button[type="submit"]');
|
||||||
|
await expect(page.locator("#app")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
test.describe("Authentification", () => {
|
||||||
|
// Seul bloc à repartir sans session : c'est le parcours de connexion qu'il teste.
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test("l'écran de connexion s'affiche tant qu'on n'est pas identifié", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator("#login-screen")).toBeVisible();
|
||||||
|
await expect(page.locator("#app")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un mauvais mot de passe affiche une erreur et ne laisse pas entrer", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.fill("#login-username", USER);
|
||||||
|
await page.fill("#login-password", "mauvais");
|
||||||
|
await page.click('#login-form button[type="submit"]');
|
||||||
|
|
||||||
|
await expect(page.locator("#login-error")).toHaveText(/identifiants invalides/i);
|
||||||
|
await expect(page.locator("#app")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la connexion donne accès au dashboard et affiche l'utilisateur", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await expect(page.locator("#whoami")).toHaveText(USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le champ mot de passe est vidé après connexion", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await expect(page.locator("#login-password")).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la déconnexion ramène à l'écran de connexion", async ({ page }) => {
|
||||||
|
// Ce bloc n'a pas de session partagée : il faut se connecter d'abord.
|
||||||
|
await login(page);
|
||||||
|
await page.click("#logout-btn");
|
||||||
|
await expect(page.locator("#login-screen")).toBeVisible();
|
||||||
|
await expect(page.locator("#app")).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
test.describe("Navigation", () => {
|
||||||
|
test.beforeEach(async ({ page }) => { await page.goto("/"); });
|
||||||
|
|
||||||
|
test("les cinq sections sont accessibles et marquent l'onglet actif", async ({ page }) => {
|
||||||
|
const sections = [
|
||||||
|
["pilotage", "Pilotage"],
|
||||||
|
["bands", "Groupes"],
|
||||||
|
["locations", "Localisations"],
|
||||||
|
["activity", "Journal"],
|
||||||
|
["llm", "LLM & coûts"],
|
||||||
|
];
|
||||||
|
for (const [view, title] of sections) {
|
||||||
|
await page.click(`.nav a[data-view="${view}"]`);
|
||||||
|
await expect(page.locator(".view-head h1")).toHaveText(title);
|
||||||
|
await expect(page.locator(`.nav a[data-view="${view}"]`)).toHaveAttribute("aria-current", "page");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'ancienne URL #/actions redirige vers Pilotage (favoris préservés)", async ({ page }) => {
|
||||||
|
await page.goto("/#/actions");
|
||||||
|
await expect(page.locator(".view-head h1")).toHaveText("Pilotage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une URL inconnue retombe sur Pilotage", async ({ page }) => {
|
||||||
|
await page.goto("/#/nimportequoi");
|
||||||
|
await expect(page.locator(".view-head h1")).toHaveText("Pilotage");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
test.describe("Pilotage — est-ce que ça tourne ?", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click('.nav a[data-view="pilotage"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le bandeau de santé résume l'état du pipeline", async ({ page }) => {
|
||||||
|
const strip = page.locator("#p-health");
|
||||||
|
await expect(strip.locator(".health-card")).toHaveCount(5);
|
||||||
|
await expect(strip).toContainText("Géocodage");
|
||||||
|
await expect(strip).toContainText("Coût LLM");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le cœur de la refonte : plus de « je constate ici, j'agis ailleurs ».
|
||||||
|
test("chaque problème est affiché avec le bouton qui le résout", async ({ page }) => {
|
||||||
|
const alert = page.locator(".alert-err").filter({ hasText: "erreur de géocodage" });
|
||||||
|
await expect(alert).toBeVisible();
|
||||||
|
await expect(alert).toContainText("12");
|
||||||
|
await expect(alert.getByRole("button", { name: "Tout remettre en file" })).toBeVisible();
|
||||||
|
await expect(alert.getByRole("link", { name: "Examiner" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("« Examiner » amène sur les localisations filtrées sur le bon statut", async ({ page }) => {
|
||||||
|
await page.locator(".alert").filter({ hasText: "erreur de géocodage" })
|
||||||
|
.getByRole("link", { name: "Examiner" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(".view-head h1")).toHaveText("Localisations");
|
||||||
|
await expect(page.locator('.chip[data-status="error"]')).toHaveAttribute("aria-pressed", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les traitements peuvent être lancés depuis le pilotage", async ({ page }) => {
|
||||||
|
await page.click('[data-job="enrich"]');
|
||||||
|
await expect(page.locator("#p-job-feedback")).toContainText("Demande #77 enregistrée");
|
||||||
|
await expect(page.locator("#p-job-feedback")).toHaveClass(/ok/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les opérations destructives sont repliées et prévenues", async ({ page }) => {
|
||||||
|
const danger = page.locator("#p-danger");
|
||||||
|
await expect(danger.locator(".btn-danger").first()).toBeHidden();
|
||||||
|
await danger.locator("summary").click();
|
||||||
|
await expect(danger.locator('[data-danger="reset-all"]')).toBeVisible();
|
||||||
|
await expect(danger).toContainText("facturés");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une opération destructive demande confirmation et respecte le refus", async ({ page }) => {
|
||||||
|
page.on("dialog", (d) => d.dismiss());
|
||||||
|
await page.locator("#p-danger summary").click();
|
||||||
|
await page.click('[data-danger="reset-all"]');
|
||||||
|
await expect(page.locator("#p-danger-feedback")).toBeEmpty();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
test.describe("Pilotage — annulation coopérative", () => {
|
||||||
|
test("un run actif peut être arrêté, et l'état intermédiaire est explicite", async ({ page }) => {
|
||||||
|
// Un run actif depuis 5 min, non encore annulé.
|
||||||
|
await page.route("**/admin/api/live", async (route) => {
|
||||||
|
const res = await route.fetch();
|
||||||
|
const body = await res.json();
|
||||||
|
body.active_runs = [{
|
||||||
|
id: 5, run_type: "enrich", status: "running",
|
||||||
|
started_at: new Date(Date.now() - 5 * 60000).toISOString(),
|
||||||
|
bands_seen: 120, bands_new: 3, bands_updated: 0, bands_enriched: 118,
|
||||||
|
error: null, cancel_requested: false,
|
||||||
|
}];
|
||||||
|
await route.fulfill({ response: res, json: body });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click('.nav a[data-view="pilotage"]');
|
||||||
|
await expect(page.locator(".run-row")).toContainText("enrich");
|
||||||
|
|
||||||
|
// Le libellé doit dire que l'arrêt est demandé, pas immédiat : le crawler
|
||||||
|
// ne s'interrompt qu'à son prochain point de contrôle.
|
||||||
|
const dialog = new Promise((resolve) => page.once("dialog", (d) => { resolve(d.message()); d.accept(); }));
|
||||||
|
await page.click("[data-cancel-run]");
|
||||||
|
expect(await dialog).toMatch(/point de contrôle/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un run dont l'arrêt est déjà demandé affiche l'attente et désactive le bouton", async ({ page }) => {
|
||||||
|
await page.route("**/admin/api/live", async (route) => {
|
||||||
|
const res = await route.fetch();
|
||||||
|
const body = await res.json();
|
||||||
|
body.active_runs = [{
|
||||||
|
id: 5, run_type: "full_europe", status: "running",
|
||||||
|
started_at: new Date(Date.now() - 60000).toISOString(),
|
||||||
|
bands_seen: 10, bands_new: 0, bands_updated: 0, bands_enriched: 0,
|
||||||
|
error: null, cancel_requested: true, cancel_requested_by: "nico",
|
||||||
|
}];
|
||||||
|
await route.fulfill({ response: res, json: body });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click('.nav a[data-view="pilotage"]');
|
||||||
|
await expect(page.locator(".run-cancel-note")).toContainText("Arrêt demandé");
|
||||||
|
await expect(page.locator(".run-cancel-note")).toContainText("nico");
|
||||||
|
await expect(page.locator(".run-row button")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
test.describe("Groupes — trouver et corriger", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click('.nav a[data-view="bands"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la liste s'affiche avec le nombre total", async ({ page }) => {
|
||||||
|
await expect(page.locator("#b-table tbody tr")).toHaveCount(3);
|
||||||
|
await expect(page.locator("#b-count")).toContainText("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la recherche part sur Entrée et transmet le terme à l'API", async ({ page }) => {
|
||||||
|
const request = page.waitForRequest((r) => r.url().includes("q=mayhem"));
|
||||||
|
await page.fill("#b-q", "mayhem");
|
||||||
|
await page.press("#b-q", "Enter");
|
||||||
|
await request;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les filtres rapides sont des bascules", async ({ page }) => {
|
||||||
|
const chip = page.locator('.chip[data-chip="has_conflict"]');
|
||||||
|
await expect(chip).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await chip.click();
|
||||||
|
await expect(page.locator('.chip[data-chip="has_conflict"]')).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await page.locator('.chip[data-chip="has_conflict"]').click();
|
||||||
|
await expect(page.locator('.chip[data-chip="has_conflict"]')).toHaveAttribute("aria-pressed", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les filtres avancés sont masqués par défaut", async ({ page }) => {
|
||||||
|
await expect(page.locator("#b-advanced")).toBeHidden();
|
||||||
|
await page.click("#b-advanced-toggle");
|
||||||
|
await expect(page.locator("#b-advanced")).toBeVisible();
|
||||||
|
await expect(page.locator("#b-advanced-toggle")).toHaveAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un groupe sans coordonnées est signalé dans la liste", async ({ page }) => {
|
||||||
|
const row = page.locator('tr[data-ma-id="2"]');
|
||||||
|
await expect(row.locator('[title="Pas de coordonnées"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un groupe avec des champs verrouillés porte un cadenas", async ({ page }) => {
|
||||||
|
await expect(page.locator('tr[data-ma-id="2"] .lock')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cliquer une ligne ouvre la fiche du groupe", async ({ page }) => {
|
||||||
|
await page.locator('tr[data-ma-id="1"]').click();
|
||||||
|
const modal = page.locator(".modal-backdrop .modal");
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await expect(modal.locator("h3")).toContainText("Mayhem");
|
||||||
|
await expect(modal.locator("#m-name")).toHaveValue("Mayhem");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Les lignes de tableau sont souvent inaccessibles au clavier : ici elles
|
||||||
|
// portent role=button et tabindex, et répondent à Entrée.
|
||||||
|
test("une ligne s'ouvre aussi au clavier", async ({ page }) => {
|
||||||
|
await page.locator('tr[data-ma-id="1"]').focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.locator(".modal-backdrop .modal")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la fiche se ferme avec Échap et rend le focus", async ({ page }) => {
|
||||||
|
await page.locator('tr[data-ma-id="1"]').click();
|
||||||
|
await expect(page.locator(".modal-backdrop")).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.locator(".modal-backdrop")).toHaveCount(0);
|
||||||
|
await expect(page.locator('tr[data-ma-id="1"]')).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'édition envoie bien les champs modifiés", async ({ page }) => {
|
||||||
|
await page.locator('tr[data-ma-id="1"]').click();
|
||||||
|
await page.fill("#m-name", "Mayhem (corrigé)");
|
||||||
|
|
||||||
|
const request = page.waitForRequest(
|
||||||
|
(r) => r.url().includes("/admin/api/bands/1") && r.method() === "PATCH");
|
||||||
|
await page.click("#m-save");
|
||||||
|
const body = JSON.parse((await request).postData());
|
||||||
|
expect(body.name).toBe("Mayhem (corrigé)");
|
||||||
|
await expect(page.locator(".modal-backdrop")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une erreur serveur reste dans la fiche au lieu de la fermer", async ({ page }) => {
|
||||||
|
await page.route("**/admin/api/bands/1", async (route) => {
|
||||||
|
if (route.request().method() === "PATCH") {
|
||||||
|
return route.fulfill({ status: 400, json: { ok: false, error: "formed_year invalide" } });
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
await page.locator('tr[data-ma-id="1"]').click();
|
||||||
|
await page.click("#m-save");
|
||||||
|
await expect(page.locator("#m-error")).toHaveText("formed_year invalide");
|
||||||
|
await expect(page.locator(".modal-backdrop")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
test.describe("Localisations — débloquer le géocodage", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click('.nav a[data-view="locations"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la vue s'ouvre sur ce qui est à débloquer", async ({ page }) => {
|
||||||
|
await expect(page.locator('.chip[data-status="error,llm_needed"]')).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(page.locator("#l-table tbody tr")).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chaque ligne montre le diagnostic sans avoir à cliquer", async ({ page }) => {
|
||||||
|
const row = page.locator("tr[data-loc-id='11']");
|
||||||
|
await expect(row).toContainText("Kolbotn");
|
||||||
|
await expect(row).toContainText("erreur");
|
||||||
|
await expect(row).toContainText("3 géo · 0 LLM"); // essais géocodeur / LLM
|
||||||
|
await expect(row).toContainText("seuil de confiance"); // message d'erreur réel
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un filtre de statut recharge la liste avec le bon paramètre", async ({ page }) => {
|
||||||
|
const request = page.waitForRequest((r) => r.url().includes("status=llm_needed"));
|
||||||
|
await page.locator('.chip[data-status="llm_needed"]').click();
|
||||||
|
await request;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auparavant il fallait relancer des milliers d'appels facturés pour
|
||||||
|
// débloquer un seul lieu.
|
||||||
|
test("une localisation peut être relancée seule", async ({ page }) => {
|
||||||
|
const request = page.waitForRequest(
|
||||||
|
(r) => r.url().includes("/admin/api/locations/11/requeue") && r.method() === "POST");
|
||||||
|
await page.locator("tr[data-loc-id='11'] [data-requeue]").click();
|
||||||
|
await request;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les coordonnées peuvent être saisies à la main", async ({ page }) => {
|
||||||
|
await page.locator("tr[data-loc-id='11'] [data-setcoords]").click();
|
||||||
|
const modal = page.locator(".modal-backdrop .modal");
|
||||||
|
await expect(modal).toContainText("Kolbotn");
|
||||||
|
|
||||||
|
await modal.locator("#c-lat").fill("59.7955");
|
||||||
|
await modal.locator("#c-lon").fill("10.8");
|
||||||
|
|
||||||
|
const request = page.waitForRequest(
|
||||||
|
(r) => r.url().includes("/admin/api/locations/11") && r.method() === "PATCH");
|
||||||
|
await modal.locator("#c-save").click();
|
||||||
|
const body = JSON.parse((await request).postData());
|
||||||
|
expect(body).toEqual({ lat: "59.7955", lon: "10.8" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("des coordonnées invalides sont refusées et le message reste affiché", async ({ page }) => {
|
||||||
|
await page.locator("tr[data-loc-id='11'] [data-setcoords]").click();
|
||||||
|
await page.locator("#c-lat").fill("999");
|
||||||
|
await page.locator("#c-lon").fill("10");
|
||||||
|
await page.locator("#c-save").click();
|
||||||
|
await expect(page.locator("#c-error")).toHaveText(/lat invalide/);
|
||||||
|
await expect(page.locator(".modal-backdrop")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le nom du groupe renvoie vers sa fiche", async ({ page }) => {
|
||||||
|
await page.locator("tr[data-loc-id='11'] [data-open-band]").click();
|
||||||
|
await expect(page.locator(".modal-backdrop .modal h3")).toContainText("Darkthrone");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
test.describe("Journal", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click('.nav a[data-view="activity"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("traitements et actions admin apparaissent dans une même chronologie", async ({ page }) => {
|
||||||
|
await expect(page.locator("#a-table tbody tr")).toHaveCount(2);
|
||||||
|
await expect(page.locator("#a-table")).toContainText("Traitement");
|
||||||
|
await expect(page.locator("#a-table")).toContainText("Admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ouvrir un traitement affiche son journal détaillé", async ({ page }) => {
|
||||||
|
await page.locator("#a-table tbody tr").first().click();
|
||||||
|
const modal = page.locator(".modal-backdrop .modal");
|
||||||
|
await expect(modal.locator("h3")).toContainText("Traitement #5");
|
||||||
|
await expect(modal.locator("#act-logs")).toContainText("enrich done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ouvrir une action admin montre l'avant et l'après", async ({ page }) => {
|
||||||
|
await page.locator("#a-table tbody tr").nth(1).click();
|
||||||
|
const modal = page.locator(".modal-backdrop .modal");
|
||||||
|
await expect(modal.locator("h3")).toContainText("Action admin");
|
||||||
|
await expect(modal).toContainText("Avant");
|
||||||
|
await expect(modal).toContainText("Après");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le filtre par type recharge la liste", async ({ page }) => {
|
||||||
|
const request = page.waitForRequest((r) => r.url().includes("type=run"));
|
||||||
|
await page.locator('.chip[data-atype="run"]').click();
|
||||||
|
await request;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
test.describe("LLM & coûts", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.click('.nav a[data-view="llm"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le coût est mis en avant, pas enterré", async ({ page }) => {
|
||||||
|
await expect(page.locator("#llm-summary")).toContainText("Coût total");
|
||||||
|
await expect(page.locator("#llm-summary")).toContainText("$0.0412");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chaque appel expose son prompt et sa réponse brute", async ({ page }) => {
|
||||||
|
await expect(page.locator("#llm-list")).toContainText("Bayonne");
|
||||||
|
await page.locator("#llm-list details summary").first().click();
|
||||||
|
await expect(page.locator(".llm-detail").first()).toContainText("PROMPT:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
test.describe("Robustesse", () => {
|
||||||
|
test("une session expirée renvoie à l'écran de connexion", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.route("**/admin/api/**", (route) =>
|
||||||
|
route.fulfill({ status: 401, json: { ok: false, error: "unauthorized" } }));
|
||||||
|
await page.click('.nav a[data-view="bands"]');
|
||||||
|
await expect(page.locator("#login-screen")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une erreur serveur est affichée, pas avalée en silence", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.route("**/admin/api/bands**", (route) =>
|
||||||
|
route.fulfill({ status: 500, json: { ok: false, error: "Erreur recherche bands" } }));
|
||||||
|
await page.click('.nav a[data-view="bands"]');
|
||||||
|
await expect(page.locator("#b-table .err-box")).toContainText("Erreur recherche bands");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une liste vide affiche un message, pas un tableau vide", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.route("**/admin/api/locations?*", (route) =>
|
||||||
|
route.fulfill({ status: 200, json: { ok: true, items: [], total: 0, page: 1, pageSize: 50 } }));
|
||||||
|
await page.click('.nav a[data-view="locations"]');
|
||||||
|
await expect(page.locator("#l-table")).toContainText("Aucune localisation");
|
||||||
|
});
|
||||||
|
});
|
||||||
21
apps/admin/test/e2e/auth.setup.js
Normal file
21
apps/admin/test/e2e/auth.setup.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { test as setup, expect } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Se connecte une fois et enregistre le cookie de session.
|
||||||
|
*
|
||||||
|
* Sans ça, chacun des ~45 scénarios repayait un aller-retour complet
|
||||||
|
* (chargement + bcrypt + rendu), soit l'essentiel de la durée de la suite.
|
||||||
|
* Le parcours de connexion lui-même reste testé explicitement dans
|
||||||
|
* admin.spec.js, qui repart d'un contexte vierge.
|
||||||
|
*/
|
||||||
|
export const STORAGE_STATE = path.join(process.cwd(), "node_modules/.cache/playwright/admin-auth.json");
|
||||||
|
|
||||||
|
setup("authentification", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.fill("#login-username", "nico");
|
||||||
|
await page.fill("#login-password", "motdepasse-e2e");
|
||||||
|
await page.click('#login-form button[type="submit"]');
|
||||||
|
await expect(page.locator("#app")).toBeVisible();
|
||||||
|
await page.context().storageState({ path: STORAGE_STATE });
|
||||||
|
});
|
||||||
140
apps/admin/test/e2e/fixtures.mjs
Normal file
140
apps/admin/test/e2e/fixtures.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* Jeu de données du serveur e2e.
|
||||||
|
*
|
||||||
|
* Le faux pool associe un fragment de SQL à un résultat : l'ordre compte, le
|
||||||
|
* premier fragment qui correspond gagne. Les entrées les plus spécifiques
|
||||||
|
* doivent donc précéder les plus générales.
|
||||||
|
*/
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { rows } from "../../../api/test/helpers/fakePool.js";
|
||||||
|
|
||||||
|
export const E2E_USER = "nico";
|
||||||
|
export const E2E_PASSWORD = "motdepasse-e2e";
|
||||||
|
// Coût 4 : suffisant pour exercer le vrai chemin bcrypt sans les ~330 ms du
|
||||||
|
// coût 12 utilisé en production.
|
||||||
|
const PASSWORD_HASH = bcrypt.hashSync(E2E_PASSWORD, 4);
|
||||||
|
|
||||||
|
const BANDS = [
|
||||||
|
{ ma_id: 1, name: "Mayhem", country: "NO", status: "Active", genre: "Black Metal",
|
||||||
|
location_text: "Oslo", themes: "Death, Satanism", formed_year: 1984, enriched: true,
|
||||||
|
lat: 59.91, lon: 10.75, crawled_at: "2026-08-01T10:00:00Z", updated_at: "2026-08-10T10:00:00Z",
|
||||||
|
first_seen_at: "2026-01-01T00:00:00Z", locked_fields: {} },
|
||||||
|
{ ma_id: 2, name: "Darkthrone", country: "NO", status: "Active", genre: "Black Metal",
|
||||||
|
location_text: "Kolbotn", themes: "Cold, Winter", formed_year: 1986, enriched: true,
|
||||||
|
lat: null, lon: null, crawled_at: "2026-08-02T10:00:00Z", updated_at: "2026-08-11T10:00:00Z",
|
||||||
|
first_seen_at: "2026-01-01T00:00:00Z", locked_fields: { name: true } },
|
||||||
|
{ ma_id: 3, name: "Gojira", country: "FR", status: "Active", genre: "Progressive Death Metal",
|
||||||
|
location_text: "Bayonne", themes: "Nature", formed_year: 1996, enriched: false,
|
||||||
|
lat: 43.49, lon: -1.47, crawled_at: null, updated_at: "2026-08-12T10:00:00Z",
|
||||||
|
first_seen_at: "2026-02-01T00:00:00Z", locked_fields: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOCATIONS = [
|
||||||
|
{ id: 11, ma_id: 2, band_name: "Darkthrone", country: "NO", step_order: 0, step_label: null,
|
||||||
|
location_raw: "Kolbotn", is_country_only: false, lat: null, lon: null,
|
||||||
|
geocode_status: "error", geocode_provider: null, geocode_confidence: null,
|
||||||
|
geocode_granularity: null, geocode_query: "Kolbotn, Norway",
|
||||||
|
geocode_error: "Aucun résultat au-dessus du seuil de confiance",
|
||||||
|
geocode_tries_geo: 3, geocode_tries_llm: 0,
|
||||||
|
geocode_next_at: "2026-08-12T09:00:00Z", updated_at: "2026-08-12T09:00:00Z" },
|
||||||
|
{ id: 12, ma_id: 3, band_name: "Gojira", country: "FR", step_order: 0, step_label: "early",
|
||||||
|
location_raw: "Ondres / Bayonne", is_country_only: false, lat: null, lon: null,
|
||||||
|
geocode_status: "llm_needed", geocode_provider: null, geocode_confidence: null,
|
||||||
|
geocode_granularity: null, geocode_query: null, geocode_error: null,
|
||||||
|
geocode_tries_geo: 3, geocode_tries_llm: 1,
|
||||||
|
geocode_next_at: "2026-08-12T08:00:00Z", updated_at: "2026-08-12T08:00:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} [over] surcharges par scénario (ex. aucun run actif)
|
||||||
|
*/
|
||||||
|
export function seedHandlers(over = {}) {
|
||||||
|
const {
|
||||||
|
activeRuns = [],
|
||||||
|
pendingJobs = [],
|
||||||
|
geoStatuses = [
|
||||||
|
{ status: "done", n: 1840 },
|
||||||
|
{ status: "error", n: 12 },
|
||||||
|
{ status: "llm_needed", n: 5 },
|
||||||
|
{ status: "queued", n: 3 },
|
||||||
|
],
|
||||||
|
bands = BANDS,
|
||||||
|
locations = LOCATIONS,
|
||||||
|
neverEnriched = 1,
|
||||||
|
} = over;
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ---- Authentification ----
|
||||||
|
// Fragment volontairement précis : « SELECT count(*)::int AS n » seul
|
||||||
|
// capturait aussi le comptage du cache de géocodage.
|
||||||
|
{ match: "FROM admin_login_attempts\n WHERE success = false", result: rows({ n: 0 }) },
|
||||||
|
{ match: "FROM admin_users", result: rows({ password_hash: PASSWORD_HASH }) },
|
||||||
|
{ match: "INSERT INTO admin_login_attempts", result: rows() },
|
||||||
|
{ match: "UPDATE admin_users", result: rows() },
|
||||||
|
|
||||||
|
// ---- Écritures ----
|
||||||
|
{ match: "INSERT INTO admin_audit_log", result: rows() },
|
||||||
|
{ match: "INSERT INTO crawl_log", result: rows() },
|
||||||
|
{ match: "INSERT INTO job_triggers", result: rows({ id: 77 }) },
|
||||||
|
{ match: "UPDATE crawl_run", result: rows({ id: 5, run_type: "enrich" }) },
|
||||||
|
{ match: "UPDATE job_triggers", result: rows({ id: 7, job_type: "enrich" }) },
|
||||||
|
|
||||||
|
// ---- Détail d'un groupe (avant les listes : fragment plus spécifique) ----
|
||||||
|
{ match: "SELECT * FROM bands WHERE ma_id", result: (_s, v) => rows(bands.find((b) => b.ma_id === Number(v[0])) || bands[0]) },
|
||||||
|
{ match: "UPDATE bands SET", result: (_s, v) => rows({ ...bands[0], ma_id: Number(v[0]), name: v[1] ?? bands[0].name }) },
|
||||||
|
|
||||||
|
// ---- Localisations ----
|
||||||
|
{ match: "SELECT bl.id, bl.ma_id", result: rows(...locations) },
|
||||||
|
{ match: "count(*)::int AS total\n FROM band_locations", result: rows({ total: locations.length }) },
|
||||||
|
{ match: "SELECT * FROM band_locations", result: rows(locations[0]) },
|
||||||
|
{ match: "UPDATE band_locations", result: rows({ ...locations[0], lat: 59.79, lon: 10.8, geocode_status: "done" }) },
|
||||||
|
{ match: "FROM band_locations\n WHERE ma_id", result: rows(locations[0]) },
|
||||||
|
|
||||||
|
// ---- Statistiques ----
|
||||||
|
{ match: "never_enriched", result: rows({
|
||||||
|
total: 1950, enriched: 1200, not_enriched: 750, geocoded: 1840, not_geocoded: 110,
|
||||||
|
crawled_by_current_system: 1900, never_enriched: neverEnriched, stale: 4 }) },
|
||||||
|
{ match: "new_bands", result: rows({ new_bands: 3, modified_since_enrich: 1, legacy_pending: 0, stale: 4 }) },
|
||||||
|
{ match: "run_type = 'enrich'", result: rows() },
|
||||||
|
{ match: "AS status, count(*)::int AS total", result: rows({ status: "Active", total: 1500 }, { status: "Split-up", total: 450 }) },
|
||||||
|
{ match: "AS country, count", result: rows({ country: "NO", total: 900 }, { country: "FR", total: 400 }) },
|
||||||
|
{ match: "SELECT genre, count", result: rows({ genre: "Black Metal", total: 800 }) },
|
||||||
|
|
||||||
|
// ---- Supervision ----
|
||||||
|
{ match: "WHERE status = 'running'", result: rows(...activeRuns) },
|
||||||
|
{ match: "WHERE status = 'pending'", result: rows(...pendingJobs) },
|
||||||
|
{ match: "geocode_status = 'processing'", result: rows() },
|
||||||
|
{ match: "GROUP BY geocode_status", result: rows(...geoStatuses) },
|
||||||
|
{ match: "FROM crawl_checkpoint", result: rows({ key: "last_full_crawl_at", value: "2026-08-01", updated_at: "2026-08-01T00:00:00Z" }) },
|
||||||
|
|
||||||
|
// ---- Géocodage / LLM ----
|
||||||
|
{ match: "FROM geocode_cache", result: rows({ n: 1500 }) },
|
||||||
|
{ match: "b.geocoded_at IS NOT NULL", result: rows() },
|
||||||
|
{ match: "sum(cost_usd)", result: rows({ n: 320, total_cost_usd: "0.041200", n_null: 18 }) },
|
||||||
|
{ match: "COALESCE(geocode_provider", result: rows({ provider: "geoapify", n: 1800, avg_conf: "0.91" }) },
|
||||||
|
{ match: "SELECT model,", result: rows({ model: "llama-3.3-70b-versatile", n: 320, n_null: 18, cost: "0.041200" }) },
|
||||||
|
{ match: "FROM llm_cache lc", result: rows({
|
||||||
|
id: 3, ma_id: 3, band_name: "Gojira", model: "llama-3.3-70b-versatile",
|
||||||
|
location_raw: "Ondres / Bayonne", country: "FR", parsed_city: "Bayonne",
|
||||||
|
parsed_country: "FR", is_null: false, tokens_in: 180, tokens_out: 12,
|
||||||
|
cost_usd: "0.000090", prompt: "Extract the city…", response: '{"city":"Bayonne"}',
|
||||||
|
created_at: "2026-08-12T08:00:00Z" }) },
|
||||||
|
|
||||||
|
// ---- Journal ----
|
||||||
|
{ match: "UNION ALL", result: (sql) => sql.includes("count(*)")
|
||||||
|
? rows({ total: 2 })
|
||||||
|
: rows(
|
||||||
|
{ type: "run", id: 5, subtype: "enrich", status: "done", actor: null,
|
||||||
|
ts: "2026-08-12T07:00:00Z", finished_at: "2026-08-12T07:30:00Z",
|
||||||
|
summary: { bands_seen: 500, bands_new: 12, bands_updated: 30, bands_enriched: 480, error: null, countries: null } },
|
||||||
|
{ type: "admin_action", id: 2, subtype: "update", status: "done", actor: "nico",
|
||||||
|
ts: "2026-08-12T06:00:00Z", finished_at: "2026-08-12T06:00:00Z",
|
||||||
|
summary: { target_table: "bands", target_id: "1", before: { name: "Mayhem" }, after: { name: "Mayhem" } } }) },
|
||||||
|
{ match: "FROM crawl_log", result: rows({ id: 1, run_id: 5, level: "info", message: "enrich done", ma_id: null, created_at: "2026-08-12T07:30:00Z" }) },
|
||||||
|
|
||||||
|
// ---- Liste des groupes (fragments génériques : en dernier) ----
|
||||||
|
{ match: "SELECT ma_id, name, country", result: rows(...bands) },
|
||||||
|
{ match: "count(*)::int AS total FROM bands", result: rows({ total: bands.length }) },
|
||||||
|
{ match: "FROM llm_cache", result: rows() },
|
||||||
|
];
|
||||||
|
}
|
||||||
119
apps/admin/test/e2e/server.mjs
Normal file
119
apps/admin/test/e2e/server.mjs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* Serveur de test pour les parcours Playwright.
|
||||||
|
*
|
||||||
|
* Reproduit la topologie de production : nginx sert les fichiers statiques du
|
||||||
|
* dashboard et proxifie `/admin/api/*` et `/admin/auth/*` vers l'API (voir
|
||||||
|
* apps/admin/nginx.conf). Ici, un petit serveur HTTP joue le rôle de nginx et
|
||||||
|
* transmet à la VRAIE application Fastify — routage, cookies, JWT, bcrypt,
|
||||||
|
* validation et génération SQL sont donc ceux de production — avec un faux pool
|
||||||
|
* à la place de Postgres.
|
||||||
|
*
|
||||||
|
* Ce qui est réellement exercé : les parcours d'interface, l'authentification,
|
||||||
|
* les états de chargement et d'erreur, l'accessibilité au clavier.
|
||||||
|
* Ce qui ne l'est pas : la validité du SQL pour Postgres et le comportement de
|
||||||
|
* PostGIS — affaire de tests d'intégration, pas de tests d'interface.
|
||||||
|
*/
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { buildServer } from "../../../api/src/app.js";
|
||||||
|
import { makeFakePool, rows } from "../../../api/test/helpers/fakePool.js";
|
||||||
|
import { seedHandlers } from "./fixtures.mjs";
|
||||||
|
|
||||||
|
const SITE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../site");
|
||||||
|
|
||||||
|
// Secret de session propre aux tests. Sans lui, signAdminSession() lève et la
|
||||||
|
// connexion renvoie un 500 opaque — exactement le piège documenté dans
|
||||||
|
// infra/.env.example pour la production.
|
||||||
|
process.env.ADMIN_JWT_SECRET ||= "secret-e2e-suffisamment-long-pour-passer-32";
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".png": "image/png",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Les mêmes préfixes que ceux proxifiés par nginx.conf. */
|
||||||
|
const API_PREFIXES = ["/admin/api/", "/admin/auth/", "/api/"];
|
||||||
|
|
||||||
|
export async function startTestServer({ port = 0, handlers = seedHandlers() } = {}) {
|
||||||
|
const pool = makeFakePool(handlers);
|
||||||
|
|
||||||
|
const app = await buildServer({
|
||||||
|
pool,
|
||||||
|
logger: false,
|
||||||
|
importToken: "jeton-e2e",
|
||||||
|
corsOrigins: ["http://127.0.0.1"],
|
||||||
|
seedAdmin: false,
|
||||||
|
// Les parcours enchaînent beaucoup d'appels ; les plafonds réels sont
|
||||||
|
// vérifiés séparément dans apps/api/test/rateLimit.test.js.
|
||||||
|
globalRateLimitMax: 1e9,
|
||||||
|
adminRateLimitMax: 1e9,
|
||||||
|
authRateLimitMax: 1e9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const front = createServer(async (req, res) => {
|
||||||
|
const url = req.url || "/";
|
||||||
|
|
||||||
|
if (API_PREFIXES.some((p) => url.startsWith(p))) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const c of req) chunks.push(c);
|
||||||
|
const payload = chunks.length ? Buffer.concat(chunks) : undefined;
|
||||||
|
|
||||||
|
// `inject()` recalcule content-length à partir du payload : retransmettre
|
||||||
|
// celui du client donne un corps tronqué, donc un 400 « champ requis ».
|
||||||
|
const headers = { ...req.headers };
|
||||||
|
delete headers["content-length"];
|
||||||
|
delete headers["transfer-encoding"];
|
||||||
|
|
||||||
|
// inject() traverse tout le cycle de vie Fastify (hooks, plugins,
|
||||||
|
// sérialisation) : c'est la même exécution qu'une requête réseau.
|
||||||
|
const reply = await app.inject({ method: req.method, url, headers, payload });
|
||||||
|
res.writeHead(reply.statusCode, reply.headers);
|
||||||
|
res.end(reply.rawPayload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rel = url.split("?")[0].replace(/^\/+/, "") || "index.html";
|
||||||
|
const file = path.resolve(SITE, rel);
|
||||||
|
if (!file.startsWith(SITE)) {
|
||||||
|
res.writeHead(403).end("forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buf = await readFile(file);
|
||||||
|
res.writeHead(200, { "content-type": MIME[path.extname(file)] || "application/octet-stream" });
|
||||||
|
res.end(buf);
|
||||||
|
} catch {
|
||||||
|
// Application monopage : toute route inconnue retombe sur index.html
|
||||||
|
res.writeHead(200, { "content-type": MIME[".html"] });
|
||||||
|
res.end(await readFile(path.join(SITE, "index.html")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => front.listen(port, "127.0.0.1", resolve));
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
pool,
|
||||||
|
url: `http://127.0.0.1:${front.address().port}`,
|
||||||
|
/** Reprogramme les réponses de la base entre deux scénarios. */
|
||||||
|
setHandlers: (h) => pool.reset(h),
|
||||||
|
close: async () => {
|
||||||
|
await new Promise((r) => front.close(r));
|
||||||
|
await app.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { rows };
|
||||||
|
|
||||||
|
// Démarrage autonome, utilisé par `webServer` dans playwright.config.js.
|
||||||
|
if (process.env.E2E_STANDALONE === "1") {
|
||||||
|
const srv = await startTestServer({ port: Number(process.env.E2E_PORT || 4319) });
|
||||||
|
console.log(`e2e server: ${srv.url}`);
|
||||||
|
}
|
||||||
|
|
@ -630,6 +630,164 @@ export default async function adminRoutes(fastify, opts) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Localisations — consultation ligne par ligne.
|
||||||
|
//
|
||||||
|
// L'admin ne disposait que d'actions EN MASSE sur band_locations (tout
|
||||||
|
// remettre en file, tout réinitialiser) et d'aucun moyen de voir CE qui
|
||||||
|
// coince. Débloquer un lieu supposait donc de relancer des milliers d'appels
|
||||||
|
// Geoapify/Groq payants pour en corriger un seul.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const LOCATION_STATUSES = new Set([
|
||||||
|
"queued", "processing", "done", "country_only", "error", "llm_needed", "manual",
|
||||||
|
]);
|
||||||
|
|
||||||
|
fastify.get("/admin/api/locations", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const { status, q, country, provider } = req.query || {};
|
||||||
|
const { page, pageSize, offset } = pagination(req.query || {});
|
||||||
|
|
||||||
|
const where = [];
|
||||||
|
const vals = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
// Allowlist : `status` finit dans un ANY($n) paramétré, mais on refuse
|
||||||
|
// tôt les valeurs inconnues pour éviter des filtres silencieusement vides.
|
||||||
|
const wanted = String(status).split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const invalid = wanted.filter((s) => !LOCATION_STATUSES.has(s));
|
||||||
|
if (invalid.length) {
|
||||||
|
return reply.code(400).send({ ok: false, error: `statut inconnu: ${invalid.join(", ")}` });
|
||||||
|
}
|
||||||
|
if (wanted.length) {
|
||||||
|
where.push(`bl.geocode_status = ANY($${i}::text[])`);
|
||||||
|
vals.push(wanted);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
const needle = String(q).trim().slice(0, 100);
|
||||||
|
if (needle.length >= 1) {
|
||||||
|
where.push(`(bl.location_raw ILIKE $${i} OR b.name ILIKE $${i})`);
|
||||||
|
vals.push(`%${needle}%`);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (country) {
|
||||||
|
where.push(`b.country = $${i}`);
|
||||||
|
vals.push(String(country).toUpperCase());
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (provider) {
|
||||||
|
where.push(`bl.geocode_provider = $${i}`);
|
||||||
|
vals.push(String(provider));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const [rowsRes, countRes] = await Promise.all([
|
||||||
|
pool.query(`
|
||||||
|
SELECT bl.id, bl.ma_id, b.name AS band_name, b.country,
|
||||||
|
bl.step_order, bl.step_label, bl.location_raw, bl.is_country_only,
|
||||||
|
bl.lat, bl.lon, bl.geocode_status, bl.geocode_provider,
|
||||||
|
bl.geocode_confidence, bl.geocode_granularity, bl.geocode_query,
|
||||||
|
bl.geocode_error, bl.geocode_tries_geo, bl.geocode_tries_llm,
|
||||||
|
bl.geocode_next_at, bl.updated_at
|
||||||
|
FROM band_locations bl
|
||||||
|
JOIN bands b ON b.ma_id = bl.ma_id
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY bl.updated_at DESC, bl.id DESC
|
||||||
|
LIMIT $${i} OFFSET $${i + 1}
|
||||||
|
`, [...vals, pageSize, offset]),
|
||||||
|
pool.query(`
|
||||||
|
SELECT count(*)::int AS total
|
||||||
|
FROM band_locations bl JOIN bands b ON b.ma_id = bl.ma_id
|
||||||
|
${whereSql}
|
||||||
|
`, vals),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { ok: true, items: rowsRes.rows, total: countRes.rows[0].total, page, pageSize };
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err);
|
||||||
|
return reply.code(500).send({ ok: false, error: "Erreur liste localisations" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remet UNE localisation en file (au lieu des milliers du bouton global).
|
||||||
|
fastify.post("/admin/api/locations/:id/requeue", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id) || id < 0) {
|
||||||
|
return reply.code(400).send({ ok: false, error: "bad id" });
|
||||||
|
}
|
||||||
|
const r = await pool.query(`
|
||||||
|
UPDATE band_locations
|
||||||
|
SET geocode_status='queued',
|
||||||
|
geocode_tries_geo=0, geocode_tries_llm=0,
|
||||||
|
geocode_query=NULL, geocode_error=NULL,
|
||||||
|
geocode_next_at=now(), updated_at=now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, ma_id, location_raw
|
||||||
|
`, [id]);
|
||||||
|
if (!r.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
|
||||||
|
|
||||||
|
await writeAuditLog(pool, req.adminUsername, "requeue_location", "band_locations", id, null, r.rows[0]);
|
||||||
|
return { ok: true, item: r.rows[0] };
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err);
|
||||||
|
return reply.code(500).send({ ok: false, error: "Erreur requeue localisation" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Saisie manuelle des coordonnées d'une localisation que le pipeline n'a pas
|
||||||
|
// su résoudre. Marque 'manual' (et non 'done') pour rester distinguable d'un
|
||||||
|
// résultat automatique dans les statistiques.
|
||||||
|
fastify.patch("/admin/api/locations/:id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id) || id < 0) {
|
||||||
|
return reply.code(400).send({ ok: false, error: "bad id" });
|
||||||
|
}
|
||||||
|
const { lat, lon } = req.body || {};
|
||||||
|
const latN = lat === null || lat === undefined || lat === "" ? null : Number(lat);
|
||||||
|
const lonN = lon === null || lon === undefined || lon === "" ? null : Number(lon);
|
||||||
|
if (latN === null || lonN === null) {
|
||||||
|
return reply.code(400).send({ ok: false, error: "lat et lon requis" });
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(latN) || latN < -90 || latN > 90) {
|
||||||
|
return reply.code(400).send({ ok: false, error: "lat invalide" });
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(lonN) || lonN < -180 || lonN > 180) {
|
||||||
|
return reply.code(400).send({ ok: false, error: "lon invalide" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = await pool.query(`SELECT * FROM band_locations WHERE id = $1`, [id]);
|
||||||
|
if (!before.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
|
||||||
|
|
||||||
|
const r = await pool.query(`
|
||||||
|
UPDATE band_locations
|
||||||
|
SET lat = $2, lon = $3,
|
||||||
|
geocode_status = 'done',
|
||||||
|
geocode_provider = 'admin',
|
||||||
|
geocode_confidence = 1.0,
|
||||||
|
geocode_error = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
`, [id, latN, lonN]);
|
||||||
|
|
||||||
|
await writeAuditLog(
|
||||||
|
pool, req.adminUsername, "set_location_coords", "band_locations", id,
|
||||||
|
before.rows[0], r.rows[0]
|
||||||
|
);
|
||||||
|
return { ok: true, item: r.rows[0] };
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err);
|
||||||
|
return reply.code(500).send({ ok: false, error: "Erreur mise à jour localisation" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Purge les entrées de cache issues de l'ancien pipeline Nominatim (raw avec
|
// Purge les entrées de cache issues de l'ancien pipeline Nominatim (raw avec
|
||||||
// place_rank). Force le worker à re-géocoder ces lieux via Geoapify.
|
// place_rank). Force le worker à re-géocoder ces lieux via Geoapify.
|
||||||
fastify.post("/admin/api/geocode-cache/purge-nominatim", async (req, reply) => {
|
fastify.post("/admin/api/geocode-cache/purge-nominatim", async (req, reply) => {
|
||||||
|
|
|
||||||
232
apps/api/test/locations.test.js
Normal file
232
apps/api/test/locations.test.js
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
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(); });
|
||||||
|
|
||||||
|
function buildApp(handlers) {
|
||||||
|
pool.reset(handlers ?? []);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listHandlers = () => [
|
||||||
|
{ match: "SELECT bl.id, bl.ma_id", result: rows({ id: 9, ma_id: 1, band_name: "Mayhem", location_raw: "Oslo" }) },
|
||||||
|
{ match: "count(*)::int AS total", result: rows({ total: 1 }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ces routes existent pour éviter le seul recours disponible auparavant :
|
||||||
|
* relancer des milliers d'appels Geoapify/Groq payants pour débloquer un lieu.
|
||||||
|
*/
|
||||||
|
describe("GET /admin/api/locations", () => {
|
||||||
|
it("exige une session et ne touche pas la base sinon", async () => {
|
||||||
|
const a = buildApp([]);
|
||||||
|
expect((await a.inject({ method: "GET", url: "/admin/api/locations" })).statusCode).toBe(401);
|
||||||
|
expect(pool.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joint le nom et le pays du groupe", async () => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
const body = (await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth })).json();
|
||||||
|
expect(body.items[0].band_name).toBe("Mayhem");
|
||||||
|
expect(pool.find("SELECT bl.id").sql).toContain("JOIN bands b ON b.ma_id = bl.ma_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtre sur une liste de statuts, en paramètre lié", async () => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
await a.inject({ method: "GET", url: "/admin/api/locations?status=error,llm_needed", headers: auth });
|
||||||
|
const call = pool.find("SELECT bl.id");
|
||||||
|
expect(call.sql).toContain("bl.geocode_status = ANY($1::text[])");
|
||||||
|
expect(call.values[0]).toEqual(["error", "llm_needed"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sans allowlist, une faute de frappe donnerait une liste vide sans expliquer
|
||||||
|
// pourquoi — le pire cas pour une vue de diagnostic.
|
||||||
|
it("rejette un statut inconnu au lieu de renvoyer une liste vide", async () => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
const res = await a.inject({ method: "GET", url: "/admin/api/locations?status=erreur", headers: auth });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toMatch(/statut inconnu/);
|
||||||
|
expect(pool.find("SELECT bl.id")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["queued", "processing", "done", "country_only", "error", "llm_needed", "manual"])(
|
||||||
|
"accepte le statut %s",
|
||||||
|
async (status) => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
const res = await a.inject({ method: "GET", url: `/admin/api/locations?status=${status}`, headers: auth });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("cherche à la fois dans le lieu brut et le nom du groupe", async () => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
await a.inject({ method: "GET", url: "/admin/api/locations?q=oslo", headers: auth });
|
||||||
|
const call = pool.find("SELECT bl.id");
|
||||||
|
expect(call.sql).toContain("bl.location_raw ILIKE $1 OR b.name ILIKE $1");
|
||||||
|
expect(call.values[0]).toBe("%oslo%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalise le pays en majuscules", async () => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
await a.inject({ method: "GET", url: "/admin/api/locations?country=no", headers: auth });
|
||||||
|
expect(pool.find("SELECT bl.id").values[0]).toBe("NO");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("le compte applique le même filtre que la page", async () => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
await a.inject({ method: "GET", url: "/admin/api/locations?status=error", headers: auth });
|
||||||
|
const count = pool.find("count(*)::int AS total");
|
||||||
|
expect(count.sql).toContain("bl.geocode_status = ANY($1::text[])");
|
||||||
|
expect(count.values).toEqual([["error"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trie par date de mise à jour décroissante", async () => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth });
|
||||||
|
expect(pool.find("SELECT bl.id").sql).toContain("ORDER BY bl.updated_at DESC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expose l'erreur et le nombre d'essais (le diagnostic tient dans la liste)", async () => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth });
|
||||||
|
const sql = pool.find("SELECT bl.id").sql;
|
||||||
|
for (const col of ["geocode_error", "geocode_tries_geo", "geocode_tries_llm", "geocode_query"]) {
|
||||||
|
expect(sql).toContain(col);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("borne pageSize", async () => {
|
||||||
|
const a = buildApp(listHandlers());
|
||||||
|
const body = (await a.inject({ method: "GET", url: "/admin/api/locations?pageSize=9999", headers: auth })).json();
|
||||||
|
expect(body.pageSize).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /admin/api/locations/:id/requeue", () => {
|
||||||
|
const okHandlers = () => [
|
||||||
|
{ match: "UPDATE band_locations", result: rows({ id: 9, ma_id: 1, location_raw: "Oslo" }) },
|
||||||
|
{ match: "INSERT INTO admin_audit_log", result: rows() },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("exige une session", async () => {
|
||||||
|
const a = buildApp([]);
|
||||||
|
expect((await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", payload: {} })).statusCode).toBe(401);
|
||||||
|
expect(pool.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne remet en file QUE la ligne visée", async () => {
|
||||||
|
const a = buildApp(okHandlers());
|
||||||
|
const res = await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const upd = pool.find("UPDATE band_locations");
|
||||||
|
expect(upd.sql).toContain("WHERE id = $1");
|
||||||
|
expect(upd.values).toEqual([9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("réinitialise les compteurs d'essais et l'erreur", async () => {
|
||||||
|
const a = buildApp(okHandlers());
|
||||||
|
await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
|
||||||
|
const sql = pool.find("UPDATE band_locations").sql;
|
||||||
|
expect(sql).toContain("geocode_status='queued'");
|
||||||
|
expect(sql).toContain("geocode_tries_geo=0");
|
||||||
|
expect(sql).toContain("geocode_tries_llm=0");
|
||||||
|
expect(sql).toContain("geocode_error=NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trace l'action dans le journal d'audit", async () => {
|
||||||
|
const a = buildApp(okHandlers());
|
||||||
|
await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
|
||||||
|
const audit = pool.find("INSERT INTO admin_audit_log");
|
||||||
|
expect(audit.values[0]).toBe("nico");
|
||||||
|
expect(audit.values[1]).toBe("requeue_location");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["abc", "-1", "1.5"])("refuse l'identifiant %s", async (bad) => {
|
||||||
|
const a = buildApp(okHandlers());
|
||||||
|
const res = await a.inject({ method: "POST", url: `/admin/api/locations/${bad}/requeue`, headers: auth, payload: {} });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404 quand la localisation n'existe pas", async () => {
|
||||||
|
const a = buildApp([{ match: "UPDATE band_locations", result: rows() }]);
|
||||||
|
const res = await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /admin/api/locations/:id — coordonnées manuelles", () => {
|
||||||
|
const okHandlers = () => [
|
||||||
|
{ match: "SELECT * FROM band_locations", result: rows({ id: 9, lat: null, lon: null }) },
|
||||||
|
{ match: "UPDATE band_locations", result: rows({ id: 9, lat: 59.9, lon: 10.7 }) },
|
||||||
|
{ match: "INSERT INTO admin_audit_log", result: rows() },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("exige une session", async () => {
|
||||||
|
const a = buildApp([]);
|
||||||
|
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", payload: { lat: 1, lon: 2 } });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(pool.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enregistre les coordonnées et marque le lieu résolu par admin", async () => {
|
||||||
|
const a = buildApp(okHandlers());
|
||||||
|
const res = await a.inject({
|
||||||
|
method: "PATCH", url: "/admin/api/locations/9", headers: auth,
|
||||||
|
payload: { lat: 59.9, lon: 10.7 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const upd = pool.find("UPDATE band_locations");
|
||||||
|
expect(upd.values).toEqual([9, 59.9, 10.7]);
|
||||||
|
expect(upd.sql).toContain("geocode_status = 'done'");
|
||||||
|
// 'admin' distingue une saisie manuelle d'un résultat automatique dans les
|
||||||
|
// statistiques par provider.
|
||||||
|
expect(upd.sql).toContain("geocode_provider = 'admin'");
|
||||||
|
expect(upd.sql).toContain("geocode_error = NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["lat hors bornes", { lat: 91, lon: 0 }],
|
||||||
|
["lon hors bornes", { lat: 0, lon: 181 }],
|
||||||
|
["lat non numérique", { lat: "nord", lon: 0 }],
|
||||||
|
["lat absente", { lon: 10 }],
|
||||||
|
["lon absente", { lat: 59 }],
|
||||||
|
["corps vide", {}],
|
||||||
|
])("refuse %s", async (_label, payload) => {
|
||||||
|
const a = buildApp(okHandlers());
|
||||||
|
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(pool.find("UPDATE band_locations")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepte le point (0, 0), qui est une coordonnée valide", async () => {
|
||||||
|
const a = buildApp(okHandlers());
|
||||||
|
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 0, lon: 0 } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(pool.find("UPDATE band_locations").values).toEqual([9, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404 quand la localisation n'existe pas", async () => {
|
||||||
|
const a = buildApp([{ match: "SELECT * FROM band_locations", result: rows() }]);
|
||||||
|
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("journalise l'avant et l'après", async () => {
|
||||||
|
const a = buildApp(okHandlers());
|
||||||
|
await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
|
||||||
|
const audit = pool.find("INSERT INTO admin_audit_log");
|
||||||
|
expect(audit.values[1]).toBe("set_location_coords");
|
||||||
|
expect(audit.values[4]).toBeTruthy();
|
||||||
|
expect(audit.values[5]).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -41,9 +41,11 @@ export default [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Tests (vitest). Certains tournent sous jsdom → globales navigateur aussi. ----
|
// ---- Tests (vitest + Playwright). Certains tournent sous jsdom ou dans un
|
||||||
|
// navigateur → globales navigateur en plus de Node. Le harnais e2e est en
|
||||||
|
// .mjs (modules ES chargés directement par node). ----
|
||||||
{
|
{
|
||||||
files: ["apps/*/test/**/*.js"],
|
files: ["apps/*/test/**/*.{js,mjs}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2023,
|
ecmaVersion: 2023,
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
|
|
|
||||||
78
package-lock.json
generated
78
package-lock.json
generated
|
|
@ -11,7 +11,9 @@
|
||||||
"apps/api"
|
"apps/api"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@stryker-mutator/core": "^8.7.1",
|
"@stryker-mutator/core": "^8.7.1",
|
||||||
"@stryker-mutator/vitest-runner": "^8.7.1",
|
"@stryker-mutator/vitest-runner": "^8.7.1",
|
||||||
"@vitest/coverage-v8": "^2.1.8",
|
"@vitest/coverage-v8": "^2.1.8",
|
||||||
|
|
@ -71,6 +73,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@axe-core/playwright": {
|
||||||
|
"version": "4.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
|
||||||
|
"integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"axe-core": "~4.13.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"playwright-core": ">= 1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
|
|
@ -1922,6 +1937,22 @@
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.62.4",
|
"version": "4.62.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
|
||||||
|
|
@ -5267,6 +5298,53 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -25,14 +25,20 @@
|
||||||
"audit:py": "pip-audit -r apps/api/../crawler/requirements.txt -r apps/geocoder/requirements.txt",
|
"audit:py": "pip-audit -r apps/api/../crawler/requirements.txt -r apps/geocoder/requirements.txt",
|
||||||
"_comment_check": "`check` = la porte de qualite lancee avant chaque push. Les cinq verifications tournent en parallele (voir scripts/check.mjs). Mutation et audits de dependances en sont exclus : trop lents / dependants du reseau — voir `check:full`.",
|
"_comment_check": "`check` = la porte de qualite lancee avant chaque push. Les cinq verifications tournent en parallele (voir scripts/check.mjs). Mutation et audits de dependances en sont exclus : trop lents / dependants du reseau — voir `check:full`.",
|
||||||
"check": "node scripts/check.mjs",
|
"check": "node scripts/check.mjs",
|
||||||
"check:full": "npm run check && npm run audit:js && npm run audit:py && npm run test:mutation:full",
|
"check:full": "npm run check && npm run test:e2e && npm run audit:js && npm run audit:py && npm run test:mutation:full",
|
||||||
"hooks:install": "git config core.hooksPath .githooks",
|
"hooks:install": "git config core.hooksPath .githooks",
|
||||||
"_comment_cache": "lint et typecheck utilisent un cache dans node_modules/.cache : `check` tourne des dizaines de fois par jour, refaire l'analyse complete de fichiers inchanges a chaque fois n'apporte rien. Les caches sont invalides par mtime+contenu ; `npm run check:clean` les purge.",
|
"_comment_cache": "lint et typecheck utilisent un cache dans node_modules/.cache : `check` tourne des dizaines de fois par jour, refaire l'analyse complete de fichiers inchanges a chaque fois n'apporte rien. Les caches sont invalides par mtime+contenu ; `npm run check:clean` les purge.",
|
||||||
"check:clean": "node -e \"require('fs').rmSync('node_modules/.cache',{recursive:true,force:true})\"",
|
"check:clean": "node -e \"require('fs').rmSync('node_modules/.cache',{recursive:true,force:true})\"",
|
||||||
"check:sequential": "node scripts/check.mjs --sequential"
|
"check:sequential": "node scripts/check.mjs --sequential",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:install": "playwright install chromium",
|
||||||
|
"_comment_e2e": "Les parcours Playwright (~22 s) ne sont PAS dans `check` : celui-ci tourne des dizaines de fois par jour et doit rester sous 10 s. Ils sont lances par le hook pre-push, granularite correcte pour ce cout. `npm run test:e2e:install` recupere Chromium sur un clone neuf."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@stryker-mutator/core": "^8.7.1",
|
"@stryker-mutator/core": "^8.7.1",
|
||||||
"@stryker-mutator/vitest-runner": "^8.7.1",
|
"@stryker-mutator/vitest-runner": "^8.7.1",
|
||||||
"@vitest/coverage-v8": "^2.1.8",
|
"@vitest/coverage-v8": "^2.1.8",
|
||||||
|
|
|
||||||
57
playwright.config.js
Normal file
57
playwright.config.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parcours de bout en bout du dashboard admin.
|
||||||
|
*
|
||||||
|
* Le serveur de test (apps/admin/test/e2e/server.mjs) reproduit la topologie de
|
||||||
|
* production — statique servi + `/admin/*` proxifié vers la VRAIE application
|
||||||
|
* Fastify — avec un faux pool à la place de Postgres. Démarrage ~1 s, aucun
|
||||||
|
* conteneur, conformément au principe suivi par le reste de la suite.
|
||||||
|
*
|
||||||
|
* Performance : les scénarios tournent en parallèle et partagent une session
|
||||||
|
* enregistrée une fois par le projet `setup`. Ils ne se marchent pas dessus car
|
||||||
|
* aucun ne reprogramme le faux pool : les variations de données passent par
|
||||||
|
* `page.route`, qui est propre à chaque onglet.
|
||||||
|
*/
|
||||||
|
const PORT = Number(process.env.E2E_PORT || 4319);
|
||||||
|
const STORAGE_STATE = path.join(process.cwd(), "node_modules/.cache/playwright/admin-auth.json");
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "apps/admin/test/e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
workers: process.env.CI ? 2 : 4,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: 0,
|
||||||
|
reporter: [["list", { printSteps: false }]],
|
||||||
|
timeout: 15000,
|
||||||
|
expect: { timeout: 5000 },
|
||||||
|
outputDir: "node_modules/.cache/playwright/results",
|
||||||
|
|
||||||
|
use: {
|
||||||
|
baseURL: `http://127.0.0.1:${PORT}`,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "off",
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{ name: "setup", testMatch: /auth\.setup\.js/ },
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
testMatch: /.*\.spec\.js/,
|
||||||
|
use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE },
|
||||||
|
dependencies: ["setup"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
webServer: {
|
||||||
|
command: "node apps/admin/test/e2e/server.mjs",
|
||||||
|
url: `http://127.0.0.1:${PORT}/`,
|
||||||
|
env: { E2E_STANDALONE: "1", E2E_PORT: String(PORT) },
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30000,
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "pipe",
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue