Skip to content

qa: Playwright suite Phase 1+2, Svelte 5 fixes, Trello sync - #13

Open
jeremie0342 wants to merge 37 commits into
masterfrom
qa/playwright-suite-and-trello-sync
Open

qa: Playwright suite Phase 1+2, Svelte 5 fixes, Trello sync#13
jeremie0342 wants to merge 37 commits into
masterfrom
qa/playwright-suite-and-trello-sync

Conversation

@jeremie0342

Copy link
Copy Markdown
Collaborator

Summary

  • fix(admin) — Svelte 5 hydration race (deep-link redirected authed admins to /auth/login on 7 pages) + is_banned field-name mismatch on /users + preventive refetch pattern on challenges lifecycle
  • test(e2e) — new Playwright admin project with a shared authenticated storageState (API-driven login in global-setup). 10 spec files covering Phase 1 nav-smoke (17 routes) and Phase 2 critical flows (login-2fa, user ban/unban, challenge lifecycle, reset-2fa, reports, community, sponsored, kyc, sso, fraud)
  • chore(qa)qa/ folder holds bugs/todos markdown + push-to-trello.py mirroring them into a shared board so the backend team sees P0/P1 issues alongside admin

Bugs surfaced by the new suite (all filed in qa/BUGS_BACK.md + Trello)

  • [P1] Routes admin hors admin_gate middleware (digest, github/sync, accounting export)
  • [P1] GET /admin/users/{id} omits totp_enabled (breaks reset-2FA UI)
  • [P1] GET /admin/sso/sessions returns {data:{sessions:[]}} instead of {data:[]} (SSO list always empty in UI)
  • [P1] POST /admin/community/{id}/approve 500s when the community challenge has no is_training/project_id (DB check constraint)
  • [P1] Seasons /status vs /activate endpoint mismatch
  • [P2] Projects DELETE vs POST /archive mismatch, GET /admin/users/{id} also omits email_2fa_enabled

Trello board

https://trello.com/b/DgCwxpV7/skilluv-qa-bugs-admin (21 cards synced)

Test plan

  • npm run check — 0 errors
  • npm test — 76/76 vitest
  • node e2e/setup/bootstrap-admin.mjs (one-off, needs backend on :3001)
  • npx playwright test --project=public — 21 tests
  • npx playwright test --project=admin — 20 tests (needs backend + fresh DB)
  • python qa/push-to-trello.py — idempotent, shows = for all existing cards

Two related races made deep-links + moderation flows unreliable:

1. Auth-check race on direct navigation. `onMount(() => { if (!auth.isAuthenticated) goto('/auth/login') })` fires before +layout.svelte's `$effect` migrates `data.user` into the auth store — the store starts `null`, so any deep-link (bookmark, email link, refresh) kicked authenticated admins back to login. `hooks.server.ts` already 303-redirects unauthenticated users, so the client check is dead code — removed from +layout.svelte and 7 pages (users/[id], tenants, tenants/[id], enterprise-kyc, operations, sponsored-challenges, tournaments).

2. Field-name mismatch on /users. The list card read `user.banned` while the backend returns `is_banned` — the ban badge and "Débannir" button never appeared post-ban. Renamed `UserSummary.banned` → `is_banned` in the API client type and updated all usages. As part of the same fix, replaced the in-place `user.banned = true` mutation in `confirmBan`/`unban` with `await loadUsers()`; the mutation didn't reliably re-render the `{#if user.banned}` action-button block in Svelte 5.

3. Applied the same refetch-instead-of-mutate pattern preventively to challenges (publish/archive) — same shape of bug waiting to happen.
…cal paths

Split the Playwright suite into two projects:
- `public`  — anonymous specs (auth-redirect, auth-pages, admin-back-e2e)
- `admin`   — authenticated specs that reuse a storageState built by global-setup

global-setup logs in via the API (POST /auth/login with a TOTP code computed
from the persisted admin secret) rather than driving the UI — faster, more
stable, and unaffected by Svelte hydration timing quirks. First run requires
`node e2e/setup/bootstrap-admin.mjs` to register the admin, elevate it via
SQL, and enable 2FA.

Phase 1 (nav-smoke) covers all 17 admin routes with a shared data-driven
test. Phase 2 adds 9 critical flows:
- login-2fa (UI end-to-end with 2FA challenge)
- user ban + unban (moderation)
- challenge create → publish → archive (lifecycle)
- reset-2fa (UI regression guard + API E2E — UI is blocked pending backend fix)
- reports resolve + dismiss
- community approve + reject
- sponsored decide (approve/reject via modal)
- kyc approve + reject
- sso revoke (regression guard + API E2E — list UI blocked pending backend fix)
- fraud mark-valid + revoke

New deps: `otpauth` (TOTP code generation, zero runtime deps), `pg` already
present. Test data is seeded via direct SQL through a shared `e2e/setup/db.ts`
helper to bypass the 5/h auth/register rate limit and avoid Trello-like
side-effects on staging.
qa/ holds the source-of-truth for cross-team QA work:
- AUDIT_ADMIN.md / AUDIT_BACKEND.md / AUDIT_MAPPING.md — one-shot audit of
  the front's API surface vs backend routes
- AUDIT_COVERAGE.md — running checklist of what Playwright covers
- BUGS_FRONT.md / BUGS_BACK.md — bug tracker per team (open + fixed)
- TODO_ADMIN.md / TODO_BACKEND.md — planned implementations per team
- README.md — how to use, board URL, workflow

