metalfrom.eu/apps/admin/test/e2e/fixtures.mjs
Nicolas Fryder f14060720b
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
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>
2026-08-18 15:05:29 +02:00

140 lines
7.9 KiB
JavaScript

/**
* 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() },
];
}