push-to-trello.py mirrors these markdown files into a shared Trello board
so the backend team sees their bugs/todos alongside ours without leaving
their tooling. Idempotent (match by title, update desc + labels + list),
auto-loads qa/.trello.env (gitignored), supports P0–P9 priorities and
team/type labels (backend/frontend/admin × bug/implementation/other).

Also ignoring e2e/setup/*.png so debug screenshots from local Playwright
runs stay out of the repo.
Adds a second Playwright job that runs the `admin` project against a real
backend service. Pulls `ghcr.io/skilluv/skilluv-backend:master` (published
by skilluv-backend PR #33) instead of rebuilding Rust in every PR — target
runtime ~2 min pull + 30s bootstrap + Playwright.

Services (GHA): postgres 18 (with PGDATA subdir for the 18+ mount check),
redis, mailpit. MinIO started via `docker run --network host` because
GHA services don't accept a command and the minio image requires
`server /data` as an arg.

Backend also runs `--network host` so it reaches postgres/redis/minio/
mailpit at `localhost:<port>` and gets discovered by the runner's Node
scripts at `localhost:3001`. `ADMIN_ORIGINS=http://localhost:5174` is
set so the admin_gate middleware accepts the test's Origin header.

Also splits the existing `e2e` job to run only `--project=public` (its
implicit scope was already public smoke tests).

This job will stay red until the backend PR merges + publishes the image.
That's intentional — we prefer red-but-honest to skip-and-hide.
Fixes the 1 low-severity dependabot alert on master. `cookie` is a
transitive dep of `@sveltejs/kit@2.70.1` (still pinning `^0.6.0` in its
own manifest as of 2.70.1 latest), so we override at the workspace level
to force the patched 0.7.x range. Verified: `npm ls cookie` shows 0.7.2,
`npm audit` reports 0 vulnerabilities, `npm run check` + `npm test` green.
Every page that formats dates/numbers had a copy-pasted
  function intlLocale() {
    return i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US';
  }
7 routes + 5 admin components declared it identically. Extracted to
`src/lib/i18n/index.svelte.ts` and re-exported from `$lib/i18n`, so a
future locale bump only touches one place. Zero behavior change.

Note: the audit also surfaced ~37 real translation strings still using
`i18n.locale === 'fr' ? 'FR text' : 'EN text'` inline (sso-sessions x18,
auth/login x10, 4 shared UI components) — those bypass `ar.ts` entirely
and are tracked as a follow-up in qa/TODO_ADMIN.md (P2).
Backend-independent safety net for the shared components that E2E specs
lean on the most. Complements the existing ConfirmDangerousDialog spec.

Input (6 tests): label/id association, password type default, "show
password" toggle presence + aria-label flip + type switch, error alert
with aria-describedby, hint hidden when error is set.

Modal (7 tests): open=false renders nothing, open=true exposes
role=dialog + aria-modal + aria-label from title, close (X) button
triggers onclose, Escape triggers onclose, backdrop click closes but
inner content click doesn't, no header when title omitted, actions
snippet rendered in footer.

Select (7 tests): current value label in trigger, placeholder fallback,
opens listbox on click with aria-selected on current, onchange fires
with new value, searchable filter narrows options, disabled blocks
open, Escape closes listbox.
…18n.t

The `grep -c "i18n.locale ===" src/` count went from 37 to 0. All strings
that were previously hardcoded fr/en pairs now flow through the standard
`i18n.t()` machinery so ar.ts is populated.

New sub-namespaces under `admin.*` (mirrored in fr.ts + en.ts + ar.ts +
types.ts):
- admin.sso  (18 keys)  — sso-sessions page
- admin.loginPage (10 keys) — auth/login page
- admin.levelUp / admin.multiSelect / admin.replayPlayer /
  admin.shareButton — the 4 shared UI components that still had
  inline ternaries

User-visible impact for arabic locale: SSO sessions page, admin login,
share menu, multiselect chips, replay player timings and level-up modal
now actually render in Arabic instead of falling back to English.
`createApiClient` now catches network-level fetch failures (backend down,
DNS, CORS preflight) and flips a global `backendStatus.isDown` flag.
HTTP responses (4xx/5xx) still surface via SkilluError as before — this
new branch only handles the truly-offline case.

`<BackendStatusBanner>` in the root layout subscribes to the flag and:
- Shows a red top banner with a countdown until the next probe
- Polls `/api/health` with exponential backoff (3→5→10→20→30→60s)
- On success, fires a "reconnected" toast and disappears
- Exposes a "retry now" button so the user can bypass the wait

Before: a backend outage produced a stream of opaque "erreur inattendue"
toasts, one per failed request. After: single persistent banner + auto-
recovery. UX defensive win for prod incidents.

Unit tests: 5 cases on the store (markDown/markUp idempotence, backoff
schedule caps at 60s).
…lper

Every spec used to duplicate the same `new pg.Client() / connect / try /
finally / end` boilerplate and a copy of the uniq() timestamp+random.
Extracted to `withDb(fn)`, `uniq()`, and a `seedUser({ prefix, role, totpEnabled })`
helper already living at `e2e/setup/db.ts`.

Nets ~120 lines removed across 9 spec files with zero behavior change.
Future specs get a one-liner user seed instead of 15 lines of setup, and
the pg connection lifecycle is centralized.
`sponsored-challenges/+page.svelte` was 489 lines, dominated by the
decision modal (approve/reject/negotiate form). Extracted into
`src/lib/components/admin/SponsoredDecideModal.svelte` (130 lines,
self-contained) — the page drops to 421 lines and no longer owns the
form state (`action`, `adminNotes`, `showDecide` internals).

Pattern documented in qa/TODO_ADMIN.md for the 6 other pages that need
the same treatment (projects, tournaments, operations, skills, fraud,
challenges — all still > 400 lines). Notable Svelte 5 gotcha captured:
initializing local state from a prop only captures the initial value —
use a `$effect(() => { if (open) local = prop })` to re-sync on
(re-)open.
… projects)

Follow-up to the sponsored-challenges extraction. Same pattern applied:
each big form-in-a-modal lives in `src/lib/components/admin/` with its
own state and prop-driven mode selection; the parent page keeps only
`open` / `editing` / `submitting` and a submit callback.

Line counts (page shrinkage after extraction):
- skills:               559 → 277  (SkillFormModal 277 — unified create+edit
                                    via discriminated union `mode`)
- challenges:           411 → 185  (ChallengeFormModal 253)
- projects:             602 → 332  (ProjectFormModal 315)
- sponsored-challenges: (already done in the prior commit)

Net: ~800 lines removed from the 3 pages, ~845 lines added as 3 focused
components — no behavior change, contract-preserving (null vs undefined
in optional fields kept exactly as the pre-refactor code sent).

Notable Svelte 5 idioms:
- SkillFormModal uses `mode: {kind:'create'} | {kind:'edit', target}` so
  every property that differs between the two flows (slug immutability,
  clearParent checkbox) is expressed via one discriminated union rather
  than parallel boolean props.
- All three re-seed local `$state` fields inside `$effect(() => { if (open) … })`
  because Svelte 5's state initializers only read a prop's initial value.

Not extracted intentionally: tournaments / operations / fraud — those
only have `<ConfirmDangerousDialog>` (already a shared component); their
line count comes from tabbed sections + business logic, a different
refactor pattern documented as a follow-up.
Both backend routes existed since Phase-B (variant is IA-C.1, deep-scan is
IA-B) but had no UI trigger — admin had to curl them. Now surfaced:

- `<ChallengeVariantDialog>` : new "Générer variante IA" button on any
  published challenge row. Modal picks harder|easier + optional prompt
  hint. On submit hits `POST /admin/challenges/{id}/variant`.
- Deep-scan card in `/fraud` under the eval tab, next to LLM-evaluate.
  Reuses the existing deliverable-id input + threshold/window sliders.
  Renders similarity_score + verdict + comparison_pool_size in a dl.

Added `adminApi.generateChallengeVariant()` + `adminApi.deepScanDeliverable()`
in `$lib/api/admin.ts`. New i18n keys under `admin.variant.*` and
`admin.deepScan.*` in fr/en/ar (typed via `types.ts`).
…ct changes

Backend shipped breaking payload changes (see
skilluv-backend/.trello-push-front.md). Admin doesn't currently call any
of these 4 methods (users manage their own 2FA on the public frontend),
but the auth client is imported here and the wrong signatures would
silently rot until someone tried to expose an admin self-settings page.
Alignment now:

- `totpDisable(code)` → `totpDisable(password, code)` — BE-P0-02
  requires both to prevent stolen-session 2FA drop.
- `enableEmail2fa()` → `enableEmail2fa(password)` — BE-P0-03 mirrors the
  disable flow.
- `disableEmail2fa(currentPassword)` — was posting the old
  `ChangePasswordRequest` shape with a `new_password` filler; new
  backend struct is `PasswordConfirmRequest { password }`.
- `deleteAccount(password, totpCode?, reason?)` — BE-P0-01 response is
  now `{ account_deleted, scheduled_for, message }` (was
  `MessageResponse`). Signature grew a `reason` for the audit trail.

All four have jsdoc pointers to the corresponding BE-P0-XX cards.
Zero admin callers today so no consumer code needs touching.
Backend is live at https://api.skill-uv.com. Wire vite.config.ts to read
the proxy target from `VITE_API_PROXY_TARGET` (defaults to the prod host
when the env var is missing) so a fresh clone talks to real staging out
of the box. Devs running the Rust backend locally just set
VITE_API_PROXY_TARGET=http://localhost:3001 in their `.env`.

`.env.example` documents both modes side-by-side. `.env` itself stays
gitignored — a local copy pointing at prod ships alongside this commit
for the developer machine.
Prefixing "✅ (fait)" onto the entry titles broke the by-title match in
push-to-trello.py — every done item created a zombie card in Backlog
while the original stayed there. Statut line alone is enough to move
the card to Fait; the checkmark now lives in the body instead.
…it 4e857ad)

GET /admin/users/{id} now exposes totp_enabled, email_2fa_enabled, and
webauthn_credentials_count. Split the single '2FA' badge into three
distinct badges and derive targetHasStrongFactor from TOTP OR passkey
so the reset-2FA button reflects the backend rule accurately (admin_gate
accepts either strong factor).
Backend PR fix/dockerfile-seeds-and-admin-bugs shipped:
- aa5e79b: /admin/sso/sessions returns standard {data: T[]} envelope
- 4e857ad: /admin/users/{id} exposes totp_enabled + webauthn_credentials_count
- d96bdb8: /admin/community/{id}/approve pre-checks business rule, returns 400

reset-2fa.spec: was a disabled-button regression guard, now drives the
full UI happy path (TOTP badge, dialog, reason ≥ 8 chars, DB verifies
totp_secret nulled) + keeps a no-strong-factor guard.

sso-revoke.spec: was an empty-tbody regression guard, now seeds an SSO
session, revokes via UI, asserts revoked_at flips.

community-review.spec: adds a 400 regression guard so we notice if the
handler ever regresses to bubbling the DB check-constraint 500.
Backend team shipped fix/dockerfile-seeds-and-admin-bugs. Seven of my
BUGS_BACK entries are now fixed (with commit SHAs), plus the P2 TODO
for user-detail 2FA field enrichment. The two remaining P3 TODOs
(list-payload convention audit, exhaustive utoipa annotation) are
marked deferred with rationale for future backend follow-up.
- qa/README.md : Linear devient le tracker, Trello passe en lecture seule
  (historique jusqu'à la fin de la campagne QA en cours). Les .md restent
  la source descriptive, Linear porte l'état.
- e2e/global-setup.ts : l'absence de credentials n'échoue plus tout le run.
  Le projet `public` n'a pas besoin de session et doit rester lançable sur
  une machine sans backend ni DB ; seul le projet `admin` échoue alors,
  ce qui est le bon signal.
- README.md : reformuler le positionnement (né en Afrique, ouvert
  globalement) et pointer le profil d'org plutôt que le repo backend.
- vite.config.ts : commentaire, "production" → "deployed" (l'API pointée
  par défaut est l'API déployée, pas nécessairement la prod).
…-100)

Livre les trois tickets area:admin du projet "P26 v2 — Workflow challenge
complet via Skilluv (Phase 1 dogfooding)".

SKI-98 — Page projets étendue
- ProjectFormModal expose les 5 champs P26 v2 : couple github owner/repo,
  labels curés (nouveau <TagInput>), mode d'ingestion, domaines du projet.
  Validation live du couple GitHub, avertissement quand mode=auto sans
  label curé (mirror du warn backend) et quand aucun repo n'est câblé.
- Nouvelle fiche projet /projects/[slug] : config d'ingestion, santé du
  workflow, slices ouvertes, journal d'audit.
- Nouvelle page /slices/[id]/config : override des deux garde-fous de
  claim (orientations requises, rang plancher), raison obligatoire,
  historique d'audit. Un champ vidé envoie `null` — "efface l'override",
  distinct de "restreint à rien".
- La liste projets repasse sur les tokens du design system (elle était
  restée sur des classes neutral-* / bg-white en dur).

SKI-99 — Gestion des validateurs
- /validators/{applications,invitations,active} sous un layout commun.
- Candidatures : filtres statut/domaine/origine, approve, reject motivé,
  et les signaux d'éligibilité comparés aux seuils renvoyés par le back
  (jamais recopiés en dur côté front).
- Invitations : recherche d'utilisateur debouncée, notes obligatoires,
  suivi de l'acceptation.
- Validateurs actifs : roster par domaine avec révocation par capability.

SKI-100 — Analytics validation
- /validation-analytics, cinq sections : agrégat cross-projet, santé par
  projet, activité par validateur, concentration validateur × claimant,
  et les compteurs Prometheus (lien Grafana via PUBLIC_GRAFANA_URL).
- Export CSV sur les trois tableaux, sélecteur de fenêtre, et la note de
  contexte Phase 1 : en dogfooding les ratios élevés sont attendus, la
  page informe et ne sanctionne pas.

Notes d'implémentation
- L'agrégat global est la somme des stats par projet curé : il n'existe
  pas d'endpoint d'agrégat, et la page le dit plutôt que de laisser
  croire à une mesure directe.
- Les contrats livrés par le backend diffèrent des specs des tickets
  (`per_page` et non `limit`, `live_stats` et non `stats`, `claimant_*`
  et non `claimer_*`, `reject_count_approx`, `user` imbriqué) : les types
  suivent l'implémentation réelle.
- `challenge_validator:{domaine}` entre dans le type Capability ; le slug
  est encodé à la révocation à cause des deux-points.
- <PendingBackendNotice> distingue un endpoint pas encore déployé (404)
  d'une vraie erreur, au lieu d'un toast que l'opérateur ne peut pas
  actionner.

Reste ouvert côté backend : SKI-109 (GET /admin/projects/{slug} ne
renvoie pas les 5 champs — le formulaire traite donc "vide" comme "ne pas
modifier" et bascule seul en pré-remplissage quand ce sera corrigé) et
SKI-110 (endpoint de forçage d'ingestion, partie 3 de SKI-98).

23 nouveaux tests unitaires (contrats API + TagInput). 124 tests verts,
svelte-check propre, build OK.
…lité)

Le critère « traçabilité complète des grant/revoke » de SKI-99 n'était pas
couvert : les pages affichaient l'état d'une candidature sans jamais dire
qui avait tranché ni quand.

- Candidatures et invitations : ligne / colonne « décidée le », avec lien
  vers l'admin décisionnaire (`reviewed_at` + `admin_actor_id`).
- Validateurs actifs : date de grant par domaine, lue sur
  GET /users/{id}/capabilities.

Le chemin grant/revoke des capabilities n'écrit pas dans `audit_log` — il
ne stocke qu'un `granted_reason` sur la ligne. Ces deux sources sont donc
la seule trace disponible, d'où le choix de les afficher plutôt que de
requêter le journal générique.

La date de grant coûte une requête par validateur : le roster tient en
quelques personnes en Phase 1, et la colonne retombe sur un tiret si un
appel échoue plutôt que de faire tomber la page.
…8 partie 3)

Dernière partie manquante de SKI-98. Le poller P11 tourne à l'heure ;
après avoir saisi une config d'ingestion on veut savoir tout de suite si
elle est bonne, pas au prochain tick.

- Bouton « Forcer l'ingestion » sur /projects/[slug].
- Panneau de compte-rendu : issues vues, slices créées, déjà connues,
  mode et labels retenus. `issues_seen` est là pour distinguer « la config
  est mauvaise » de « il n'y a rien à ingérer » — et quand des issues sont
  lues sans produire ni créer ni reconnaître une seule slice, la page le
  dit explicitement, c'est le symptôme d'un label curé qui ne matche rien.
- Tant que l'endpoint n'est pas déployé, le 404 rend l'état « pas encore
  déployé » plutôt qu'un toast que l'opérateur ne peut pas actionner.

Le bouton n'est pas désactivable en amont pour un projet sans repo câblé
ou en mode manual_only : ces champs ne sont pas relisibles tant que
SKI-109 n'est pas fait, donc c'est le 400 backend qui portera le message.

Contrat consommé documenté dans SKI-110 pour que l'implémentation
backend s'y aligne.
…100)

Écrit, pas exécuté : ces specs demandent un backend + une DB dont je ne
dispose pas. Elles sont donc à considérer comme non vérifiées tant qu'un
premier run réel n'a pas eu lieu.

Ce qu'elles prouvent que les tests unitaires ne peuvent pas — l'effet réel
en base, pas la forme de la requête :

- p26-project-challenge-config : les cinq champs saisis dans le formulaire
  arrivent bien dans les colonnes ; la paire GitHub dépareillée bloque
  avant l'envoi ; l'avertissement de no-op suit la combinaison mode+labels
  et pas le mode seul.
- p26-slice-config : vider un champ envoie `null` et non `[]`. Les deux se
  ressemblent dans l'UI et ont des effets opposés — `[]` voudrait dire
  « restreint à aucune orientation », donc bloquerait tout le monde.
- p26-validators : approuver accorde réellement la capability ; rejeter
  n'en accorde aucune ; inviter n'en accorde pas non plus tant que l'invité
  n'a pas accepté. Une UI qui affiche « approuvé » sans que la capability
  suive est le pire des cas, silencieux et faux.
- p26-validation-analytics : les 5 sections, la fenêtre, le seuil de
  signalement, l'export CSV.

Les specs dépendant d'un endpoint pas encore déployé se `skip` sur 404/405
plutôt que d'échouer : un endpoint absent est un état de déploiement connu,
pas une régression.

Au passage, deux corrections dans l'existant :
- projects-crud attendait `POST /admin/projects/{slug}/archive` alors que
  le client envoie `DELETE /admin/projects/{slug}` — le spec ne pouvait pas
  passer.
- nav-smoke couvre les quatre nouvelles routes.

Fixtures P26 mutualisées dans e2e/setup/db.ts plutôt que redéclarées dans
chacune des quatre specs.

77 tests collectés sur 19 fichiers (`--list`), typecheck TS propre.
Les specs P26 se `skip`ent sur un 404, ce qui est le bon comportement — un
endpoint absent est un état de déploiement connu, pas une régression. Mais
ça veut dire qu'un run peut être vert en n'ayant rien vérifié du tout.

`npm run test:e2e:preflight` répond à la seule question qui compte avant de
lancer : l'environnement est-il capable de valider quelque chose ? Il sonde
les routes P26 (une route qui existe répond 403 via AdminGate même sans
session ; un 404 signifie build trop ancien), vérifie le niveau de migration
et la présence du schéma P26, et sort en 1 avec le détail sinon.

Lecture seule de bout en bout : il n'écrit rien et n'imprime jamais l'URL de
connexion, qui porte le mot de passe.

État actuel sur le serveur de test : 6 contrôles en échec — les 5 endpoints
P26 absents (PR backend #67 pas encore mergée, Coolify déploie `master`) et
la base injoignable depuis l'extérieur (hôte docker interne, tunnel SSH
nécessaire). Cf. SKI-113.
Les 23 specs P26 n'avaient jamais tourné. Lancées contre staging, elles ont
trouvé quatre défauts — dont deux qui dépassent largement P26.

**`<Toast />` n'était monté nulle part.** Les ~194 appels `toast.success` /
`toast.error` répartis dans 39 fichiers ne rendaient rien : depuis toujours,
aucune action du panneau admin — ban, révocation, sauvegarde, erreur — ne
donnait le moindre retour visuel. Monté dans le layout racine.

**Escape fermait la modale entière.** Ouvrir le multiselect des domaines dans
le formulaire projet puis presser Escape pour refermer la liste fermait la
modale et perdait toute la saisie. `Select` et `MultiSelect` écoutent
désormais en capture et stoppent la propagation quand leur dropdown est
ouvert, pour que la touche soit consommée avant d'atteindre `Modal`.

**Le contrat backend avait bougé.** J'avais lu `admin_validators.rs` avec des
modifications non commitées ; le build déployé sert `active_domains` en
`[{domain, granted_at}]` et non plus en tableau de chaînes, et `reject_count`
sans le suffixe `_approx`. Conséquence : filtre par domaine inopérant et
`aria-label` de révocation rendant `[object Object]`. Types réalignés sur la
réponse réelle, et le N+1 qui allait chercher les dates de grant une par
utilisateur est supprimé — le backend les sert inline (SKI-115 livré).

**`.env` n'était lu par personne côté E2E.** `playwright.config`,
`global-setup`, `preflight` et `bootstrap-admin` retombaient silencieusement
sur `localhost:3001` / `localhost:5433`. Chargement centralisé dans
`e2e/setup/env.mjs`, et lectures rendues paresseuses là où une constante de
module capturait la valeur d'avant chargement.

Ajouts : `dump-p26-payloads.mjs` (imprime la forme réelle des réponses P26,
lecture seule) et identifiants du bootstrap surchargeables par l'environnement
pour ne rien figer dans le dépôt.

Côté specs : trois sélecteurs ambigus ou trop hâtifs corrigés — `getByText`
qui matchait aussi la légende du seuil, clic avant hydratation Svelte, clic
pendant un re-rendu qui refermait le dropdown.

29/29 vertes en série. En parallèle (8 workers) le lot est instable : backend
distant + compilation à la demande. 125 tests unitaires verts, build OK.
J'ai affirmé que les specs nettoyaient derrière elles. C'était faux : le
nettoyage est la dernière ligne de chaque test, donc **un test qui échoue ne
l'atteint jamais**. Après les runs de mise au point, la base de test portait
15 projets, 69 utilisateurs, 11 slices et 4 candidatures orphelins.

Sur une base partagée ça ne reste pas cosmétique : un roster de validateurs
qui déborde ou un sélecteur de projet qui matche deux lignes finit par fausser
les tests suivants — la suite devient non déterministe pour une raison
invisible.

`globalTeardown` purge donc quoi qu'il arrive, sur des motifs sans ambiguïté
(`e2e-*` pour les slugs, `@skilluv.test` pour les emails). Le compte admin
dédié est explicitement épargné, sinon le `storageState` du run suivant
pointerait sur un utilisateur supprimé.

Suppression utilisateur par utilisateur plutôt qu'en bloc : tous les FK vers
`users` ne cascadent pas (`challenge_templates.created_by`,
`enterprises.owner_id`, `reports.reporter_id`), et un DELETE global échouait
entièrement à cause d'une poignée de lignes. Ce qui résiste est compté et
signalé plutôt que masqué — 9 utilisateurs restent, créés par des specs
antérieures à P26 dont le nettoyage propre relève de SKI-181.

Base repassée de 112 à 43 utilisateurs, 0 projet et 0 slice de test.
…flight

Le tunnel SSH vers la base de test est tombé en fin de session
(`Connection reset by peer`). Le préflight l'a bien détecté, mais son
diagnostic parlait d'un nom d'hôte qui ne résout pas — l'autre cause — et
n'affichait même pas de raison : `pg` remonte un `AggregateError` dont le
`message` est vide quand la connexion est refusée, l'information étant dans
`code` et dans les erreurs agrégées.

Les deux situations sont maintenant distinguées et nommées : localhost qui
refuse = tunnel à rouvrir ; hôte qui ne résout pas = nom de service interne au
provider. Sans ça, on cherche du côté des identifiants alors qu'il suffit de
relancer une commande.

`qa/README.md` documente la commande de tunnel et l'IP docker du conteneur,
au lieu de la laisser en savoir tribal.
…e qui reste

Vérifié sur staging : `GET /admin/projects/{slug}` renvoie bien les cinq
champs P26. Je les annonçais encore comme manquants, à tort.

Conséquences réelles, mesurées et non supposées :

- **Les tableaux se vident.** Envoyer `[]` sur `curated_labels` /
  `skill_domains` efface bien la valeur — `COALESCE` prend `[]` puisque ce
  n'est pas `null`. Le formulaire se pré-remplit désormais et transmet ces
  champs systématiquement.
- **Le repo GitHub, non.** `PATCH { github_repo_owner: null }` répond **200**
  et ne change rien : `COALESCE` lit `null` comme « champ absent ». Aucune
  valeur ne permet de débrancher un repo — `""` est rejeté par le validateur,
  `null` est ignoré. L'admin reçoit une confirmation de succès pour une
  modification qui n'a pas eu lieu, et un projet mal câblé continue d'ingérer.
  Remonté en SKI-269 ; en attendant, le formulaire avertit explicitement et
  oriente vers le mode d'ingestion « Manuel » comme contournement.

La spec de la fiche projet acceptait « repo affiché OU non exposé par l'API »
le temps que l'endpoint arrive. Elle assère maintenant que la config est bien
rendue — une assertion permissive qui ne se resserre jamais finit par ne plus
rien prouver.

Le garde `p26Echoed` reste en place plutôt que d'être supprimé : il couvre un
backend en retard (déploiement décalé, environnement local), où l'on préfère
« champ vide = ne pas modifier » à un effacement à l'aveugle.

7/7 sur la spec concernée, 125 tests unitaires verts.
…tut (SKI-112)

Le trou que le ticket décrit était toujours ouvert : `GET /api/admin/slices`
était livré côté backend, mais aucun écran ne le consommait. Une slice qui
n'était plus `open` restait atteignable seulement par son UUID, donc via psql
— précisément l'état dans lequel on a besoin d'agir : un override de rang se
demande sur un challenge déjà claimé, un blocage se diagnostique sur une PR
soumise depuis trois semaines.

- Page `/slices` : filtres statut / projet / domaine + recherche libre sur le
  titre ou la référence externe, pagination, lien vers la config de chaque
  slice. Les filtres sont portés par l'URL, donc la page est partageable et
  survit à un rechargement.
- Les compteurs par statut deviennent cliquables sur la fiche projet et sur la
  section 1 de l'analytics. Le dashboard disait *combien* de slices étaient
  dans un état sans permettre de voir *lesquelles*.
- La fiche projet liste désormais tous les statuts, triés par dernière
  activité, au lieu des seules slices ouvertes.

Un piège rencontré en route, qui valait le détour : `replaceState` lève tant
que le routeur n'est pas initialisé. Appelé avant le fetch, il faisait échouer
le chargement **sans aucune erreur visible** — la page restait simplement
vide. La synchronisation d'URL est passée après le chargement et enveloppée :
un confort ne doit jamais bloquer les données.

6 nouvelles specs E2E, vertes. Les 51 specs P26 + nav-smoke passent toujours,
125 tests unitaires verts.
…s (SKI-181)

En reprenant les 18 specs rouges, la première n'était pas de la dérive de test
mais un vrai plantage : `/tenants` lisait `res.data.tenants` alors que l'API
renvoie `data` directement en tableau — `undefined.length`, page morte. Même
écart sur `/challenges` et `/sponsored-challenges`, qui affichaient une liste
vide en silence.

C'est la contrepartie jamais faite du chantier backend « aligner toutes les
listes admin sur `{data, pagination}` » (BUGS_BACK P3, corrigé côté back).
Trois fronts étaient restés sur l'ancienne forme imbriquée. Vérifié endpoint
par endpoint contre le backend déployé plutôt qu'au jugé : `/admin/fraud/queue`
utilise toujours la forme imbriquée et n'a pas été touché.

Réparé aussi, côté specs — chaque cas tranché entre dérive de sélecteur,
schéma périmé et vrai bug :

- `catalog-crud` : la modale orientation n'est pas un `<form>`, donc
  `requestSubmit()` n'avait rien à appeler ; les ids de la modale tenant sont
  préfixés `t-` ; le fixture `badge_rules` visait des colonnes disparues
  (`kind`, `rule_expr`, `reward_fragments`) ; `orientations.name` et non
  `display_name` ; et le clic de dépréciation visait `.first()`, donc une
  autre règle que celle seedée.
- `ops-jobs` : deux boutons de la page s'appellent « Déclencher » à
  l'identique. Plutôt qu'une regex sur du texte d'interface — qui avait déjà
  dérivé une fois — les quatre déclencheurs portent un `data-testid`.
- Les clics arrivaient parfois avant l'hydratation : le bouton est rendu en
  SSR, son `onclick` n'existe qu'après. `pageFireAndAssert` réessaie au lieu
  de poser une attente arbitraire, et son message d'échec nomme la cause.

10 des 18 vertes (catalog 3, challenge-lifecycle 1, sponsored 2, ops 4).
Restent community-review, fraud-actions, gdpr-guild, kyc-decide, skills-crud.
…s (SKI-181)

Reprise des 18 specs qui n'avaient jamais tourné. Le rouge recouvrait trois
causes bien distinctes, et la première n'était pas du test.

**Cinq pages affichaient une liste vide, ou plantaient.** `/tenants`,
`/challenges`, `/sponsored-challenges`, `/enterprise-kyc` et `/community`
lisaient `res.data.X` sur un payload devenu `{data: T[]}`. `/tenants` mourait
sur `undefined.length` ; les quatre autres ne montraient rien, en silence —
dont une file KYC de trois dossiers et six challenges en attente de revue.
C'est la contrepartie jamais faite du chantier backend « aligner les listes
admin sur {data, pagination} » (BUGS_BACK P3). Vérifié endpoint par endpoint
contre le backend déployé : `/admin/fraud/queue` garde la forme imbriquée et
n'a pas été touché.

**Six fixtures visaient un schéma qui n'existe plus.** `badge_rules` (colonnes
disparues), `orientations.name` et non `display_name`, `guilds.founder_id` et
non `owner_id` avec `tag` NOT NULL, `deliverables` qui exige un parent et
refuse `artifact_type='code'` comme `verifiable_by='ai'`, `disbanded_at` et
non `dissolved_at`.

**Deux assertions asséraient un effet inexistant** : révoquer un livrable pose
`revoked_at`, ça ne touche pas `verification_status`. La spec attendait un
état que le backend n'écrit nulle part — elle aurait été rouge même si tout
fonctionnait.

Le reste est du sélecteur et du timing : modale sans `<form>` donc
`requestSubmit()` n'appelait rien, ids préfixés, `.first()` visant une autre
ligne que celle seedée, et des clics arrivant avant l'hydratation.

Durcissements pour que ça ne redérive pas : quatre `data-testid` sur
`/operations` (deux boutons s'y appellent « Déclencher » à l'identique), et
les clics sensibles à l'hydratation réessaient via `toPass` au lieu d'une
attente arbitraire.

`qa/AUDIT_COVERAGE.md` réécrit — il était corrompu par une édition précédente
(section dupliquée, tableau Phase 2 absorbé sous un titre P26) et annonçait
des ✅ hérités d'exécutions locales anciennes.
…pr-guild

**Section 1 de /validation-analytics.** Faute d'endpoint d'agrégat, l'overview
demande une requête de stats par projet curé. En `Promise.all` brut, cinquante
projets déclenchaient cinquante appels d'un coup au chargement. Trois
changements :

- concurrence plafonnée à 4 requêtes en vol ;
- cache par `slug|fenêtre`, donc un aller-retour 90 j → 30 j → 90 j ne
  recharge plus rien — ces stats bougent à l'échelle de l'heure ;
- un projet en erreur ne vide plus tout l'agrégat. La page affiche la somme
  partielle **en disant laquelle est incomplète**, au lieu de présenter un
  total tronqué comme un total. Si tout échoue, c'est une vraie erreur.

**gdpr-guild : le vrai motif de l'instabilité.** `requestDissolve` sort
immédiatement quand le champ UUID est vide. Or remplir un input avant
l'hydratation écrit dans le DOM sans mettre à jour l'état Svelte : le handler
lit une chaîne vide et n'ouvre jamais la modale. Ma première correction
recliquait sans re-remplir, donc l'état restait vide indéfiniment — le test
alternait au gré du hasard. Le champ est maintenant re-rempli à chaque
tentative. Trois exécutions consécutives vertes.

**Note de test corrigée dans qa/README.** J'avais écrit que vitest et
Playwright se marchaient dessus ; la cause réelle est plus précise et plus
courante : un `npm run dev` laissé tourner suffit à faire déborder le timeout
de 5 s des tests unitaires — 24 échecs qui ressemblent à des bugs alors que
rien n'est cassé. Le symptôme qui trahit la contention est `tests 12ms` pour
deux minutes de durée totale. Vérifié : 125/125 dès le serveur coupé.
**84/84 specs vertes d'affilée** contre le serveur de test (6 min,
`--workers=1`). C'est la première exécution complète de la suite admin.

Il a fallu deux corrections pour y arriver.

**Le tunnel SSH ne tenait pas la distance.** Le serveur coupe la session au
bout de quelques dizaines de minutes (`Connection reset by peer`, malgré
`ServerAliveInterval`), et il est tombé trois fois — dont une pile au démarrage
d'un run. J'ai arrêté la campagne plutôt que de la laisser produire quarante
rouges qui ressemblent à des bugs applicatifs : un faux rouge coûte plus cher
qu'un run perdu. `e2e/setup/db-tunnel.sh` reconnecte désormais tout seul et
journalise chaque coupure. La commande brute documentée avant ne survit pas de
façon fiable à un run de dix minutes.

**Le teardown laissait une quarantaine de comptes en base à chaque campagne.**
Les lignes filles créées par les specs — guildes, rapports, challenges,
entreprises — retiennent leur auteur par clé étrangère. Elles sont maintenant
supprimées dans l'ordre des dépendances avant les utilisateurs. Ce run s'est
terminé sans aucun résidu, contre 46 comptes bloqués la fois précédente.

Vérifié qu'aucune donnée réelle n'a été touchée : les fixtures sont toutes en
`@skilluv.test` et il n'en reste qu'une, le compte admin dédié conservé
volontairement pour le `storageState`. Les 69 comptes réels sont intacts.
…e l'ingestion

Constaté en exerçant enfin le chemin nominal du bouton d'ingestion, une fois
les quatre projets Skilluv créés (SKI-74) : la réponse du backend porte un
champ `errors` que le contrat du ticket ne mentionnait pas.

Sans lui, une passe qui échoue à moitié ressemble à une passe qui n'a rien
trouvé — les deux affichent zéro slice créée. Le panneau le signale désormais.

Ajouté aussi le cas `issues_seen === 0`, qui est l'état normal tant qu'aucune
issue ne porte de label curé : le dire évite de chercher une erreur de config
là où il n'y en a pas.
`docker-compose.yml` réserve 5433 au Postgres de la stack locale ; le tunnel
prenait le même port. Comme un listener sur 127.0.0.1 l'emporte sur un
listener 0.0.0.0, les connexions à localhost:5433 partaient vers la base du
serveur de test — y compris celles des tests locaux, et y compris des DELETE.
Rien ne le signalait : les deux bases répondent, avec le même schéma.

Le tunnel écoute désormais sur 5434 et laisse 5433 à docker.

Second correctif, sans lequel le premier ne tient pas : la boucle lançait
`ssh` au premier plan, donc tuer le script laissait le `ssh` vivant avec son
port, et la boucle survivante en relançait un toutes les 3 secondes. `ssh`
passe en arrière-plan avec `wait`, et un trap INT/TERM le tue avec le script.
Le trap ajouté au commit précédent ne partait pas : sous MSYS, un `wait` sur
un enfant Windows natif ne rend la main à aucun signal, donc la boucle
survivait à son propre `kill` et relançait un `ssh` toutes les 3 secondes.
Vérifié : après un TERM, la boucle et son tunnel étaient toujours là.

Un pidfile porte désormais les deux pids. `db-tunnel.sh stop` envoie TERM à la
boucle — il reste en attente — puis tue le `ssh`, ce qui débloque le `wait` et
laisse enfin le trap s'exécuter. Un démarrage commence par le même nettoyage,
donc un lancement suffit à récupérer d'un orphelin quoi qu'il soit arrivé.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant