mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-05-18 08:18:15 +02:00
capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) : - 60 entrées intégrées dans knowledge/ (backend, frontend, workflow) - 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md, frontend/patterns/general.md, workflow/patterns/general.md - 6 doublons rejetés - Mise à jour des READMEs index pour refléter les nouvelles entrées - 95_a_capitaliser.md restauré à sa structure initiale - 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant - 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI, prisma migrate diffs cosmétiques Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ Dernière mise à jour : 2026-03-12
|
|||||||
- [Le front-end est un logiciel en production](#decision-frontend-production)
|
- [Le front-end est un logiciel en production](#decision-frontend-production)
|
||||||
- [Single source of truth des contrats — schémas runtime partagés (Zod) + z.infer (No-DTO)](#decision-contrats-sso-zod)
|
- [Single source of truth des contrats — schémas runtime partagés (Zod) + z.infer (No-DTO)](#decision-contrats-sso-zod)
|
||||||
- [User views — User public par défaut + MeUser explicite](#decision-user-views)
|
- [User views — User public par défaut + MeUser explicite](#decision-user-views)
|
||||||
|
- [Mono-tenant déployable vs SaaS multi-tenant](#decision-mono-tenant-deployable)
|
||||||
|
|
||||||
### 2. Infra
|
### 2. Infra
|
||||||
|
|
||||||
@@ -293,6 +294,48 @@ Règles associées :
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<a id="decision-mono-tenant-deployable"></a>
|
||||||
|
|
||||||
|
## Mono-tenant déployable vs SaaS multi-tenant
|
||||||
|
|
||||||
|
- Date : 2026-04-27
|
||||||
|
- Statut : Accepted
|
||||||
|
- Périmètre : global
|
||||||
|
|
||||||
|
### Contexte
|
||||||
|
|
||||||
|
Pour une app vendable à plusieurs clients qui fonctionnent chacun individuellement (ex : loges, cabinets, écoles), le choix d'architecture multi-tenant a un impact majeur sur la complexité, la sécurité et le coût opérationnel. Trois architectures possibles, à trancher tôt.
|
||||||
|
|
||||||
|
### Options envisagées
|
||||||
|
|
||||||
|
| Approche | Description | Pour | Contre |
|
||||||
|
| -------- | ----------- | ---- | ------ |
|
||||||
|
| **Mono-tenant déployable** | Chaque client = son instance app + DB + uploads + domaine | Pas d'isolation cross-tenant à coder, complexité ÷10, sécurité plus simple | Coût opérationnel par déploiement, pas de cross-tenant report |
|
||||||
|
| **SaaS multi-tenant logique** | Une app, une DB, `tenantId` partout | Coût opérationnel ÷N, cross-tenant analytics | Isolation à coder partout (RLS, scoping, tests cross-tenant), risque leak data |
|
||||||
|
| **SaaS multi-tenant physique** | Une app, plusieurs DBs (1 par tenant) | Isolation physique, scaling fin | Routing complexe, ops par tenant, migration cross-DB |
|
||||||
|
|
||||||
|
### Décision
|
||||||
|
|
||||||
|
Pour un projet dont la cible est constituée de clients indépendants qui veulent leur autonomie, **préférer le mono-tenant déployable** (Option 1) tant que :
|
||||||
|
|
||||||
|
- pas de feature cross-tenant attendue (pas de "voir les statistiques tous clients")
|
||||||
|
- l'admin technique de chaque instance peut être différent
|
||||||
|
- la simplicité de la sécurité (pas de scoping `tenantId` partout, pas de RLS Postgres, pas de tests "leak cross-tenant") prime sur l'économie d'opérations
|
||||||
|
|
||||||
|
### Justification
|
||||||
|
|
||||||
|
- Population cible = clients indépendants qui veulent leur autonomie
|
||||||
|
- Sécurité plus simple : pas de scoping multi-tenant à valider partout
|
||||||
|
- Conséquence : modélisation des configurations en singleton (cf. `pattern-singleton-db-config-globale` dans `knowledge/backend/patterns/general.md`)
|
||||||
|
|
||||||
|
### Conséquences
|
||||||
|
|
||||||
|
- Chaque déploiement a sa propre DB, ses propres uploads, son propre domaine
|
||||||
|
- Pipeline CI/CD par instance ou via template (cf. `pattern-pipeline-cicd-github-actions-vps` dans `knowledge/backend/patterns/general.md`)
|
||||||
|
- Migration vers Option 2 si la cible évolue (back-office central pour toutes les instances) → **réécriture explicite** plutôt que rétro-fit (ajouter `tenantId` partout est non-trivial)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2. Infra
|
## 2. Infra
|
||||||
|
|
||||||
<a id="decision-structure-docker"></a>
|
<a id="decision-structure-docker"></a>
|
||||||
|
|||||||
@@ -220,3 +220,113 @@ npm install -g <package>@latest
|
|||||||
- `npm root -g`
|
- `npm root -g`
|
||||||
- `npm ls -g --depth=0 <package>` | npm list -g @openai/codex --depth=0
|
- `npm ls -g --depth=0 <package>` | npm list -g @openai/codex --depth=0
|
||||||
- <package> --version
|
- <package> --version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sub-agents Claude Code — `Write` indisponible dans la sandbox `Explore`
|
||||||
|
|
||||||
|
### Contexte
|
||||||
|
|
||||||
|
Workflow BMAD `testarch-test-review` sur RL799_V2 (24-04-2026) utilisant 4 sub-agents `subagent_type=Explore` pour évaluer 4 dimensions qualité en parallèle. Chaque sub-agent devait écrire un fichier JSON dans `/tmp/`.
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Les 4 sub-agents ont terminé leur analyse avec succès mais **aucun n'a réussi à écrire son fichier JSON**
|
||||||
|
- Messages de retour : *"Je rencontre une limitation d'outillage… je suis en mode READ-ONLY… je génère le rapport directement en texte."*
|
||||||
|
|
||||||
|
### Cause
|
||||||
|
|
||||||
|
Le sub-agent type `Explore` n'a pas accès à l'outil `Write` dans sa sandbox (spec : "Tools: All tools except Agent, ExitPlanMode, Edit, Write, NotebookEdit"). Non documenté clairement dans les workflows TEA qui demandent pourtant d'écrire en JSON.
|
||||||
|
|
||||||
|
### Correctif / règle à retenir
|
||||||
|
|
||||||
|
1. **Ne pas demander aux sub-agents `Explore` d'utiliser `Write`** — briefer explicitement "retourne le JSON en bloc dans ta réponse finale"
|
||||||
|
2. **L'orchestrateur matérialise** les fichiers de sortie pour le compte des sub-agents
|
||||||
|
3. **Alternative** : utiliser `subagent_type=general-purpose` qui a accès à tous les tools (mais plus cher en tokens et moins spécialisé pour l'exploration)
|
||||||
|
|
||||||
|
Extrait de brief corrigé pour futur usage :
|
||||||
|
|
||||||
|
```
|
||||||
|
Ta mission : analyse X dans les fichiers Y.
|
||||||
|
Format de sortie : JSON structuré selon le schéma ci-dessous.
|
||||||
|
IMPORTANT : retourne le JSON directement dans ta réponse finale, entre blocs ```json```.
|
||||||
|
Ne tente pas d'écrire de fichier (Write indisponible dans ta sandbox).
|
||||||
|
L'orchestrateur matérialisera le fichier à partir de ton retour.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Effet iceberg en CI — patcher en cascade jusqu'au fond du puits
|
||||||
|
|
||||||
|
### Contexte
|
||||||
|
|
||||||
|
Quand un fix CI structurant rétablit un pipeline qui foirait depuis longtemps, **plusieurs bugs latents en aval peuvent apparaître en cascade** : ils étaient tous présents avant, juste invisibles parce que le runner s'arrêtait à l'échec amont. Vécu sur RL799_V2 le 30-04 / 01-05-2026, 8 étages d'iceberg fixés en cascade.
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
| # | Phase | Symptôme | Cause | Fix |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | CI tests | `Cannot find module '@org/shared'` | `dist/lib` non bâti avant `test:api` | Build workspace en amont |
|
||||||
|
| 2 | CI tests | `Module '@prisma/client' has no exported member 'X'` | Client Prisma non généré | Inverser `prisma generate` → `pnpm build` |
|
||||||
|
| 3 | CI tests | `Seed incomplet : 0 users / N attendus` | Étape seed manquante | Ajouter `prisma db seed` après `prisma migrate deploy` |
|
||||||
|
| 4 | CI tests | `<env> non configuré (requis hors dev)` | Variable d'env applicative manquante en CI | Définir au bloc `env:` du job |
|
||||||
|
| 5 | CI tests | 14×500 sur endpoints qui chiffrent | `ENCRYPTION_KEY` manquante | Idem |
|
||||||
|
| 6 | CI tests (PDF) | `Could not find Chrome` | Puppeteer cherche son cache local absent du runner | `PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable` |
|
||||||
|
| 7 | CD prod (migrate) | `Cannot find module '/app/scripts/check-node-version.mjs'` | `pnpm run prisma:migrate` appelle un script absent de l'image API | Appel direct du binaire Prisma |
|
||||||
|
| 8 | CI tests | Test attend `50,00 €` reçoit `1,19 €` | `waitForNotification` mal scopé (filtre par `type` mais pas par `recipientId`) — masquée par les étages 1-7 | Re-run OU patch chirurgical du `where:` |
|
||||||
|
|
||||||
|
Chaque étage masquait le suivant. Aucun n'était nouveau — tous présents avant la session, mais invisibles à cause des étages amont.
|
||||||
|
|
||||||
|
### Cause
|
||||||
|
|
||||||
|
- **Local ≠ CI** : en local, `dist/` traîne, le client Prisma est généré, la DB est seedée d'une session précédente, le `.env` est complet. Le bug est invisible
|
||||||
|
- **Pipeline early-exit** : un échec à l'étape N ne laisse rien tourner aux étapes N+1, N+2, …
|
||||||
|
- **Effet additif des sessions** : plus le pipeline est cassé depuis longtemps, plus le code applicatif a évolué sans validation CI
|
||||||
|
|
||||||
|
### Correctif / règle à retenir
|
||||||
|
|
||||||
|
1. **Validation locale stricte avant push CI structurant** : simuler les conditions CI vierges (`rm -rf node_modules/.prisma packages/*/dist apps/*/.next` + relancer la chaîne complète)
|
||||||
|
2. **Lecture honnête des nouveaux failures** : après un fix CI structurant, ne pas présumer que les nouveaux failures sont des régressions du fix. Probablement des bugs latents
|
||||||
|
3. **Tableau iceberg** : noter au fil de la session le tableau (étage / symptôme / cause / fix). Ne pas se laisser submerger par "ça casse encore"
|
||||||
|
4. **Push après chaque étage** : ne pas attendre d'avoir tout fixé. Chaque fix structurant mérite son commit thématique
|
||||||
|
5. **Ne pas stopper trop tôt** : un seul push ne révèle qu'un étage. Tant qu'il y a des bugs latents, le pipeline cassera
|
||||||
|
|
||||||
|
### Signal pour repérer un effet iceberg
|
||||||
|
|
||||||
|
- Le pipeline était cassé depuis ≥ 1 semaine
|
||||||
|
- Le fix d'aujourd'hui touche une étape **précoce** du workflow (install, build, generate, migrate)
|
||||||
|
- Les commits récents ont ajouté des features sans valider en CI
|
||||||
|
- Sentiment vague de "ça pourrait casser plein d'autres trucs" — c'est probablement vrai
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prisma migrate inclut les diffs cosmétiques (RenameIndex)
|
||||||
|
|
||||||
|
### Contexte
|
||||||
|
|
||||||
|
`prisma migrate dev --create-only --name add_lodge_settings` peut générer une migration qui contient (1) le changement attendu mais aussi (2) un side-effect cosmétique pré-existant entre le schema Prisma et la DB qui n'avait jamais été nettoyé. RL799_V2 — migration `20260427120920_add_lodge_settings` qui ramassait un `ALTER INDEX … RENAME TO …` orphelin.
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Migration thématique qui contient un rename d'index sans rapport avec le scope de la story
|
||||||
|
- Un dev qui regarde la migration ne comprend pas pourquoi cet `ALTER INDEX` est là
|
||||||
|
|
||||||
|
### Options et décision
|
||||||
|
|
||||||
|
| Option | Pro | Con |
|
||||||
|
|---|---|---|
|
||||||
|
| Garder le rename dans la migration thématique avec commentaire | la prochaine `prisma migrate dev` ne re-générera pas ce rename | le commit "thématique" contient un side-effect cosmétique |
|
||||||
|
| Retirer le rename | commit propre | la prochaine migration thématique l'inclura à nouveau → piège pour le prochain dev |
|
||||||
|
| Migration de cleanup séparée | plus propre | nécessite 2 migrations + 2 PRs |
|
||||||
|
|
||||||
|
**Décision recommandée** : option 1 avec commentaire explicite dans le `.sql` :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- RenameIndex (réalignement DB ↔ schema, dérive cosmétique pré-existante)
|
||||||
|
ALTER INDEX "tronc_entries_tenue_idx" RENAME TO "tronc_entries_tenue_id_idx";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Correctif / règle à retenir
|
||||||
|
|
||||||
|
- **Préventif** : `prisma migrate diff` régulièrement (CI/CD ou pré-commit) pour détecter la dérive AVANT qu'elle ne pollue une migration thématique
|
||||||
|
- **Curatif** : inspecter manuellement le SQL généré par `--create-only` avant de l'appliquer en migration thématique
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ vers les fichiers appropriés :
|
|||||||
- `knowledge/n8n/risques/general.md`
|
- `knowledge/n8n/risques/general.md`
|
||||||
- `knowledge/product/patterns/general.md`
|
- `knowledge/product/patterns/general.md`
|
||||||
- `knowledge/product/risques/<thème>.md`
|
- `knowledge/product/risques/<thème>.md`
|
||||||
|
- `knowledge/workflow/patterns/general.md`
|
||||||
- `knowledge/workflow/risques/story-tracking.md`
|
- `knowledge/workflow/risques/story-tracking.md`
|
||||||
- `10_conventions_redaction.md`
|
- `10_conventions_redaction.md`
|
||||||
- `40_decisions_et_archi.md`
|
- `40_decisions_et_archi.md`
|
||||||
@@ -37,7 +38,7 @@ Chaque proposition doit suivre ce format :
|
|||||||
DATE — PROJET
|
DATE — PROJET
|
||||||
|
|
||||||
FILE_UPDATE_PROPOSAL
|
FILE_UPDATE_PROPOSAL
|
||||||
Fichier cible : <knowledge/backend/patterns/<thème>.md | knowledge/backend/risques/<thème>.md | knowledge/frontend/patterns/<thème>.md | knowledge/frontend/risques/<thème>.md | knowledge/ux/patterns/<thème>.md | knowledge/ux/risques/<thème>.md | knowledge/n8n/patterns/general.md | knowledge/n8n/risques/general.md | knowledge/product/patterns/general.md | knowledge/product/risques/<thème>.md | knowledge/workflow/risques/story-tracking.md | 10_conventions_redaction.md | 40_decisions_et_archi.md | 90_debug_et_postmortem.md>
|
Fichier cible : <knowledge/backend/patterns/<thème>.md | knowledge/backend/risques/<thème>.md | knowledge/frontend/patterns/<thème>.md | knowledge/frontend/risques/<thème>.md | knowledge/ux/patterns/<thème>.md | knowledge/ux/risques/<thème>.md | knowledge/n8n/patterns/general.md | knowledge/n8n/risques/general.md | knowledge/product/patterns/general.md | knowledge/product/risques/<thème>.md | knowledge/workflow/patterns/general.md | knowledge/workflow/risques/story-tracking.md | 10_conventions_redaction.md | 40_decisions_et_archi.md | 90_debug_et_postmortem.md>
|
||||||
|
|
||||||
Pourquoi :
|
Pourquoi :
|
||||||
<raison pour laquelle ce savoir mérite d'être capitalisé>
|
<raison pour laquelle ce savoir mérite d'être capitalisé>
|
||||||
@@ -75,7 +76,7 @@ Description courte, factuelle, orientée réutilisation.
|
|||||||
3. La validation et l'intégration finale dans `Lead_tech`
|
3. La validation et l'intégration finale dans `Lead_tech`
|
||||||
sont faites **manuellement**.
|
sont faites **manuellement**.
|
||||||
4. Une fois intégrée, la proposition doit être **supprimée de ce fichier**.
|
4. Une fois intégrée, la proposition doit être **supprimée de ce fichier**.
|
||||||
5. La structure de ce fichier est **restaurée à son état initial** (voir `70_templates/template_a_capitaliser.md`).
|
5. La structure de ce fichier est **restaurée à son état initial** (voir `70_templates/tempate_a_capitaliser.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
|
|||||||
| `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique |
|
| `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique |
|
||||||
| `multi-tenant.md` | Multi-tenant, isolation, feature flags | 403 vs 404, repository tenant-aware, tenantId dans updates, helper tenant partagé, feature flag tenant, EN enforcement |
|
| `multi-tenant.md` | Multi-tenant, isolation, feature flags | 403 vs 404, repository tenant-aware, tenantId dans updates, helper tenant partagé, feature flag tenant, EN enforcement |
|
||||||
| `nextjs.md` | Next.js App Router, Server Actions, isolation | Runtime-only logique pure, server-only isolation, utilitaires purs sans server-only, réutiliser champ V1, validation URL externe |
|
| `nextjs.md` | Next.js App Router, Server Actions, isolation | Runtime-only logique pure, server-only isolation, utilitaires purs sans server-only, réutiliser champ V1, validation URL externe |
|
||||||
| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents |
|
| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents, hooks fire-and-forget après création DB, fanout notification avec filtre grade, auto-purge fenêtre temporelle SQL |
|
||||||
| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable |
|
| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable, ordre canonique des gates HTTP, délégation agrégat → endpoint agrégé, anti-énumération DELETE 204, lazy init memoizé, cap LRU par-user, convention dot-notation audit, whitelist explicite audit, singleton DB config, invalidation cache avant mutation, pipeline CI/CD GitHub Actions → VPS |
|
||||||
|
| `tests.md` | Tests d'intégration DB, isolation, atomicité | `cleanup.track()` LIFO, `globalSetup` purge, template database Postgres, helper `waitForX()` polling-borné, test d'atomicité transaction, convention `describe()` 2 niveaux, refactor itératif d'un fichier monolithe |
|
||||||
|
|||||||
@@ -77,3 +77,177 @@
|
|||||||
- Dead-letter ou statut FAILED visible
|
- Dead-letter ou statut FAILED visible
|
||||||
- Idempotence documentée
|
- Idempotence documentée
|
||||||
- Logs corrélés (requestId/traceId)
|
- Logs corrélés (requestId/traceId)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-hooks-fire-and-forget-creation-db"></a>
|
||||||
|
## Pattern : Hooks fire-and-forget après création DB critique
|
||||||
|
|
||||||
|
- Objectif : déclencher des hooks secondaires (mail accusé réception, notification, invalidation cache) après une création DB sans bloquer la réponse HTTP au client.
|
||||||
|
- Contexte : endpoint POST qui crée une ressource en DB et déclenche en cascade des hooks impliquant des appels réseau (Resend, FCM, Redis cache).
|
||||||
|
- Quand l'utiliser : hooks **rapides** (< 1-2 s) qui peuvent vivre dans le même process que la requête HTTP.
|
||||||
|
- Quand l'éviter : tâches lourdes (génération PDF, batch envoi sur 100 destinataires) — utiliser un vrai job queue (BullMQ, pg-boss).
|
||||||
|
- Avantage :
|
||||||
|
- la 201 part dès la création DB (l'AC critique de la route)
|
||||||
|
- chaque hook logge ses propres échecs sans bloquer le caller
|
||||||
|
- `Promise.allSettled` détaché → robustesse même si un hook futur ajoute un comportement async
|
||||||
|
- Limites / vigilance :
|
||||||
|
- dans Next.js 15+, préférer `after()` (cf. `knowledge/backend/patterns/nextjs.md`) qui garantit l'exécution post-réponse même en serverless
|
||||||
|
- `Promise.all` reject au premier échec — `allSettled` attend toutes les promesses
|
||||||
|
- tests : poll DB borné (`waitForX`) plutôt que `setTimeout(50)` (cf. `knowledge/backend/patterns/tests.md`)
|
||||||
|
- Validé le : 30-04-2026
|
||||||
|
- Contexte technique : Node.js — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ La 201 part dès la création DB ; les hooks tournent en parallèle
|
||||||
|
const created = await prisma.registration.create({ data });
|
||||||
|
|
||||||
|
// Promise.allSettled détaché : ne reject jamais, on capture quand même
|
||||||
|
// au cas où le service de log lui-même bug
|
||||||
|
void Promise.allSettled([
|
||||||
|
sendAcknowledgmentMail(data.email),
|
||||||
|
notifyObservers(created.id),
|
||||||
|
invalidateCache(`stats:${data.scope}`),
|
||||||
|
]).catch((err) => {
|
||||||
|
logger.error({ type: 'hooks', event: 'unexpected_error', err: String(err) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(201, { data: created });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Règles d'utilisation
|
||||||
|
|
||||||
|
1. **L'AC critique doit être atteint avant** : la création DB doit réussir (await) — c'est le seul résultat que le client attend.
|
||||||
|
2. **Chaque hook doit logger ses propres échecs** : le service mail doit avoir son propre `logger.error` sur status=failed. Le `.catch()` du `Promise.allSettled` est un filet, pas le canal d'audit primaire.
|
||||||
|
3. **`Promise.allSettled` (pas `Promise.all`)** : robuste si un hook futur ajoute un comportement asynchrone derrière.
|
||||||
|
4. **Côté tests** : helper `waitForX` polling-borné plutôt que `setTimeout(N)` arbitraire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-fanout-notification-grade-plancher"></a>
|
||||||
|
## Pattern : Notification fanout fire-and-forget avec filtre grade plancher
|
||||||
|
|
||||||
|
- Objectif : notifier N destinataires éligibles (filtrage par grade plancher) après une mutation, sans bloquer la réponse HTTP et sans rollback de la création principale si la notif échoue.
|
||||||
|
- Contexte : action métier qui crée une ressource + doit notifier les membres dont le grade ≥ grade plancher de la ressource (`SOIREE_CANCELLED` à tous les membres, `COMMUNICATION_PUBLISHED` aux membres de grade ≥ X, etc.).
|
||||||
|
- Quand l'utiliser : fanout multi-rôles avec filtrage métier sur le profil destinataire.
|
||||||
|
- Quand l'éviter : si la notif est critique (la ressource ne doit pas exister sans notif) — utiliser une transaction.
|
||||||
|
- Avantage :
|
||||||
|
- seuil monotone `gradeRank(member) >= gradeRank(resource)` aligné sur les filtres `list*` consommateurs
|
||||||
|
- exclusion du créateur via `id: { not: userId }` pour éviter de se notifier soi-même
|
||||||
|
- log explicite sur `catch` du fire-and-forget — pas de perte silencieuse
|
||||||
|
- Limites / vigilance :
|
||||||
|
- pas de transaction avec la création principale : best-effort, dégradation acceptable
|
||||||
|
- le `linkUrl` doit être rôle-aware (cf. `knowledge/backend/risques/general.md` risque-notif-linkurl-non-role-aware)
|
||||||
|
- Validé le : 23-04-2026
|
||||||
|
- Contexte technique : Prisma — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const createResourceNotifications = async (input: {
|
||||||
|
resourceId: string;
|
||||||
|
grade: string; // plancher (seuil monotone)
|
||||||
|
excludeUserId?: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const thresholdRank = gradeRank(input.grade);
|
||||||
|
|
||||||
|
const recipients = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
role: { in: [...ROLES_ALL_ACTIVE] },
|
||||||
|
id: input.excludeUserId ? { not: input.excludeUserId } : undefined,
|
||||||
|
profile: { is: {} },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true, // pour linkUrl rôle-aware si multi-rôles
|
||||||
|
profile: { select: { grade: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eligibleIds = recipients
|
||||||
|
.filter((r) => {
|
||||||
|
const g = r.profile?.grade;
|
||||||
|
if (!g) return false;
|
||||||
|
return gradeRank(g) >= thresholdRank;
|
||||||
|
})
|
||||||
|
.map((r) => r.id);
|
||||||
|
|
||||||
|
if (eligibleIds.length === 0) return;
|
||||||
|
|
||||||
|
await prisma.notification.createMany({
|
||||||
|
data: eligibleIds.map((recipientId) => ({
|
||||||
|
type: NotificationType.RESOURCE_CREATED,
|
||||||
|
recipientId,
|
||||||
|
// …
|
||||||
|
linkUrl: ..., // rôle-aware si nécessaire
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Côté handler
|
||||||
|
try {
|
||||||
|
const resource = await createResource({ ... });
|
||||||
|
logAction(userId, 'resource:create', ...);
|
||||||
|
|
||||||
|
// Fire-and-forget
|
||||||
|
void createResourceNotifications({
|
||||||
|
resourceId: resource.id,
|
||||||
|
...minimumDataForNotif,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[resource:create] notification fanout failed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(201, { data: serialize(resource) });
|
||||||
|
} catch {
|
||||||
|
return errorResponse(500, ...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pourquoi un seuil monotone
|
||||||
|
|
||||||
|
`gradeRank(member) >= gradeRank(resource)` = "à partir du grade X", aligné sur les filtres `list*` consommateurs. Évite les sélections non-contiguës (A+M sans C) qui sont pénibles à représenter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-auto-purge-fenetre-temporelle-sql"></a>
|
||||||
|
## Pattern : Auto-purge côté vue via fenêtre temporelle SQL
|
||||||
|
|
||||||
|
- Objectif : faire porter la rétention courte par le filtre de lecture plutôt que par un cron de purge réelle, quand une donnée a deux publics avec des besoins de rétention différents.
|
||||||
|
- Contexte : donnée consultée à long terme côté admin/historique mais utile uniquement sur fenêtre courte côté consommateur final (membre lambda).
|
||||||
|
- Quand l'utiliser : 2 publics, rétention courte côté consommateur, rétention longue côté admin, volumétrie raisonnable.
|
||||||
|
- Quand l'éviter :
|
||||||
|
- volumétrie très élevée (millions de rows) — finir par un vrai archivage si le volume explose
|
||||||
|
- RGPD / obligations légales de suppression — il faut **vraiment** supprimer la donnée, pas la masquer
|
||||||
|
- données avec coût de stockage significatif (PDF, blobs, logs verbeux) — purge réelle + archivage externe
|
||||||
|
- Avantage :
|
||||||
|
- pas de cron à écrire, déployer, monitorer
|
||||||
|
- zéro risque de purge destructive : la donnée reste en DB
|
||||||
|
- rétention courte est **déclarative** (paramètre de query), pas cachée dans un job planifié
|
||||||
|
- l'admin conserve l'accès complet via un autre endpoint
|
||||||
|
- Limites / vigilance :
|
||||||
|
- index sur `createdAt` indispensable dès que la table grossit
|
||||||
|
- Validé le : 23-04-2026
|
||||||
|
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const listRecentXxxForMember = async (
|
||||||
|
...filters,
|
||||||
|
sinceDays = 30,
|
||||||
|
) => {
|
||||||
|
const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000);
|
||||||
|
return prisma.xxx.findMany({
|
||||||
|
where: {
|
||||||
|
...filters,
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
L'admin garde un endpoint distinct sans le filtre temporel pour l'accès historique complet.
|
||||||
|
|||||||
@@ -342,3 +342,338 @@ Le helper `requireRoleAccess` doit retourner :
|
|||||||
- Contexte technique : auth / audit — RL799_V2
|
- Contexte technique : auth / audit — RL799_V2
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<a id="pattern-consume-single-use-pas-session-implicite"></a>
|
||||||
|
## Pattern : Consume single-use = pas de session implicite, force re-login
|
||||||
|
|
||||||
|
- Objectif : éviter qu'un endpoint qui consume un magic link (invitation, reset password) émette une session implicite — défense en profondeur contre l'interception d'email.
|
||||||
|
- Contexte : flows magic-link qui aboutissent à un `consume` (set du nouveau mot de passe ou activation du compte).
|
||||||
|
- Quand l'utiliser : tous les flows single-use qui touchent au cycle d'authentification.
|
||||||
|
- Quand l'éviter : magic-link "passwordless" pur (sans étape de saisie de mot de passe) où le link EST le facteur d'auth.
|
||||||
|
- Avantage :
|
||||||
|
- un attaquant qui intercepte le mail obtient un consume, pas une session
|
||||||
|
- cohérence cross-flow : un seul pattern post-consume, surface d'audit réduite
|
||||||
|
- factorisation page consume facilitée (les modes diffèrent par wording, pas par flow)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- friction perçue d'un re-login → minime, l'utilisateur vient de saisir son mot de passe
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : auth / magic-link — RL799_V2
|
||||||
|
|
||||||
|
### Règle
|
||||||
|
|
||||||
|
Réponse minimale post-consume : `{ data: { ok: true, email } }`. Pas de cookie JWT, pas d'access token. Le frontend redirige vers `/login?email=…` avec bouton "Se connecter" bien placé sur la page de succès.
|
||||||
|
|
||||||
|
### Anti-pattern
|
||||||
|
|
||||||
|
- "Auto-login pour invitation, re-login pour reset" sous prétexte UX — divergence asymétrique = anti-pattern de sécurité
|
||||||
|
- Cookie JWT émis avant que l'utilisateur ait validé qu'il connaît son nouveau mot de passe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-magic-link-consume-sans-fusion-table"></a>
|
||||||
|
## Pattern : Magic-link consume — mécanique partagée sans fusion table
|
||||||
|
|
||||||
|
- Objectif : factoriser la mécanique sécu d'un consume single-use (hash SHA-256, transaction atomique, révocation refresh tokens) sans fusionner les modèles DB des deux domaines.
|
||||||
|
- Contexte : projet avec deux flows partageant la même mécanique (invitation magic-link + reset password) mais des objets métier différents (invitation = audit riche, reset = purement technique).
|
||||||
|
- Quand l'utiliser : factorisation au niveau **service**, pas au niveau table.
|
||||||
|
- Quand l'éviter : un seul flow magic-link dans le projet — pas de factorisation prématurée.
|
||||||
|
- Avantage :
|
||||||
|
- audit historique riche pour les invitations (statut traçable)
|
||||||
|
- transaction atomique partagée (rotation/consume)
|
||||||
|
- rate limiter dédié par endpoint sans pollution cross-domaine
|
||||||
|
- Limites / vigilance :
|
||||||
|
- duplication contrôlée des modèles DB (acceptable — chaque domaine a son cycle de vie)
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : auth / magic-link / Prisma — RL799_V2
|
||||||
|
|
||||||
|
### Règles d'or
|
||||||
|
|
||||||
|
1. **Tokens stockés en hash SHA-256 uniquement** (`tokenHash @unique`). Le raw token n'est transmis que dans l'URL email, jamais persisté. Helper `hashXxxToken(raw: string): string` réutilisable côté service ET tests.
|
||||||
|
|
||||||
|
2. **PK technique `id String @id @default(uuid())`**, pas le token comme PK. Évite le couplage PK/sécurité, permet la rotation d'un token sans créer une nouvelle row.
|
||||||
|
|
||||||
|
3. **Transaction atomique consume** :
|
||||||
|
- Lock implicite via `findUnique({ where: { tokenHash }, include: { user } })`
|
||||||
|
- Check `status === 'active'`, `consumedAt === null`, `revokedAt === null`, `expiresAt > now`, `user.isActive === true`
|
||||||
|
- UPDATE token `status='consumed', consumedAt=now()`
|
||||||
|
- UPDATE user (password si applicable)
|
||||||
|
- UPDATE refresh_tokens `revokedAt=now()` (force re-login propre)
|
||||||
|
- UPDATE autres tokens actifs `status='revoked'` (anti-réutilisation)
|
||||||
|
- INSERT audit dans la transaction (atomicité stricte)
|
||||||
|
|
||||||
|
4. **Retour discriminé** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ConsumeResult =
|
||||||
|
| { ok: true; userId: string; email: string }
|
||||||
|
| { ok: false; reason: 'NOT_FOUND' | 'ALREADY_USED' | 'EXPIRED' | 'REVOKED' | 'USER_INACTIVE' };
|
||||||
|
```
|
||||||
|
|
||||||
|
Le frontend ne connaît qu'un code générique côté UX (`INVALID_X_TOKEN`) pour ne pas exposer d'oracle.
|
||||||
|
|
||||||
|
5. **Rate limiter dédié par endpoint** :
|
||||||
|
- `validate` : 30 req / 15 min / IP (oracle d'énumération)
|
||||||
|
- `consume` : 10 req / 15 min / IP (modifie le mot de passe)
|
||||||
|
- `resend` (admin) : 20 req / heure / admin
|
||||||
|
|
||||||
|
6. **Helpers transverses** : `revokeAndIssueXxx(input)` en transaction unique (couplé à un index unique partiel `WHERE status='active'`), `revokeXxxForUser(userId)` (appelé par `setUserActive(false)`), `getLatestXxxByUserIds(userIds[])` batch (anti N+1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-sentinelle-non-hashable-user-invite"></a>
|
||||||
|
## Pattern : Sentinelle non-hashable pour user en attente de mot de passe
|
||||||
|
|
||||||
|
- Objectif : éviter à la fois un `password: String?` nullable qui casse les chemins login/test/audit qui font tous des `select: { password }`, et un appel scrypt inutile (~100 ms par user invité).
|
||||||
|
- Contexte : user créé via invitation qui n'a pas encore défini son mot de passe.
|
||||||
|
- Quand l'utiliser : tout flow d'invitation où le user existe en base avant le set du password.
|
||||||
|
- Quand l'éviter : projet où le user n'est créé qu'au consume du magic link (pas de placeholder nécessaire).
|
||||||
|
- Avantage :
|
||||||
|
- le champ `password` reste `NOT NULL` en DB → aucun chemin code ne casse
|
||||||
|
- `verifyPassword` détecte le préfixe en early-return → 0 ms scrypt
|
||||||
|
- garantie cryptographique (pas conventionnelle) : le préfixe `!` est absent d'une sortie hex
|
||||||
|
- Limites / vigilance :
|
||||||
|
- la sentinelle est lisible en clair par un admin DB — acceptable car c'est un placeholder identifié, pas un secret
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : auth / scrypt — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/.../inviteService.ts
|
||||||
|
const buildInvitedPlaceholderPassword = (): string =>
|
||||||
|
`!INVITED_PENDING_${crypto.randomUUID()}`;
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { email, password: buildInvitedPlaceholderPassword(), ... },
|
||||||
|
});
|
||||||
|
|
||||||
|
// lib/passwords.ts
|
||||||
|
export const verifyPassword = (password: string, stored: StoredPassword): boolean => {
|
||||||
|
if (typeof stored !== 'string' || typeof password !== 'string') return false;
|
||||||
|
if (stored.startsWith('!')) return false; // placeholder réservé, jamais matchable
|
||||||
|
// …logique scrypt habituelle
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Préfixe `!` (caractère **garanti** absent de l'alphabet hex `0-9a-f`)
|
||||||
|
- [ ] UUID embarqué pour l'unicité par user (utile pour debug audit, pas un secret)
|
||||||
|
- [ ] Test : `verifyPassword('anything', '!INVITED_PENDING_xxx') === false`
|
||||||
|
- [ ] AC dédié : `verifyPassword` rejette en 0 ms observable (pas d'appel scrypt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-ttl-court-bouton-resend"></a>
|
||||||
|
## Pattern : TTL court + bouton resend admin > TTL longue
|
||||||
|
|
||||||
|
- Objectif : minimiser la fenêtre d'exposition d'un token sensible sans dégrader l'UX dans les cas marginaux (user en vacances).
|
||||||
|
- Contexte : tokens d'authentification sensibles (invitation, reset password) où la tentation est d'allonger la TTL pour couvrir les cas de longue absence.
|
||||||
|
- Quand l'utiliser : tout token sensible avec un canal admin disponible pour relancer.
|
||||||
|
- Quand l'éviter : tokens où le resend est impossible (signed URLs publiques sans admin).
|
||||||
|
- Avantage :
|
||||||
|
- surface d'attaque réduite (token intercepté n'est utilisable que pendant la TTL courte)
|
||||||
|
- granularité opérationnelle : l'admin trace dans l'audit qui demande un resend
|
||||||
|
- invariant "1 active par user" reste applicable (le resend révoque l'ancien et émet un nouveau)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- rate limiter sur le bouton resend (20/h/admin) pour éviter le spam involontaire
|
||||||
|
- pas d'extension d'`expiresAt` au resend — émettre un nouveau token, pas patcher l'ancien
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : auth / magic-link — RL799_V2
|
||||||
|
|
||||||
|
### Règles d'or
|
||||||
|
|
||||||
|
- TTL en constante explicite côté service (`7 * 24 * 60 * 60 * 1000`), pas en magic number éparpillé
|
||||||
|
- Fenêtres recommandées : invitation ≤ 7 jours, reset password ≤ 24 h
|
||||||
|
- Audit `xxx.resend` sur le bouton admin + audit `xxx.revoke` (cohérence avec resend qui révoque l'ancien)
|
||||||
|
- AC dédié : `expiresAt - createdAt ≈ N * 24h ± 1s` (tolérance fixture)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-hook-setuseractive-revoke-side-tokens"></a>
|
||||||
|
## Pattern : Hook `setUserActive(false)` → revoke side-tokens
|
||||||
|
|
||||||
|
- Objectif : éviter qu'un user désactivé puisse continuer à consommer un magic link reçu juste avant la désactivation et reprendre la main.
|
||||||
|
- Contexte : opération admin de désactivation user dans un projet avec tokens secondaires actifs (refresh tokens, invitations, futurs reset password tokens).
|
||||||
|
- Quand l'utiliser : tout endpoint qui transitionne `user.isActive` de `true` à `false`.
|
||||||
|
- Quand l'éviter : si la désactivation est purement métier (suspension UI) sans portée auth.
|
||||||
|
- Avantage :
|
||||||
|
- défense en profondeur (le checker du consume vérifie aussi `user.isActive` — ceinture + bretelles)
|
||||||
|
- les tokens orphelins ne survivent pas à la désactivation
|
||||||
|
- Limites / vigilance :
|
||||||
|
- best-effort tracé : les revokes ne doivent pas bloquer la désactivation (l'admin attend un retour immédiat)
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : auth / lifecycle user — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!body.isActive) {
|
||||||
|
try {
|
||||||
|
await revokeAllRefreshTokensForUser(userId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[admin.users] refresh token revoke failed', err);
|
||||||
|
refreshTokenWarning = '...';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await revokeInvitationsForUser(userId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[admin.users] invitation revoke failed', err);
|
||||||
|
}
|
||||||
|
// À ajouter quand pertinent : reset password tokens, OAuth states, etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Règles d'or
|
||||||
|
|
||||||
|
- **Best-effort tracé**, pas silencieux : `try { ... } catch { console.error(...) }`
|
||||||
|
- Warning UX en cas d'échec partiel : la réponse JSON peut contenir un champ optionnel `warning` que le frontend affiche
|
||||||
|
- **Double protection consume** : le checker du consume vérifie également `user.isActive === true` — même si le revoke échoue, l'user désactivé ne peut pas consommer
|
||||||
|
- AC dédié : "user actif avec invitation pending → admin désactive → consume échoue avec INVALID_TOKEN, pas de session créée"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-magic-link-url-clean"></a>
|
||||||
|
## Pattern : Magic link "URL clean" — token signé HMAC + `history.replaceState`
|
||||||
|
|
||||||
|
- Objectif : ouvrir une page web qui pré-charge un contexte serveur (soireeId, eventId, inviteId) sans que l'identifiant ne reste visible dans l'URL navigable.
|
||||||
|
- Contexte : mail (convocation, invitation, RSVP) avec un bouton qui mène vers une landing page PWA. On veut éviter forward, capture, indexation involontaire.
|
||||||
|
- Quand l'utiliser : magic links publics vers une page qui pré-charge un contexte sensible.
|
||||||
|
- Quand l'éviter : magic links d'authentification membre — utiliser les patterns auth classiques (refresh + access httpOnly).
|
||||||
|
- Avantage :
|
||||||
|
- URL visible côté utilisateur ne contient pas l'identifiant
|
||||||
|
- HMAC garantit l'intégrité (custom claim `purpose` pour rejeter un token signé pour un autre usage)
|
||||||
|
- `sessionStorage` plutôt que `localStorage` → contexte meurt avec l'onglet
|
||||||
|
- Limites / vigilance :
|
||||||
|
- `algorithms: ['HS256']` imposé côté `jwt.verify` pour bloquer les "alg: none" attacks
|
||||||
|
- sécurité dépendante de l'isolation cryptographique du secret (cf. pattern dérivation HMAC)
|
||||||
|
- Validé le : 30-04-2026
|
||||||
|
- Contexte technique : Node.js crypto / Vue / Vite PWA — RL799_V2
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Mail HTML
|
||||||
|
↓ Bouton (GET https://app/visit?t=<token-signé>)
|
||||||
|
Landing PWA /visit
|
||||||
|
↓ JS lit le token, POST /api/.../redeem { token }
|
||||||
|
↓ Backend valide HMAC + extrait contextId, retourne metadata
|
||||||
|
↓ JS stocke contextId en sessionStorage
|
||||||
|
↓ history.replaceState(null, '', '/inscription')
|
||||||
|
↓ router.replace() pour synchroniser le router SPA
|
||||||
|
Page d'inscription (URL clean)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend — signature
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export function signAccessToken(contextId: string, expiresAt: Date): string {
|
||||||
|
const expSeconds = Math.floor(expiresAt.getTime() / 1000);
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: contextId, purpose: 'rsvp-v1', exp: expSeconds },
|
||||||
|
getDerivedSecret(),
|
||||||
|
{ algorithm: 'HS256' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redeemAccessToken(token: string):
|
||||||
|
| { ok: true; contextId: string }
|
||||||
|
| { ok: false; reason: 'expired' | 'invalid' } {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, getDerivedSecret(), {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
}) as { sub?: string; purpose?: string };
|
||||||
|
if (decoded.purpose !== 'rsvp-v1') return { ok: false, reason: 'invalid' };
|
||||||
|
if (typeof decoded.sub !== 'string') return { ok: false, reason: 'invalid' };
|
||||||
|
return { ok: true, contextId: decoded.sub };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof jwt.TokenExpiredError) return { ok: false, reason: 'expired' };
|
||||||
|
return { ok: false, reason: 'invalid' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend — landing minimaliste
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
onMounted(async () => {
|
||||||
|
const token = route.query.t as string;
|
||||||
|
if (!token) return showError('Lien incomplet');
|
||||||
|
|
||||||
|
const result = await redeemToken(token);
|
||||||
|
saveSessionContext(result); // sessionStorage
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && window.history?.replaceState) {
|
||||||
|
window.history.replaceState(null, '', '/inscription');
|
||||||
|
}
|
||||||
|
await router.replace({ name: 'inscription' });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Choix d'expiration
|
||||||
|
|
||||||
|
- Magic link auth : court (15 min – 24 h), token consommable une fois
|
||||||
|
- RSVP événement : long (jusqu'à la date)
|
||||||
|
- Invitation one-shot : moyen (7-30 jours), invalidable à l'usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-isolation-cryptographique-hmac"></a>
|
||||||
|
## Pattern : Isolation cryptographique — secret dérivé via HMAC
|
||||||
|
|
||||||
|
- Objectif : signer plusieurs types de tokens isolés cryptographiquement sans ajouter une nouvelle env var par usage.
|
||||||
|
- Contexte : projet avec un `JWT_SECRET` racine (auth membre) qui doit signer un autre type de token (magic link, RSVP, webhook) sans cross-domain attack possible.
|
||||||
|
- Quand l'utiliser : besoin d'un secret de signature isolé sans nouvelle clé à provisionner et à tourner.
|
||||||
|
- Quand l'éviter : si le projet supporte déjà un système de gestion de clés multiples (KMS, Vault) — utiliser le mécanisme natif.
|
||||||
|
- Avantage :
|
||||||
|
- une seule env var racine à provisionner et tourner
|
||||||
|
- HMAC est one-way : un attaquant qui obtient le secret dérivé ne peut pas remonter au racine
|
||||||
|
- reproductible : `(JWT_SECRET, purpose)` → même secret, pas d'état à persister
|
||||||
|
- versionnable via le purpose (`-v1`, `-v2`) : rotation possible en bumpant le purpose
|
||||||
|
- Limites / vigilance :
|
||||||
|
- n'est PAS un substitut à la rotation de clés : si `JWT_SECRET` est compromis, tous les secrets dérivés le sont aussi
|
||||||
|
- HKDF est plus rigoureux pour la dérivation formelle ; pour un simple isolement d'usage, `HMAC(secret, purpose)` suffit
|
||||||
|
- Validé le : 30-04-2026
|
||||||
|
- Contexte technique : Node.js crypto — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
const TOKEN_PURPOSE = 'magic-link-v1';
|
||||||
|
let cachedSecret: Buffer | null = null;
|
||||||
|
|
||||||
|
function getDerivedSecret(): Buffer {
|
||||||
|
if (cachedSecret) return cachedSecret;
|
||||||
|
const root = process.env.JWT_SECRET;
|
||||||
|
if (!root) throw new Error('JWT_SECRET requis');
|
||||||
|
cachedSecret = createHmac('sha256', root).update(TOKEN_PURPOSE).digest();
|
||||||
|
return cachedSecret;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test d'isolation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('rejette un token signé avec un autre purpose', async () => {
|
||||||
|
const tokenAsMember = jwt.sign(
|
||||||
|
{ sub: 'x' },
|
||||||
|
process.env.JWT_SECRET!,
|
||||||
|
{ algorithm: 'HS256', expiresIn: '15m' },
|
||||||
|
);
|
||||||
|
const result = redeemToken(tokenAsMember);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Applications
|
||||||
|
|
||||||
|
- Tokens magic link / RSVP / invitation
|
||||||
|
- Signature de webhooks sortants
|
||||||
|
- Tokens d'unsubscribe email (lien direct sans login)
|
||||||
|
- Tokens d'accès aux ressources publiques limitées dans le temps
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -187,3 +187,268 @@ Quand une fonction crypto travaille en base64 pour la sérialisation, prévoir u
|
|||||||
### Signal review
|
### Signal review
|
||||||
|
|
||||||
- `buffer.toString('base64')` suivi immédiatement de `decrypt(base64String)` qui fait `Buffer.from(str, 'base64')` → round-trip inutile
|
- `buffer.toString('base64')` suivi immédiatement de `decrypt(base64String)` qui fait `Buffer.from(str, 'base64')` → round-trip inutile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-zod-strict-mutations"></a>
|
||||||
|
## Pattern : Zod `.strict()` systématique sur les schémas de mutation
|
||||||
|
|
||||||
|
- Objectif : bloquer la pollution de champs internes via PATCH/POST/PUT en rejetant tout champ supplémentaire non listé dans le schéma.
|
||||||
|
- Contexte : tout schéma Zod qui valide un payload de mutation côté API.
|
||||||
|
- Quand l'utiliser : systématiquement sur tous les schémas de mutation.
|
||||||
|
- Quand l'éviter : schémas de réponse (où l'API est l'émetteur) ou schémas d'enrichissement intentionnel.
|
||||||
|
- Avantage :
|
||||||
|
- première ligne de défense contre la pollution de payload (`uploadedBy`, `createdAt`, `isAdmin` injectés par un client malveillant)
|
||||||
|
- rejet à 400 avant d'atteindre Prisma → pas de risque de spread accidentel dans `data: parsed.data`
|
||||||
|
- Limites / vigilance :
|
||||||
|
- ne dispense pas de la deuxième ligne de défense : ne JAMAIS spread `parsed.data` directement dans `prisma.update`, construire `data` au champ près
|
||||||
|
- Validé le : 20-04-2026
|
||||||
|
- Contexte technique : TypeScript / Zod — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const updateXxxSchema = z.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
status: z.enum(['active', 'inactive']).optional(),
|
||||||
|
}).strict();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combiné avec le repo
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const data: Partial<UpdateXxxData> = {};
|
||||||
|
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
||||||
|
if (parsed.data.status !== undefined) data.status = parsed.data.status;
|
||||||
|
// …jamais `data: parsed.data` brut
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test à ajouter
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('PATCH .strict() rejette les champs hors-whitelist', async () => {
|
||||||
|
const r = await PATCH(makeReq({ name: 'OK', uploadedBy: 'attacker' }));
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-rigidification-zod-2-phases"></a>
|
||||||
|
## Pattern : Rigidification Zod en 2 phases (données d'abord, schémas ensuite)
|
||||||
|
|
||||||
|
- Objectif : rigidifier un schéma Zod artificiellement laxiste sans casser la suite de tests en cascade.
|
||||||
|
- Contexte : schéma qui accepte une forme large (`z.string().min(1).max(128)`) pour compenser une donnée hétérogène en base (slugs + UUIDs cohabitent), avant d'avoir uniformisé la donnée.
|
||||||
|
- Quand l'utiliser : tout chantier de rigidification (`.uuid()`, `.email()`, `.enum()`) sur un champ dont la base contient encore l'ancien format.
|
||||||
|
- Quand l'éviter : si la donnée est déjà uniforme — rigidifier directement.
|
||||||
|
- Avantage :
|
||||||
|
- diagnostic séparé : si le commit 2 casse un test, on sait que c'est la rigidification, pas la migration
|
||||||
|
- rollback granulaire : on peut rollback la rigidification sans reperdre la migration
|
||||||
|
- revue plus lisible : un reviewer valide indépendamment "migration correcte" puis "rigidification sûre"
|
||||||
|
- Limites / vigilance :
|
||||||
|
- tentation de tout faire d'un coup → écarter
|
||||||
|
- Validé le : 24-04-2026
|
||||||
|
- Contexte technique : Zod / Prisma — RL799_V2
|
||||||
|
|
||||||
|
### Séquence obligatoire (2 commits séparés)
|
||||||
|
|
||||||
|
**Phase 1 — Normalisation des données** :
|
||||||
|
- Migrer la base (seed, fixtures, lignes legacy via `prisma migrate`)
|
||||||
|
- Adapter tous les consommateurs qui référencent l'ancien format (tests, helpers E2E, scripts admin)
|
||||||
|
- Le schéma Zod reste laxiste à ce stade — il accepte les deux formats pendant la transition
|
||||||
|
- Ajouter un test d'invariant qui valide que la base ne contient plus que le format cible
|
||||||
|
- Commit : `feat(<domaine>): migration <X> + adaptation tests`
|
||||||
|
|
||||||
|
**Phase 2 — Rigidification du schéma** :
|
||||||
|
- Remplacer `z.string()` par `z.uuid()` / `z.email()` / `z.enum()` sur les champs concernés
|
||||||
|
- Adapter les quelques tests qui reposaient sur l'ancienne sémantique laxiste
|
||||||
|
- Vérifier par grep final qu'aucun autre schéma n'a le même pattern laxiste oublié
|
||||||
|
- Commit : `feat(<domaine>): rigidification Zod sur <X>`
|
||||||
|
|
||||||
|
### Signaux de dérive
|
||||||
|
|
||||||
|
- Schéma avec un commentaire "accepte toute chaîne pour compatibilité avec X" → dette à rigidifier dès que X est migré
|
||||||
|
- `.min(1).max(128)` sur un champ conceptuellement UUID/email/enum → forme laxiste en attente de rigidification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-enum-canonique-sous-ensembles-nommes"></a>
|
||||||
|
## Pattern : Enum canonique + sous-ensembles nommés (vs flags par usage)
|
||||||
|
|
||||||
|
- Objectif : factoriser les règles métier sur une enum partagée par plusieurs domaines fonctionnels sans alourdir l'enum elle-même de flags.
|
||||||
|
- Contexte : enum (rôles, statuts, types) qui sert plusieurs usages avec des règles différentes (annuaire, pointage rituel, mandats administratifs).
|
||||||
|
- Quand l'utiliser : dès qu'un même `enum.filter(r => …)` apparaît à plusieurs endroits avec une règle métier explicite.
|
||||||
|
- Quand l'éviter : si le filtre n'apparaît qu'une fois — laisser inline, l'extraction est prématurée.
|
||||||
|
- Avantage :
|
||||||
|
- chaque sous-ensemble a un nom métier explicite — le lecteur comprend sans chercher
|
||||||
|
- les règles sont localisées au point de définition, pas éparpillées en flags
|
||||||
|
- ajouter un usage = ajouter un sous-ensemble, pas modifier la structure de l'enum
|
||||||
|
- Limites / vigilance :
|
||||||
|
- les sous-ensembles doivent être typés `readonly Role[]` pour bénéficier du narrowing
|
||||||
|
- propagation côté front ET côté Zod backend (defense-in-depth)
|
||||||
|
- Validé le : 21-04-2026
|
||||||
|
- Contexte technique : TypeScript / Zod — RL799_V2
|
||||||
|
|
||||||
|
### Anti-pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Flag par usage, multipliable, illisible
|
||||||
|
export const OFFICER_ROLES = [
|
||||||
|
{ code: 'venerable', label: '...', isRitual: true, isAdmin: true },
|
||||||
|
{ code: 'archiviste', label: '...', isRitual: false, isAdmin: true },
|
||||||
|
// … 12 rôles × 3-4 flags
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern correct
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const OFFICER_ROLES = [
|
||||||
|
'venerable', 'premier-surveillant', /* … */ 'archiviste',
|
||||||
|
] as const;
|
||||||
|
type OfficerRole = (typeof OFFICER_ROLES)[number];
|
||||||
|
|
||||||
|
/** Officiers avec fonction rituelle pendant la tenue (pointage). */
|
||||||
|
export const RITUAL_OFFICER_ROLES: readonly OfficerRole[] =
|
||||||
|
OFFICER_ROLES.filter((role) => role !== 'archiviste');
|
||||||
|
|
||||||
|
/** Officiers éligibles à un mandat administratif. */
|
||||||
|
export const MANDATABLE_OFFICER_ROLES = OFFICER_ROLES;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Propagation Zod backend
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Le sous-ensemble est utilisé côté front ET côté Zod
|
||||||
|
export const tenueOfficerAssignmentSchema = z.object({
|
||||||
|
role: z.enum(RITUAL_OFFICER_ROLES as readonly [OfficerRole, ...OfficerRole[]]),
|
||||||
|
});
|
||||||
|
// → POST avec role: 'archiviste' = 400, sans duplication de la règle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-constantes-variant-fige-selecteur-strict"></a>
|
||||||
|
## Pattern : Constantes par variant figé + sélecteur enum strict
|
||||||
|
|
||||||
|
- Objectif : figer dans le code des règles ou textes versionnés via Git tout en sélectionnant l'implémentation à l'exécution via un champ DB (tenant, pays, juridiction).
|
||||||
|
- Contexte : règles métier figées (CGV par juridiction, formats de facture par pays, libellés réglementaires par régulateur) qui doivent rester typées strictement et versionnées via Git, mais sélectionnées au runtime.
|
||||||
|
- Quand l'utiliser : préparation multi-variant **avant** d'avoir réellement plusieurs implémentations, OU cas où on veut des diffs visibles dans la PR à chaque modification (texte à autorité).
|
||||||
|
- Quand l'éviter : règles métier admin-éditables runtime — ces données appartiennent à la DB, pas au code.
|
||||||
|
- Avantage :
|
||||||
|
- une seule source de vérité par variant, typée strictement
|
||||||
|
- étendre l'union à `'A' | 'B'` propage automatiquement la nouvelle option (Zod, UI, tests)
|
||||||
|
- diff visible dans la PR à chaque modification — review éclate sur un mot changé
|
||||||
|
- Limites / vigilance :
|
||||||
|
- throw explicite dans le sélecteur (pas de fallback silencieux) — un drift DB doit échouer fort
|
||||||
|
- pour du texte à autorité, préférer `expect(X).toBe(...)` à `toMatchSnapshot` — diff visible vs snapshot file rarement lu
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : TypeScript / Zod — RL799_V2
|
||||||
|
|
||||||
|
### Structure type
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/shared/src/<domain>/
|
||||||
|
types.ts ← SupportedXCode union fermée + SUPPORTED_X_CODES tuple runtime
|
||||||
|
<variantA>.ts ← Constantes du variant A (typées <Constants>)
|
||||||
|
index.ts ← getXConstants(code) + isSupportedXCode + UnsupportedXError
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source de vérité unique pour le code
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// types.ts
|
||||||
|
export type SupportedRiteCode = 'REAA';
|
||||||
|
export const SUPPORTED_RITE_CODES = ['REAA'] as const
|
||||||
|
satisfies readonly SupportedRiteCode[];
|
||||||
|
```
|
||||||
|
|
||||||
|
`SUPPORTED_RITE_CODES` est consommé partout :
|
||||||
|
- `z.enum([...SUPPORTED_RITE_CODES] as [...])` côté validation
|
||||||
|
- `<select v-for="code in SUPPORTED_RITE_CODES">` côté UI
|
||||||
|
- `switch` exhaustif dans `getXConstants`
|
||||||
|
|
||||||
|
### Sélecteur avec narrowing runtime
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class UnsupportedRiteError extends Error { /* … */ }
|
||||||
|
|
||||||
|
export const isSupportedRiteCode = (v: string): v is SupportedRiteCode =>
|
||||||
|
(SUPPORTED_RITE_CODES as readonly string[]).includes(v);
|
||||||
|
|
||||||
|
export const getRitualConstants = (code: SupportedRiteCode): RitualConstants => {
|
||||||
|
switch (code) {
|
||||||
|
case 'REAA': return REAA_RITUAL;
|
||||||
|
}
|
||||||
|
throw new UnsupportedRiteError(code); // garde-fou DB drift
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Côté service backend, `assertSupportedX(record.code)` AVANT d'exposer dans le DTO public — protège contre une row DB qui aurait drift.
|
||||||
|
|
||||||
|
### Tests : assertions explicites pour texte à autorité
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
expect(X.formule).toBe('chaîne exacte'); // diff visible en review
|
||||||
|
|
||||||
|
// Avec glyphes Unicode à risque de swap (ex : ' U+0027 vs ’ U+2019)
|
||||||
|
expect([...X.formule].map(c => c.codePointAt(0)!)).toEqual([0x41, 0x2234, /* … */]);
|
||||||
|
expect(X.formule).not.toMatch(/'/); // anti-régression typographique
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
|
||||||
|
- Stocker le texte figé en DB "pour pouvoir l'éditer plus tard" — si le texte est versionné, il appartient au code
|
||||||
|
- Hardcoder le code variant dans la validation UI (`if (code === 'REAA')`) — toujours dériver de `SUPPORTED_X_CODES` runtime
|
||||||
|
- Fallback silencieux dans le sélecteur (`switch (code) { default: return DEFAULT }`) — throw explicite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-regex-critique-partagee-anti-divergence"></a>
|
||||||
|
## Pattern : Regex critique partagée serveur ↔ client (anti-divergence)
|
||||||
|
|
||||||
|
- Objectif : éviter qu'une règle de validation critique (regex anti open-redirect, format de slug) ne dérive entre serveur (Zod) et client (composant, store, Service Worker).
|
||||||
|
- Contexte : règle de sécurité ou d'intégrité qui doit s'appliquer identiquement des deux côtés.
|
||||||
|
- Quand l'utiliser : règle où une divergence côté un seul des deux mène à un trou (anti open-redirect, anti SQL injection visible client-side, format de path/URL).
|
||||||
|
- Quand l'éviter : règle UX uniquement (pattern d'email pour autocomplétion live).
|
||||||
|
- Avantage :
|
||||||
|
- une seule source de vérité — `packages/shared` ou équivalent
|
||||||
|
- dérive impossible (ou détectée au build TS) si l'import partagé est possible
|
||||||
|
- Limites / vigilance :
|
||||||
|
- si le client ne peut PAS importer le package partagé (cas Service Worker en mode `injectManifest`), DUPLIQUER avec un commentaire `⚠️ DOIT correspondre à <chemin>` + un test croisé qui vérifie l'alignement string-wise
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : monorepo TypeScript — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation (cas idéal — import partagé)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/shared/src/dto/push.ts (source de vérité)
|
||||||
|
/**
|
||||||
|
* Regex unique anti open-redirect : démarre par '/' simple (pas '//'),
|
||||||
|
* caractères alphanum + '/_-?&=%.', pas de ':' (bloque 'javascript:').
|
||||||
|
*/
|
||||||
|
export const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
|
||||||
|
|
||||||
|
export const pushPayloadSchema = z.object({
|
||||||
|
linkUrl: z.string().regex(INTERNAL_PATH_REGEX).optional(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cas duplication contrôlée (SW mode `injectManifest`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/frontend/src/sw-helpers.ts
|
||||||
|
/**
|
||||||
|
* ⚠️ DOIT correspondre à INTERNAL_PATH_REGEX de packages/shared/src/dto/push.ts.
|
||||||
|
* Le SW (mode injectManifest) ne peut pas importer le package partagé directement.
|
||||||
|
* Test croisé : apps/frontend/src/__tests__/regex-alignment.test.ts
|
||||||
|
*/
|
||||||
|
const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Une seule source de vérité, idéalement dans `packages/shared`
|
||||||
|
- [ ] Si duplication forcée : commentaire `⚠️ DOIT correspondre à <chemin>` des deux côtés
|
||||||
|
- [ ] Test croisé qui assert l'alignement string-wise des deux regex
|
||||||
|
- [ ] JSDoc qui rappelle que c'est un contrat de cohérence (revue obligatoire si modif)
|
||||||
|
|||||||
@@ -59,3 +59,541 @@ Préférer étendre le service avec un nouveau type (enum/Set) et ajuster les re
|
|||||||
### Signal review
|
### Signal review
|
||||||
|
|
||||||
- Nouveau service qui réplique le CRUD d'un service existant avec un filtre additionnel → candidat à la fusion par type
|
- Nouveau service qui réplique le CRUD d'un service existant avec un filtre additionnel → candidat à la fusion par type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-build-packages-workspace-amont-tests-ci"></a>
|
||||||
|
## Pattern : Builder les packages workspace en amont des tests CI (monorepo pnpm)
|
||||||
|
|
||||||
|
- Objectif : garantir qu'un package workspace compilé (TypeScript → `dist/`) est construit avant que les apps consommatrices lancent leurs tests dans le CI.
|
||||||
|
- Contexte : monorepo pnpm où `apps/*` consomme `packages/<lib>` via `"workspace:*"`, et où le `package.json` du package pointe sur un build artifact (`"main": "dist/index.js"`, `"types": "dist/index.d.ts"`).
|
||||||
|
- Quand l'utiliser : dans tout pipeline CI/CD qui lance des tests sur les apps consommatrices d'un package compilé.
|
||||||
|
- Quand l'éviter : si tous les packages workspace sont consommés en source directement (TS sans build, ex. via `tsx`, `vite-node`, `@swc-node`).
|
||||||
|
- Validé le : 30-04-2026
|
||||||
|
- Contexte technique : pnpm monorepo / GitHub Actions / Node 22 / TypeScript — RL799_V2
|
||||||
|
|
||||||
|
### Règle
|
||||||
|
|
||||||
|
Ajouter une étape `Build workspace packages` entre `pnpm install` et le premier `pnpm run test:*` du pipeline. Préférer un build complet du workspace plutôt qu'un build ciblé : `pnpm -r --filter '<scope>/*' build` (ou `pnpm run build` si un script racine équivalent existe). `pnpm -r` respecte le graphe de dépendances et bâtit les libs avant les apps.
|
||||||
|
|
||||||
|
### Anti-pattern à éviter
|
||||||
|
|
||||||
|
Ne pas placer le `build` du package partagé **dans** la commande de tests de ce package (`"test:shared": "pnpm -C packages/shared build && pnpm -C packages/shared test"`). Si le script `test:shared` tourne après `test:api` dans la séquence CI, le build arrive trop tard pour les tests qui consomment `dist/`. Garder le build comme étape CI explicite et amont, et garder `test:<package>` minimaliste.
|
||||||
|
|
||||||
|
### Signal review
|
||||||
|
|
||||||
|
- Pipeline CI qui enchaîne `pnpm install` puis directement `pnpm run test:api` (ou équivalent) sans étape de build intermédiaire, alors que le package workspace consommé pointe sur un `dist/` compilé.
|
||||||
|
- Bug type : `Cannot find module '<scope>/<package>'` ou `Cannot find module '<scope>/<package>/dist/...'` au démarrage des tests CI, alors que les tests passent en local.
|
||||||
|
|
||||||
|
### Pourquoi ce bug est silencieux en local
|
||||||
|
|
||||||
|
En local, `dist/` existe presque toujours (build précédent) — donc les tests passent. Le pipeline CI part d'un environnement vierge et casse. Test de fumée local avant un push CI sensible : `rm -rf packages/<lib>/dist && pnpm run build && pnpm run test:<app>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-ordre-canonique-gates-http"></a>
|
||||||
|
## Pattern : Ordre canonique des gates dans un handler HTTP
|
||||||
|
|
||||||
|
- Objectif : éviter les fuites d'information via les codes HTTP — un user non authentifié ne doit jamais distinguer "ressource n'existe pas" de "ressource existe mais non autorisée".
|
||||||
|
- Contexte : tout handler HTTP qui combine validation de payload, authentification, autorisation, vérification d'existence de ressource et gates métier.
|
||||||
|
- Quand l'utiliser : systématique sur tout handler HTTP exposé.
|
||||||
|
- Quand l'éviter : jamais.
|
||||||
|
- Avantage :
|
||||||
|
- la sémantique HTTP reste cohérente entre endpoints
|
||||||
|
- aucune énumération possible via les codes 4xx
|
||||||
|
- Limites / vigilance :
|
||||||
|
- test "non auth + payload problématique → 401, pas 400" obligatoire pour verrouiller l'ordre
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : Next.js / NestJS / API HTTP — RL799_V2
|
||||||
|
|
||||||
|
### Ordre canonique (du plus permissif au plus restrictif)
|
||||||
|
|
||||||
|
1. **Parsing du body** (400 VALIDATION_ERROR si malformé)
|
||||||
|
2. **Validation du schéma** (400 VALIDATION_ERROR si payload invalide)
|
||||||
|
3. **Auth** (401 si non authentifié)
|
||||||
|
4. **Autz** (403 si rôle insuffisant)
|
||||||
|
5. **Existence ressource** (404 si l'id n'existe pas)
|
||||||
|
6. **Gates métier** (400/403 si règle business violée)
|
||||||
|
7. **Mutation**
|
||||||
|
|
||||||
|
### Anti-pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Gate métier AVANT auth — leak l'existence de la route + de la règle
|
||||||
|
if (payload.contains_locked_field) return 400 LOCKED;
|
||||||
|
const auth = requireAuth(...); // jamais atteint si payload contient le champ
|
||||||
|
|
||||||
|
// ✅ Auth AVANT gate métier
|
||||||
|
const auth = requireAuth(...);
|
||||||
|
if (auth instanceof Response) return auth;
|
||||||
|
if (payload.contains_locked_field) return 400 LOCKED;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test associé
|
||||||
|
|
||||||
|
Ajouter systématiquement un test `non auth + payload problématique → 401, pas 400`. Sans ce test, la régression passe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-delegation-endpoint-agrege"></a>
|
||||||
|
## Pattern : Délégation au niveau d'un agrégat → endpoint agrégé serveur
|
||||||
|
|
||||||
|
- Objectif : éviter les cascades de 403 silencieux côté client quand un user "délégué" doit accéder à des entités liées hors de l'agrégat parent.
|
||||||
|
- Contexte : user avec un rôle "délégué" rattaché à un agrégat parent (`Soiree.secretaireDeSeanceId`), qui doit pouvoir lire/écrire sur des entités liées au-delà de l'agrégat (tenues précédentes du même grade).
|
||||||
|
- Quand l'utiliser : la délégation ouvre l'accès à plusieurs entités liées qui sont rendues ensemble dans une vue unique.
|
||||||
|
- Quand l'éviter : si le frontend a vraiment besoin des entités séparément (rare).
|
||||||
|
- Avantage :
|
||||||
|
- une seule réponse hydrate toute la vue → pas de cascade de 403
|
||||||
|
- guard centrale au niveau du parent réutilisée en lecture ET en écriture
|
||||||
|
- source de vérité unique de la "fenêtre légitime" (un repo helper)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- les codes d'erreur restent standards (`403 FORBIDDEN`), pas de codes ad hoc `FORBIDDEN_OUT_OF_DELEGATION_SCOPE`
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : Next.js / API HTTP — RL799_V2
|
||||||
|
|
||||||
|
### Le pattern
|
||||||
|
|
||||||
|
1. **Endpoint agrégé côté serveur** : `GET /api/<parent>/[id]/<vue-agrégée>` qui hydrate en une seule réponse toutes les entités liées dont la vue a besoin.
|
||||||
|
2. **Guard centrale au niveau du parent** : `requireAccessForParent(request, parentId, { roleSet })` retourne `{ userId, role, viaDelegation, delegatedParentId? }` ou `Response 403/404`.
|
||||||
|
3. **Si la guard sur l'entité enfant doit aussi reconnaître la délégation** (cas d'écriture) : étendre avec un slow-path qui appelle la résolution serveur — *« cet enfant fait-il partie de la fenêtre légitime ouverte par la délégation ? »*. Pas de re-vérification dispersée dans chaque service.
|
||||||
|
4. **Single source of truth de la "fenêtre légitime"** : la même fonction de résolution est utilisée par l'endpoint agrégé (lecture) ET par la guard d'écriture.
|
||||||
|
|
||||||
|
### Ce qu'on évite
|
||||||
|
|
||||||
|
- Cascade de N requêtes côté client → autant de chances de 403/erreurs silencieuses
|
||||||
|
- Logique métier dupliquée dans la guard et dans le service de lecture (dérive garantie)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-anti-enumeration-delete-204"></a>
|
||||||
|
## Pattern : Anti-énumération sur DELETE — 204 systématique
|
||||||
|
|
||||||
|
- Objectif : empêcher un user authentifié d'énumérer les ressources d'autres users via les codes HTTP du DELETE (204 vs 404 fuite l'existence).
|
||||||
|
- Contexte : endpoint DELETE qui révoque/supprime une ressource identifiée par un identifiant connu uniquement de son propriétaire (push endpoint, refresh token, magic link, OAuth state).
|
||||||
|
- Quand l'utiliser : la ressource est identifiée par un secret/identifiant non énumérable.
|
||||||
|
- Quand l'éviter : ressource identifiée par un ID séquentiel public — fixer d'abord l'autorisation.
|
||||||
|
- Avantage :
|
||||||
|
- aucune information ne fuit (inconnue / à un autre / déjà révoquée → indistinguables)
|
||||||
|
- simplifie le code : pas de branchement 404 vs 204
|
||||||
|
- idempotence naturelle (rejouer un DELETE n'a aucun effet observable)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- garder une validation de format en amont (400 si body malformé) — c'est le format qui ne fuit rien, pas l'existence
|
||||||
|
- le filtre SQL DOIT inclure `userId = currentUser` — sinon on révoque la ressource d'un autre user
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : API HTTP / Prisma — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const handleDelete = async (request: Request): Promise<Response> => {
|
||||||
|
const auth = requireAuthenticatedUser(request, ROLES);
|
||||||
|
if ('status' in auth) return auth;
|
||||||
|
|
||||||
|
const parsed = bodySchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return errorResponse(400, 'VALIDATION_ERROR', 'Body invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await revokeByOwner({ userId: auth.userId, ...parsed.data });
|
||||||
|
} catch {
|
||||||
|
/* logger côté serveur, retourner 204 quand même */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 systématique : inconnu / autre user / déjà révoqué → indistinguables
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Repository
|
||||||
|
export const revokeByOwner = async (input: {
|
||||||
|
userId: string;
|
||||||
|
endpoint: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
await prisma.subscription.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: input.userId, // filtre propriétaire OBLIGATOIRE
|
||||||
|
endpoint: input.endpoint,
|
||||||
|
revokedAt: null, // idempotent
|
||||||
|
},
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
|
||||||
|
- `findUnique` + `if (!sub) return 404` : fuit l'existence
|
||||||
|
- `findUnique` + check `userId === auth.userId` + 403 : fuit l'existence (la 403 vs 404 vs 204 distingue)
|
||||||
|
- `prisma.delete({ where: { endpoint } })` sans filtre `userId` : un user peut révoquer la sub d'un autre
|
||||||
|
|
||||||
|
### Tests minimaux
|
||||||
|
|
||||||
|
DELETE inconnu / DELETE autre user / DELETE déjà révoqué → tous 204, état inchangé pour les autres users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-lazy-init-memoize-libs-config-globale"></a>
|
||||||
|
## Pattern : Lazy init memoizé pour libs avec config globale
|
||||||
|
|
||||||
|
- Objectif : initialiser une lib qui exige une config globale (clés API, credentials) seulement au premier appel utile pour permettre feature flag, tests isolés et démarrage gracieux sans env complet.
|
||||||
|
- Contexte : libs externes type `web-push.setVapidDetails`, `Stripe(secret)`, `S3Client({ region })`, `Resend(apiKey)`.
|
||||||
|
- Quand l'utiliser : lib avec init globale + feature flag possible + tests qui doivent reset l'état.
|
||||||
|
- Quand l'éviter : lib qui réclame son init au boot (ex : connexion persistante TCP), libs trivialement instanciables par appel.
|
||||||
|
- Avantage :
|
||||||
|
- le service ne crash pas au boot si l'env n'est pas encore configuré
|
||||||
|
- feature flag respecté de bout en bout (`isEnabled()` court-circuite avant init)
|
||||||
|
- tests parallèles peuvent reset l'état via `__resetForTests()`
|
||||||
|
- `setX` n'est appelé qu'une fois par process (memoization)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- state module-level partagé entre tous les imports — bien isoler la lib derrière un service
|
||||||
|
- `__resetForTests` doit être gardé par `NODE_ENV === 'test'` pour éviter l'usage en prod
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : Node.js / SDK avec config globale — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
const isEnabled = (): boolean =>
|
||||||
|
process.env.FEATURE_FLAG === 'true' &&
|
||||||
|
Boolean(process.env.API_KEY && process.env.SECRET);
|
||||||
|
|
||||||
|
const ensureInit = (): boolean => {
|
||||||
|
if (initialized) return true;
|
||||||
|
if (!isEnabled()) return false;
|
||||||
|
externalLib.configure({
|
||||||
|
apiKey: process.env.API_KEY!,
|
||||||
|
secret: process.env.SECRET!,
|
||||||
|
});
|
||||||
|
initialized = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const callExternal = async (input: Input): Promise<void> => {
|
||||||
|
if (!ensureInit()) return; // no-op silencieux si flag off ou env manquant
|
||||||
|
await externalLib.send(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reset interne — usage tests uniquement. */
|
||||||
|
export const __resetInitForTests = (): void => {
|
||||||
|
if (process.env.NODE_ENV !== 'test') return;
|
||||||
|
initialized = false;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- `isEnabled()` vérifie flag ET présence env complète
|
||||||
|
- `ensureInit()` retourne booléen, no-op silencieux si non activé
|
||||||
|
- `__resetForTests` gardé par `NODE_ENV === 'test'`
|
||||||
|
- Pas de log "skipped" en cas de flag off (silencieux par design — c'est le contrat du flag)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-cap-lru-ressources-par-user"></a>
|
||||||
|
## Pattern : Cap LRU sur ressources par-user avec contrainte d'unicité externe
|
||||||
|
|
||||||
|
- Objectif : empêcher un user (malveillant ou bugué) de générer un nombre illimité de ressources par-user en exploitant l'unicité d'un identifiant externe.
|
||||||
|
- Contexte : ressources où chaque insert produit une row unique côté DB (push subscription endpoint, refresh token, device fingerprint, OAuth state).
|
||||||
|
- Quand l'utiliser : ressource sans limite naturelle côté usage, où chaque action utilisateur peut créer une nouvelle row.
|
||||||
|
- Quand l'éviter : ressource intrinsèquement bornée (1 par user — utiliser une PK composite), ou ressource où l'historique compte (audit, logs).
|
||||||
|
- Avantage :
|
||||||
|
- cap dur prévisible (10 actives max, p.ex.) — borne supérieure de coût stockage connue
|
||||||
|
- LRU eviction naturelle : les anciennes subs (devices oubliés, browsers réinstallés) sont nettoyées automatiquement
|
||||||
|
- pas besoin de TTL global, le user peut garder ses N appareils légitimes
|
||||||
|
- Limites / vigilance :
|
||||||
|
- faire le check + révocation **avant** l'insert, pas après (sinon `unique constraint` violation possible si race)
|
||||||
|
- choisir entre `revoked` (soft delete) et `delete` selon les besoins audit
|
||||||
|
- le cap doit être au-dessus de l'usage légitime max (10 pour push = laptop + perso + pro + mobile + tablette + marges)
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : Prisma — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const MAX_ACTIVE_PER_USER = 10;
|
||||||
|
|
||||||
|
export const handleCreate = async (userId: string, input: CreateInput) => {
|
||||||
|
const active = await prisma.resource.count({
|
||||||
|
where: { userId, revokedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active >= MAX_ACTIVE_PER_USER) {
|
||||||
|
const oldest = await prisma.resource.findFirst({
|
||||||
|
where: { userId, revokedAt: null },
|
||||||
|
orderBy: { lastSeenAt: 'asc' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (oldest) {
|
||||||
|
await prisma.resource.update({
|
||||||
|
where: { id: oldest.id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.resource.upsert({
|
||||||
|
where: { externalKey: input.externalKey },
|
||||||
|
create: { userId, ...input },
|
||||||
|
update: { userId, revokedAt: null, lastSeenAt: new Date() },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Cap (constante) défini + commenté avec justification du chiffre
|
||||||
|
- [ ] Eviction LRU faite **avant** l'insert
|
||||||
|
- [ ] `lastSeenAt` bumpé à chaque usage légitime, pas juste à la création
|
||||||
|
- [ ] Test : seed cap-1 actives + 1 nouvel insert → cap respecté, plus ancienne révoquée
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-convention-dot-notation-audit"></a>
|
||||||
|
## Pattern : Convention dot-notation pour audit events
|
||||||
|
|
||||||
|
- Objectif : aligner le nommage des audit events avec la convention des outils observables (segment.io, datadog, posthog) qui utilisent tous la dot notation.
|
||||||
|
- Contexte : projet avec un `AUDIT_ACTION_CATALOG` ou équivalent listant les actions auditées.
|
||||||
|
- Quand l'utiliser : tout nouvel audit event, et migration progressive des events legacy en colon (`document:delete`).
|
||||||
|
- Quand l'éviter : projets dont l'outil d'observabilité impose un autre séparateur.
|
||||||
|
- Avantage :
|
||||||
|
- cohérence inter-outils (segment.io, datadog, posthog)
|
||||||
|
- lecture humaine plus fluide (`document.soft_delete` > `document:soft-delete`)
|
||||||
|
- multi-niveaux possible (`planche.tronc.admin_override`) sans confusion avec un séparateur de namespace JS
|
||||||
|
- Limites / vigilance :
|
||||||
|
- migration en PR atomique (call sites + catalog ensemble) pour éviter les events orphelins en filtrage UI
|
||||||
|
- Validé le : 20-04-2026
|
||||||
|
- Contexte technique : audit / observabilité — RL799_V2
|
||||||
|
|
||||||
|
### Convention
|
||||||
|
|
||||||
|
- **Préférer la dot notation** : `<entity>.<action_detail>` (ex : `document.update_metadata`, `document.soft_delete`, `cotisation_payment.created`)
|
||||||
|
- **Legacy colon** (`document:delete`) toléré pour rétrocompatibilité — migration encouragée lors du prochain touchement du module concerné
|
||||||
|
|
||||||
|
### Comment migrer
|
||||||
|
|
||||||
|
1. Vérifier qu'il n'y a plus de call site (`grep -rn 'document:delete'`)
|
||||||
|
2. Retirer l'entrée du `AUDIT_ACTION_CATALOG`
|
||||||
|
3. Si des call sites existent, les migrer en même temps que le retrait (PR atomique)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-whitelist-explicite-audit-fields"></a>
|
||||||
|
## Pattern : Whitelist explicite pour audit metadata fields
|
||||||
|
|
||||||
|
- Objectif : empêcher qu'un futur dev ajoutant un champ secret au schema (`backupPassword`, `apiToken`) ne le voie automatiquement loggé dans le journal d'audit.
|
||||||
|
- Contexte : PATCH d'admin qui logge `metadata: { fields: Object.keys(payload) }` pour tracer ce qui a changé.
|
||||||
|
- Quand l'utiliser : tout audit qui veut tracer "quels champs ont été modifiés".
|
||||||
|
- Quand l'éviter : audit qui ne logge que l'action et l'id cible (pas les champs).
|
||||||
|
- Avantage :
|
||||||
|
- pas de fuite silencieuse dans le journal d'audit
|
||||||
|
- `satisfies readonly (keyof typeof baseShape)[]` garantit que la whitelist ne peut pas contenir de champ inexistant (typo-safe)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- test "PATCH multi-champs valides → tous présents dans `metadata.fields`" pour vérifier que la whitelist couvre 100 % des champs PATCH-ables légitimes (silence par omission, pas de fuite)
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : audit / observabilité — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/shared/src/validation/<entity>Schemas.ts
|
||||||
|
export const AUDITABLE_LODGE_SETTINGS_FIELDS = [
|
||||||
|
'nameLong',
|
||||||
|
'nameShort',
|
||||||
|
// … uniquement les champs SAFE à logger
|
||||||
|
] as const satisfies readonly (keyof typeof baseShape)[];
|
||||||
|
|
||||||
|
// Côté service
|
||||||
|
const fields = AUDITABLE_LODGE_SETTINGS_FIELDS.filter((k) => k in payload);
|
||||||
|
await logActionSync(tx, userId, '<entity>.update', '<entity>', id, { fields });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test obligatoire
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('AUDITABLE_<X>_FIELDS couvre tous les champs PATCH-ables légitimes', () => {
|
||||||
|
const allPatchableFields = Object.keys(updateXxxSchema.shape);
|
||||||
|
const sensitiveFields = ['secretToken', 'backupPassword'];
|
||||||
|
const expected = allPatchableFields.filter((k) => !sensitiveFields.includes(k));
|
||||||
|
expect([...AUDITABLE_X_FIELDS]).toEqual(expected);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-singleton-db-config-globale"></a>
|
||||||
|
## Pattern : Singleton DB pour config globale d'instance
|
||||||
|
|
||||||
|
- Objectif : stocker une configuration applicative qui doit être éditable runtime, unique pour l'instance, et protégée contre toute création accidentelle de doublon.
|
||||||
|
- Contexte : application mono-tenant déployable où chaque instance a sa propre DB et expose une config globale (paramètres de l'instance, branding, identité).
|
||||||
|
- Quand l'utiliser : config (a) en DB pour être éditable runtime sans rebuild, (b) unique pour l'instance, (c) protégée par contrainte DB.
|
||||||
|
- Quand l'éviter : SaaS multi-tenant — chaque tenant a sa propre row.
|
||||||
|
- Avantage :
|
||||||
|
- le `CHECK` SQL est la dernière ligne de défense même si un dev contourne le repo
|
||||||
|
- le repo centralise le `where: { id: 'singleton' }` — première ligne d'abstraction
|
||||||
|
- Limites / vigilance :
|
||||||
|
- `@default("singleton")` seul ne suffit pas — un dev peut créer une row avec `id: 'other'`
|
||||||
|
- cache mémoire à invalider AVANT mutation (cf. pattern `pattern-invalidation-cache-avant-mutation`)
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model LodgeSettings {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
// …champs
|
||||||
|
@@map("lodge_settings")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Garde-fou SQL au niveau migration (édition manuelle du `.sql` après `prisma migrate dev --create-only`) :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE "lodge_settings"
|
||||||
|
ADD CONSTRAINT "lodge_settings_singleton_check"
|
||||||
|
CHECK (id = 'singleton');
|
||||||
|
```
|
||||||
|
|
||||||
|
Repository centralisé : tout accès passe par `getXxx()` / `updateXxx()` qui prennent un client transactionnel optionnel pour permettre l'atomicité mutation + audit. Jamais de `prisma.xxx.create()` direct depuis ailleurs.
|
||||||
|
|
||||||
|
### Tests d'intégration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('rejette la création d\'une 2e row', async () => {
|
||||||
|
await expect(
|
||||||
|
prisma.lodgeSettings.create({ data: { id: 'other', ... } }),
|
||||||
|
).rejects.toThrow('lodge_settings_singleton_check');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-invalidation-cache-avant-mutation"></a>
|
||||||
|
## Pattern : Invalidation cache mémoire AVANT mutation atomique
|
||||||
|
|
||||||
|
- Objectif : éviter qu'un GET parallèle pendant la transaction re-cache l'ancienne valeur jusqu'à expiration TTL.
|
||||||
|
- Contexte : config en cache mémoire (settings, feature flags, catalog) modifiée par une mutation atomique.
|
||||||
|
- Quand l'utiliser : tout flow `cache + mutation + audit` où le cache vit dans le même process que la mutation.
|
||||||
|
- Quand l'éviter : cache distribué Redis avec `SETEX` — la TTL gère naturellement.
|
||||||
|
- Avantage :
|
||||||
|
- cohérence forte (au pire, GET parallèle bloque sur fetch DB → trade-off latence acceptable)
|
||||||
|
- pas de fenêtre où le client a vu une valeur déjà obsolète après ACK serveur
|
||||||
|
- Limites / vigilance :
|
||||||
|
- garde-fou `cacheVersion` (compteur incrémenté à chaque `invalidate()`) recommandé : tout fetch en vol capture la version au démarrage et n'écrit le cache que si la version est inchangée au retour
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : Node.js / cache mémoire — RL799_V2
|
||||||
|
|
||||||
|
### Séquence sûre
|
||||||
|
|
||||||
|
```
|
||||||
|
1. invalidateCache() // AVANT toute mutation
|
||||||
|
2. transaction { // Prisma $transaction
|
||||||
|
update(...)
|
||||||
|
logActionSync(tx, ...)
|
||||||
|
}
|
||||||
|
3. return // Le prochain GET re-fetch fresh DB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pourquoi avant et pas après
|
||||||
|
|
||||||
|
Si on invalide après mutation :
|
||||||
|
1. GET parallèle pendant la transaction hit le cache (ancienne valeur) → return cachedValue
|
||||||
|
2. Si la transaction commit, le cache contient l'ancienne valeur jusqu'à TTL
|
||||||
|
3. Le client a vu une valeur déjà obsolète après ACK serveur → race condition
|
||||||
|
|
||||||
|
Si on invalide avant mutation :
|
||||||
|
1. Cache vide pendant la transaction
|
||||||
|
2. GET parallèle fait un fetch DB (peut renvoyer l'ancienne ou la nouvelle selon timing)
|
||||||
|
3. Au pire, GET parallèle bloque sur fetch DB → cohérence forte
|
||||||
|
|
||||||
|
### Anti-pattern à éviter
|
||||||
|
|
||||||
|
- Auto-chaînage entre invalidations couplées de caches dont les timings doivent diverger (ex : cache settings DTO à invalider AVANT la mutation, cache logo filesystem à invalider APRÈS pour éviter un re-cache d'un fichier supprimé). Chaque cache expose son `invalidate()` propre, le caller décide explicitement du moment.
|
||||||
|
|
||||||
|
### TTL recommandé
|
||||||
|
|
||||||
|
TTL court (60 s) plutôt que long (5 min) : fenêtre de stale plus courte si plusieurs admins éditent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-pipeline-cicd-github-actions-vps"></a>
|
||||||
|
## Pattern : Pipeline CI/CD GitHub Actions → VPS (compose externe + GHCR + SSH)
|
||||||
|
|
||||||
|
- Objectif : déployer automatiquement un monorepo Node + Postgres + Docker à chaque merge sur main vers un VPS hébergeant déjà des stacks globales (Traefik + Postgres).
|
||||||
|
- Contexte : VPS multi-apps avec Traefik global + Postgres global sur réseaux Docker externes (`traefik`, `stack`). Repo GitHub privé, image GHCR privée, user SSH dédié `deploy` (pas superuser).
|
||||||
|
- Quand l'utiliser : projet ≥ 1 app + ≥ 1 service partagé sur un VPS, sans Kubernetes ni service externe (pas de Vercel/Render/Railway).
|
||||||
|
- Quand l'éviter : déploiement sur un seul app/VPS dédié (compose simple suffit), ou besoin de blue/green strict (k8s ou Nomad plus adaptés).
|
||||||
|
- Avantage :
|
||||||
|
- réutilisation des stacks Traefik + Postgres existantes via réseaux externes
|
||||||
|
- pas de superuser, juste membre du groupe `docker`
|
||||||
|
- migrations exécutées AVANT le redémarrage applicatif (séquence `pull → migrate → up -d`)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- plan GitHub gratuit pour repos privés : pas de gating manuel possible — compenser par `workflow_dispatch` only sur le job deploy
|
||||||
|
- `pnpm run <script>` dans le service `migrate` peut casser si le script dépend de `scripts/` non embarqué dans l'image — appeler les binaires directement
|
||||||
|
- Validé le : 02-05-2026
|
||||||
|
- Contexte technique : pnpm monorepo / Next.js / Vue / Prisma / Postgres / Docker — RL799_V2
|
||||||
|
|
||||||
|
### `compose.vps.yml` — compose dédié CI/CD
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: ${API_IMAGE:?API_IMAGE is required}
|
||||||
|
env_file: [.env]
|
||||||
|
networks: [app_net, stack_net, traefik_net]
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.api.rule=Host(`${APP_DOMAIN}`) && PathPrefix(`/api`)
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: ${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}
|
||||||
|
networks: [app_net, traefik_net]
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
image: ${API_IMAGE:?API_IMAGE is required}
|
||||||
|
profiles: [ops]
|
||||||
|
# Appel DIRECT du binaire Prisma — éviter `pnpm run prisma:migrate` qui
|
||||||
|
# exécute scripts/check-node-version.mjs absent de l'image API
|
||||||
|
command: ['pnpm', '-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy']
|
||||||
|
networks: [stack_net]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik_net:
|
||||||
|
external: true
|
||||||
|
name: traefik
|
||||||
|
stack_net:
|
||||||
|
external: true
|
||||||
|
name: stack
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow CI/CD en 2 jobs
|
||||||
|
|
||||||
|
- `build-and-push` : `docker buildx` → push image sur GHCR (tag = SHA court)
|
||||||
|
- `deploy` : SSH au VPS, `scp compose.vps.yml`, `pull` + `migrate` + `up -d` + healthcheck retries
|
||||||
|
|
||||||
|
Trigger : `workflow_run` du workflow `Tests` quand vert sur main, OU `workflow_dispatch` manuel.
|
||||||
|
|
||||||
|
### Secrets GitHub à configurer
|
||||||
|
|
||||||
|
| Secret | Rôle | Exemple |
|
||||||
|
|---|---|---|
|
||||||
|
| `VPS_HOST` | hostname réel (pas alias `~/.ssh/config`) | `82.x.x.x` |
|
||||||
|
| `VPS_USER` | `deploy` |
|
||||||
|
| `VPS_SSH_PORT` | port custom éventuel | `2287` |
|
||||||
|
| `VPS_SSH_PRIVATE_KEY` | clé privée multi-lignes | `-----BEGIN OPENSSH...` |
|
||||||
|
| `VPS_SSH_KNOWN_HOSTS` | `ssh-keyscan -p <port> <host>` | 3 lignes |
|
||||||
|
| `VPS_APP_DIR` | chemin app sur VPS | `/srv/sites/<app>` |
|
||||||
|
|
||||||
|
### Pièges anticipés
|
||||||
|
|
||||||
|
1. **`pnpm run <script>` dans le service `migrate`** : si le script appelle un wrapper (`pnpm run env:node`) qui exécute `scripts/check-node-version.mjs`, et que `scripts/` n'est pas embarqué dans l'image API → 500 au boot du job migrate. **Toujours appeler les binaires directement** dans les services Docker.
|
||||||
|
|
||||||
|
2. **Image GHCR privée + `docker login` côté VPS** : credentials par-user (`~/.docker/config.json`). Faire `docker login` **en tant que `deploy`** via SSH, pas via `sudo -u deploy`.
|
||||||
|
|
||||||
|
3. **Hostname réel vs alias `~/.ssh/config`** : `VPS_HOST` doit être l'hostname résolu (`ssh -G <alias> | grep ^hostname`), pas l'alias.
|
||||||
|
|
||||||
|
4. **Permissions dossier `/srv/sites/<app>/`** : si owner historique = autre user, `deploy` ne peut pas `scp`. Solution propre = groupe partagé avec `setgid`.
|
||||||
|
|
||||||
|
5. **Healthcheck timeout** : si l'API met longtemps à boot (Chromium/Puppeteer lazy load, migrations longues), augmenter au-delà du défaut 60 s.
|
||||||
|
|||||||
@@ -164,3 +164,124 @@ if (mediaUrl) {
|
|||||||
- [ ] Validation format (`new URL()`) + protocole + longueur max
|
- [ ] Validation format (`new URL()`) + protocole + longueur max
|
||||||
- [ ] Retourner `null` si invalide, jamais passer la string brute
|
- [ ] Retourner `null` si invalide, jamais passer la string brute
|
||||||
- [ ] Composant UI reçoit `string | null`, jamais une string non vérifiée
|
- [ ] Composant UI reçoit `string | null`, jamais une string non vérifiée
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-nextjs-after-fanout-post-reponse"></a>
|
||||||
|
## Pattern : Next.js `after()` pour fanout post-réponse
|
||||||
|
|
||||||
|
- Objectif : exécuter du travail accessoire (notif push, audit, webhook) APRÈS avoir renvoyé la réponse HTTP, sans bloquer la latence métier ni risquer la fermeture lambda du fire-and-forget naïf.
|
||||||
|
- Contexte : route Next.js 15+ (App Router) qui termine une mutation métier (ex : `prisma.$transaction`) et veut déclencher un effet de bord non critique.
|
||||||
|
- Quand l'utiliser : effet de bord best-effort qui ne doit JAMAIS bloquer la réponse principale (push, log audit asynchrone, webhook sortant).
|
||||||
|
- Quand l'éviter : effet de bord qui DOIT réussir avant de répondre 200 (validation transactionnelle, écriture critique).
|
||||||
|
- Avantage :
|
||||||
|
- réponse HTTP immédiate (latence métier préservée)
|
||||||
|
- le runtime Node attend la fin du callback avant de fermer le process — pas de fire-and-forget orphelin
|
||||||
|
- les exceptions du callback sont attrapables localement, ne propagent pas au caller
|
||||||
|
- Limites / vigilance :
|
||||||
|
- **JAMAIS placer `after()` à l'intérieur d'une `prisma.$transaction(async tx => { ... after(...) })`** : si la transaction rollback, le callback `after()` reste planifié et s'exécute sur des données qui n'existent pas en DB
|
||||||
|
- construire le payload SYNCHRONE avant `after()` et attraper les erreurs là, sinon une exception dans le callback async devient silencieuse
|
||||||
|
- comportement en serverless (Vercel, Lambda) vs Node standalone peut différer — tester en cible
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : Next.js 15+ App Router — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { after } from 'next/server';
|
||||||
|
|
||||||
|
export const notifyConvocationPublished = async (
|
||||||
|
tenueId: string,
|
||||||
|
recipientIds: string[],
|
||||||
|
): Promise<void> => {
|
||||||
|
// 1. Mutation métier (transactionnelle)
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.notification.createMany({ data: ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Construction payload SYNCHRONE — toute exception attrapée ici
|
||||||
|
let payload: PushPayload;
|
||||||
|
try {
|
||||||
|
payload = buildPushPayload(tenueId);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('payload build failed', { err });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Hook après la transaction — JAMAIS dedans
|
||||||
|
after(async () => {
|
||||||
|
try {
|
||||||
|
await pushService.sendPushToUsers(recipientIds, payload);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('fanout failed', { err }); // jamais throw vers le caller
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
|
||||||
|
- ❌ `await pushService.sendPushToUsers(...)` dans le service métier (bloque la latence + propage les erreurs)
|
||||||
|
- ❌ `after(() => { ... })` à l'intérieur d'une `prisma.$transaction(async tx => { ... after(...); })`
|
||||||
|
- ❌ Construire le payload DANS le callback `after()` async — une exception y devient silencieuse
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] `after()` appelé APRÈS le `await prisma.$transaction(...)`, jamais à l'intérieur
|
||||||
|
- [ ] Payload construit synchrone avant `after()`, exceptions attrapées localement
|
||||||
|
- [ ] Le callback `after()` n'a pas le droit de throw (best-effort wrapping)
|
||||||
|
- [ ] La route métier renvoie 2xx même si le fanout échoue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-gate-agir-au-nom-de"></a>
|
||||||
|
## Pattern : Gate "agir au nom de X" (3 étages : rôle → type/scope → cible actif)
|
||||||
|
|
||||||
|
- Objectif : valider correctement une autorisation d'override d'attribution (`uploadedByOverride`, `createdByOverride`) pour qu'un rôle élevé puisse agir "au nom de" un autre user, sans laisser de faille.
|
||||||
|
- Contexte : endpoint API qui accepte un override d'attribution (archiviste upload pour un Frère, admin crée une entité pour X).
|
||||||
|
- Quand l'utiliser : tout endpoint avec un override d'attribution sensible.
|
||||||
|
- Quand l'éviter : si la délégation est implicite et déjà couverte par un guard centralisé.
|
||||||
|
- Avantage :
|
||||||
|
- validation strictement séquentielle et défensive — chaque étage a son code HTTP propre
|
||||||
|
- le check "actif" combine toutes les dimensions disponibles (pas un seul flag)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- ordre impératif : rôle EN PREMIER (plus sensible). Un membre lambda envoyant un payload avec override doit recevoir 403 même si la cible est valide
|
||||||
|
- Validé le : 20-04-2026
|
||||||
|
- Contexte technique : Next.js / API HTTP — RL799_V2
|
||||||
|
|
||||||
|
### Les 3 étages
|
||||||
|
|
||||||
|
1. **Rôle** : le user courant a-t-il la capacité ? Sinon **403 FORBIDDEN**.
|
||||||
|
2. **Type/scope** : l'override est-il pertinent pour ce type d'entité ? Sinon **400 VALIDATION_ERROR**.
|
||||||
|
3. **Cible** : la cible existe-t-elle ET est-elle active ? Sinon **400 VALIDATION_ERROR** si introuvable OU inactive OU démissionnée OU décédée.
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let effectiveUploadedBy = currentUser.id;
|
||||||
|
if (metadata.uploadedByOverride) {
|
||||||
|
// Étage 1 : rôle
|
||||||
|
if (userRole !== 'archiviste' && userRole !== 'admin') {
|
||||||
|
return errorResponse(403, 'FORBIDDEN', '...');
|
||||||
|
}
|
||||||
|
// Étage 2 : type/scope
|
||||||
|
if (metadata.type !== 'planche') {
|
||||||
|
return errorResponse(400, 'VALIDATION_ERROR', '...');
|
||||||
|
}
|
||||||
|
// Étage 3 : cible actif (toutes les dimensions disponibles)
|
||||||
|
const targetUser = await getUserById(metadata.uploadedByOverride);
|
||||||
|
const isInactive =
|
||||||
|
!targetUser
|
||||||
|
|| !targetUser.isActive
|
||||||
|
|| !!targetUser.profile?.resignedAt
|
||||||
|
|| !!targetUser.profile?.deceasedAt;
|
||||||
|
if (isInactive) {
|
||||||
|
return errorResponse(400, 'VALIDATION_ERROR', '...');
|
||||||
|
}
|
||||||
|
effectiveUploadedBy = metadata.uploadedByOverride;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pourquoi vérifier toutes les dimensions
|
||||||
|
|
||||||
|
Un user peut être `isActive: true` dans le système mais avoir une `resignedAt` antérieure (désactivation non-synchrone). Le check "actif" doit combiner **toutes** les dimensions disponibles du modèle, pas un seul flag.
|
||||||
|
|||||||
@@ -42,6 +42,29 @@ source_projects: [app-template-resto, app-alexandrie]
|
|||||||
- Index DB tenant compte du soft delete
|
- Index DB tenant compte du soft delete
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Piège — `include` ne filtre pas `deletedAt` automatiquement
|
||||||
|
|
||||||
|
`include: { related: true }` n'applique pas le filtre soft delete sur la relation. Si la relation pointe vers une entité elle-même soft-deletable, le doc caché reste exposé via la relation → fuite systématique.
|
||||||
|
|
||||||
|
Mitigations :
|
||||||
|
|
||||||
|
- relations to-many : `include: { related: { where: { deletedAt: null } } }`
|
||||||
|
- relations to-one (Prisma ne supporte pas `where` dans un `include` to-one) : `include: { related: { select: { deletedAt: true, ... } } }` puis filtrer post-query côté repo (`if (entity.related?.deletedAt) entity.related = null`)
|
||||||
|
|
||||||
|
Toujours `grep -rn "include.*<relationName>"` après l'ajout d'un soft delete pour identifier les sites à fixer.
|
||||||
|
|
||||||
|
### Pattern atomique anti-race delete/restore
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await prisma.<model>.updateMany({
|
||||||
|
where: { id, deletedAt: null }, // ou { not: null } pour restore
|
||||||
|
data: { deletedAt: new Date(), deletedById: actorId },
|
||||||
|
});
|
||||||
|
if (result.count === 0) return notFound(); // idempotent, pas de double-audit
|
||||||
|
```
|
||||||
|
|
||||||
|
`updateMany` + `where: { id, deletedAt: null }` permet de transformer un check-then-update non atomique en un update atomique conditionnel — le `count === 0` distingue "déjà supprimé" de "introuvable" sans risque de double effet de bord.
|
||||||
|
|
||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- Filtrage soft delete par défaut
|
- Filtrage soft delete par défaut
|
||||||
@@ -49,6 +72,7 @@ source_projects: [app-template-resto, app-alexandrie]
|
|||||||
- Purge maîtrisée (cron / job)
|
- Purge maîtrisée (cron / job)
|
||||||
- Index DB adaptés
|
- Index DB adaptés
|
||||||
- Tests sur cas supprimé / restauré
|
- Tests sur cas supprimé / restauré
|
||||||
|
- Audit des `include` sur les relations soft-deletables
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -256,3 +280,387 @@ return raw.filter(c => c.isVisible).map(toPublicDto);
|
|||||||
// Admin : même repo, filtre différent dans le service admin
|
// Admin : même repo, filtre différent dans le service admin
|
||||||
return raw.map(toAdminDto); // retourne tout, visible ou non
|
return raw.map(toAdminDto); // retourne tout, visible ou non
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-audit-transactionnel-atomique"></a>
|
||||||
|
## Pattern : Audit transactionnel — mutation et log dans la même `$transaction`
|
||||||
|
|
||||||
|
- Objectif : garantir l'invariant `mutation persistée ⇔ audit log existe` quand l'audit est un livrable métier (pas un simple effet de bord informatif).
|
||||||
|
- Contexte : opérations sensibles (correction par un délégué hors périmètre habituel, opérations admin, opérations soumises à conformité).
|
||||||
|
- Quand l'utiliser : tout flux où une mutation sans trace serait inacceptable.
|
||||||
|
- Quand l'éviter : audits purement informatifs (statistiques d'usage, debug) — fire-and-forget acceptable.
|
||||||
|
- Avantage :
|
||||||
|
- rollback automatique si l'audit échoue → pas de mutation orpheline
|
||||||
|
- aucune divergence possible entre l'état persisté et la trace
|
||||||
|
- Limites / vigilance :
|
||||||
|
- une mutation peut désormais échouer pour cause "audit indisponible" → 5xx renvoyé au client (cohérent : on préfère refuser la mutation que la passer sans trace)
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : Prisma / NestJS — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AuditClient = Prisma.TransactionClient | typeof prisma;
|
||||||
|
|
||||||
|
export const logActionSync = async (
|
||||||
|
client: AuditClient,
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
targetType?: string,
|
||||||
|
targetId?: string,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
await client.auditLog.create({ data: { userId, action, targetType, targetId, metadata } });
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.<entity>.update({ where: { id }, data: { ... } });
|
||||||
|
await logActionSync(tx, userId, '<entity>.<action>', '<entity>', id, { ... });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
|
||||||
|
- `logAction(...)` (fire-and-forget) après le persist quand l'audit est requis métier
|
||||||
|
- `logActionSync(prisma, ...)` (hors transaction) après le persist : synchrone mais pas atomique avec la mutation
|
||||||
|
- `.catch(() => {})` autour de l'audit "pour ne pas casser la mutation"
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Le helper d'audit accepte un `client: AuditClient` (transaction ou prisma)
|
||||||
|
- [ ] Mutation et audit dans la même `$transaction`
|
||||||
|
- [ ] Test d'atomicité : mock `createAuditLog` qui throw → assert rollback (cf. `knowledge/backend/patterns/tests.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-index-unique-partiel-actif"></a>
|
||||||
|
## Pattern : Index unique partiel Postgres pour invariant "≤ 1 active par X"
|
||||||
|
|
||||||
|
- Objectif : enforcer l'invariant "au plus une row active par scope" au niveau base de données plutôt que via un check applicatif vulnérable aux races.
|
||||||
|
- Contexte : ressources avec un cycle de vie `active → revoked/closed` où l'invariant métier impose une seule active par user/contexte (invitation, mandat d'officier, lock éditeur).
|
||||||
|
- Quand l'utiliser : dès qu'un check applicatif "≤ 1 active" est nécessaire et que la concurrence est possible.
|
||||||
|
- Quand l'éviter : si la table n'a pas de colonne `status` discriminante ou si plusieurs rows actives sont métier-acceptables.
|
||||||
|
- Avantage :
|
||||||
|
- 2e INSERT concurrente échoue avec contrainte unique violée (P2002) plutôt que de créer un doublon
|
||||||
|
- défense en profondeur : le check applicatif reste, mais la DB est la dernière ligne
|
||||||
|
- Limites / vigilance :
|
||||||
|
- Prisma ne supporte pas les unique partials en `schema.prisma` → ajouter dans la migration SQL brute
|
||||||
|
- documenter dans la migration : un `prisma format` accidentel pourrait droper l'index
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- prisma/migrations/<TS>_xxx/migration.sql
|
||||||
|
CREATE UNIQUE INDEX invitations_one_active_per_user
|
||||||
|
ON invitations(user_id) WHERE status = 'active';
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const revokeAndIssueInvitation = async (input) => {
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.invitation.updateMany({
|
||||||
|
where: { userId: input.userId, status: 'active' },
|
||||||
|
data: { status: 'revoked', revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
return tx.invitation.create({
|
||||||
|
data: { userId: input.userId, ..., status: 'active' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||||
|
return { ok: false, reason: 'RACE_CONFLICT' };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Index partiel ajouté dans le SQL brut de la migration
|
||||||
|
- [ ] Handler `P2002` traduit en code métier (`RACE_CONFLICT`, 409)
|
||||||
|
- [ ] Test de race : `Promise.all([resend(), resend()])` puis `count({ status: 'active' }) === 1`
|
||||||
|
- [ ] Commentaire dans la migration : "Prisma ne supporte pas les unique partials en schema, ne pas droper sur `prisma format`"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-uuid-v5-deterministe-seed"></a>
|
||||||
|
## Pattern : UUID v5 déterministe pour ids de seed
|
||||||
|
|
||||||
|
- Objectif : permettre d'écrire `seedUserId('venerable')` côté tests/code tout en garantissant que `User.id` reste un UUID RFC 4122 en base — débloque la rigidification Zod `.uuid()` en aval.
|
||||||
|
- Contexte : seed Prisma qui crée des entités référencées par slug lisible dans les tests, et qui doivent rester typées strictement côté API.
|
||||||
|
- Quand l'utiliser : nouveaux seeds OU migration d'un seed historique avec slugs littéraux comme PK.
|
||||||
|
- Quand l'éviter : si le seed est purement aléatoire (`@default(uuid())`) et qu'aucun test ne référence un user particulier par identifiant.
|
||||||
|
- Avantage :
|
||||||
|
- déterminisme : `seedUserId('venerable')` donne toujours le même UUID v5
|
||||||
|
- type uniforme : tous les `User.id` sont des UUID RFC 4122 → `.uuid()` activable
|
||||||
|
- lisibilité préservée : le code de tests reste sémantique
|
||||||
|
- Limites / vigilance :
|
||||||
|
- le slug ne doit JAMAIS être persisté en clair (mapping explicite `{ id: seedUserId(slug), ...rest }`)
|
||||||
|
- migration depuis un seed slug existant = chantier en 2 commits (cf. pattern rigidification Zod 2 phases dans `contracts.md`)
|
||||||
|
- Validé le : 24-04-2026
|
||||||
|
- Contexte technique : Prisma / uuid v5 — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/shared/src/utils/seedIdentity.ts
|
||||||
|
import { v5 as uuidv5 } from 'uuid';
|
||||||
|
|
||||||
|
// Namespace stable du projet (généré une fois, committé ensuite)
|
||||||
|
export const SEED_USER_NAMESPACE = '2cd71e75-dd5e-42cc-b9fa-52888c42cc3d';
|
||||||
|
|
||||||
|
export const seedUserId = (slug: string): string =>
|
||||||
|
uuidv5(slug, SEED_USER_NAMESPACE);
|
||||||
|
```
|
||||||
|
|
||||||
|
Côté tests :
|
||||||
|
|
||||||
|
- helpers (`TEST_SECRETARY`, `TEST_VENERABLE`) exposent l'UUID résolu : les tests écrivent `TEST_SECRETARY.id`, pas `'secretaire'`
|
||||||
|
- les users ad-hoc éphémères (créés/supprimés dans le scope d'un test) utilisent `randomUUID()`, pas `seedUserId()` — réservé aux entités seed durables
|
||||||
|
|
||||||
|
### Pourquoi pas UUID v4 aléatoire
|
||||||
|
|
||||||
|
Le déterminisme est essentiel : il permet aux fixtures E2E de pointer un user précis (`const TRESORIER_ID = seedUserId('tresorier')`) sans lire la base, et garantit la reproductibilité du seed en CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-test-invariant-post-seed"></a>
|
||||||
|
## Pattern : Test d'invariant post-seed
|
||||||
|
|
||||||
|
- Objectif : transformer la liste canonique des entités seed en contrat exécutable, détecter immédiatement un drift (slug ajouté hors helper, user oublié, format incohérent).
|
||||||
|
- Contexte : projet avec un seed structurant (users, configurations système) référencé par les fixtures de tests et les flux E2E.
|
||||||
|
- Quand l'utiliser : à chaque migration qui modifie la forme d'une entité seed (UUID, format d'id, contraintes).
|
||||||
|
- Quand l'éviter : seed purement aléatoire et jetable (pas de référence stable depuis les tests).
|
||||||
|
- Avantage :
|
||||||
|
- le test devient un contrat lisible du seed, pas une abstraction
|
||||||
|
- détecte un futur dev qui ajouterait un user via un slug littéral sans `seedUserId()`
|
||||||
|
- détecte un user oublié ou dupliqué
|
||||||
|
- Limites / vigilance :
|
||||||
|
- **nombre exact**, pas "au moins N" : si le seed tronque à 29 au lieu de 30, le test doit échouer
|
||||||
|
- filtrer explicitement par la liste des slugs connus — ne pas valider "tous les users en base" (résidus possibles)
|
||||||
|
- Validé le : 24-04-2026
|
||||||
|
- Contexte technique : Vitest / Prisma — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// __tests__/seedInvariants.test.ts
|
||||||
|
const SEED_SLUGS: readonly string[] = [
|
||||||
|
'venerable', 'secretaire', /* … 30 slugs … */
|
||||||
|
];
|
||||||
|
const EXPECTED_SEED_USER_COUNT = 31;
|
||||||
|
|
||||||
|
test('seed invariant: users seed possèdent un UUID déterministe', async () => {
|
||||||
|
assert.equal(SEED_SLUGS.length, EXPECTED_SEED_USER_COUNT, 'liste figée');
|
||||||
|
|
||||||
|
const expectedIds = SEED_SLUGS.map(seedUserId);
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: expectedIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(users.length, EXPECTED_SEED_USER_COUNT);
|
||||||
|
assert.ok(users.every((u) => isValidUuid(u.id)));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Liste figée des slugs en constante
|
||||||
|
- [ ] Compte exact (`.equal`, pas `.gte`)
|
||||||
|
- [ ] Filtrage explicite par la liste (pas de `findMany()` global)
|
||||||
|
- [ ] Vérification du format de l'id
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-check-fail-loud-conditionnee"></a>
|
||||||
|
## Pattern : Check `RAISE EXCEPTION` conditionnée à la présence de données
|
||||||
|
|
||||||
|
- Objectif : préserver la rejouabilité de la migration sur une DB vide (dev `prisma migrate reset`) tout en gardant le fail-loud sur DB peuplée.
|
||||||
|
- Contexte : migration qui fait un backfill de données existantes et veut échouer si l'admin/owner cible est absent.
|
||||||
|
- Quand l'utiliser : toute check "fail if missing X" qui protège un backfill, jamais le schéma lui-même.
|
||||||
|
- Quand l'éviter : check de schéma purement structurel (`NOT NULL`, FK) — ces contraintes appartiennent au DDL, pas à un `RAISE`.
|
||||||
|
- Avantage :
|
||||||
|
- DB vide (dev reset) : 0 row à backfiller → check skip propre, migration passe
|
||||||
|
- DB prod/staging avec données : check conservée, fail-loud comme prévu
|
||||||
|
- Limites / vigilance :
|
||||||
|
- une migration doit rester rejouable sur une DB vide ET une DB peuplée — c'est le contrat de `prisma migrate reset`
|
||||||
|
- Validé le : 21-04-2026
|
||||||
|
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||||
|
|
||||||
|
### Anti-pattern
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ❌ Bloque tout migrate reset sur dev (DB vide)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true) THEN
|
||||||
|
RAISE EXCEPTION 'Migration X requires an admin user.';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern correct
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ✅ Exige admin uniquement s'il y a des données à backfiller
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM cotisation_entries WHERE status = 'paid')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true)
|
||||||
|
THEN
|
||||||
|
RAISE EXCEPTION 'Migration X requires an admin user to backfill existing paid entries.';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-revocation-atomique-etat-transversal"></a>
|
||||||
|
## Pattern : Révocation atomique d'un état transversal lors d'une transition de cycle
|
||||||
|
|
||||||
|
- Objectif : éteindre les champs d'état transversaux (délégation, lock, ownership) dans la **même transaction** que la transition de cycle de vie de l'entité parente.
|
||||||
|
- Contexte : transitions `close / archive / cancel / soft-delete / lock définitif` d'une entité qui porte un ou plusieurs champs transversaux n'ayant plus de sens dans le nouveau cycle.
|
||||||
|
- Quand l'utiliser : à chaque transition de cycle où un état transversal devient un "zombie" potentiel (délégation qui survit à la clôture, lock qui survit à l'archivage).
|
||||||
|
- Quand l'éviter : transitions sans état transversal pertinent (archivage simple).
|
||||||
|
- Avantage :
|
||||||
|
- aucun état zombie possible
|
||||||
|
- la valeur précédente est capturée sous lock → audit et notif fiables (pas de race entre lecture et écriture)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- les effets de bord (audit, notif) DOIVENT sortir de la transaction (best-effort, fire-and-forget)
|
||||||
|
- l'idempotence est gérée par le `WHERE` du `updateMany` (`closedAt: null`) — la 2e tentative ne re-déclenche pas les effets de bord
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let previousDelegateeId: string | null = null;
|
||||||
|
let updateCount = 0;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const lockResult = await tx.$queryRaw<Array<{ delegatee_id: string | null }>>`
|
||||||
|
SELECT delegatee_id FROM "entities"
|
||||||
|
WHERE id = ${id} AND closed_at IS NULL
|
||||||
|
FOR UPDATE
|
||||||
|
`;
|
||||||
|
if (lockResult.length === 0) return; // idempotence
|
||||||
|
previousDelegateeId = lockResult[0].delegatee_id;
|
||||||
|
|
||||||
|
const updated = await tx.entity.updateMany({
|
||||||
|
where: { id, closedAt: null },
|
||||||
|
data: {
|
||||||
|
closedAt: now,
|
||||||
|
closedBy: userId,
|
||||||
|
delegateeId: null, // ← révocation atomique dans la même tx
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updateCount = updated.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effets de bord HORS de la transaction
|
||||||
|
if (updateCount > 0 && previousDelegateeId !== null) {
|
||||||
|
logAction(userId, 'entity:delegation_revoked_on_close', ...);
|
||||||
|
void notifyDelegatee(previousDelegateeId, ...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Les 4 invariants
|
||||||
|
|
||||||
|
1. La révocation vit dans le **même `updateMany`** que la transition principale.
|
||||||
|
2. La capture de la valeur précédente est sous **`SELECT FOR UPDATE`** dans la transaction.
|
||||||
|
3. Les effets de bord (audit, notif) **sortent de la transaction**.
|
||||||
|
4. L'idempotence est gérée par le `WHERE` (`closedAt: null`) — la 2e tentative est un no-op observable.
|
||||||
|
|
||||||
|
### Tests minimaux
|
||||||
|
|
||||||
|
- Happy path : transition avec valeur transversale présente → champ nullé + audit + notif au bon target
|
||||||
|
- Sans valeur transversale : pas d'effet de bord (pas d'audit révocation, pas de notif)
|
||||||
|
- Idempotence : 2e transition retombe en already_closed sans double effet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-migration-destructive-4-phases"></a>
|
||||||
|
## Pattern : Migration destructive en 4 phases avec sentinelle d'archive
|
||||||
|
|
||||||
|
- Objectif : refondre une table avec PK changée ou colonnes incompatibles sans perdre l'audit historique des rows métier importantes.
|
||||||
|
- Contexte : table dont la PK ou la forme évolue de façon non-rétrocompatible (ex : token en clair → hash SHA-256 stocké, slug → UUID).
|
||||||
|
- Quand l'utiliser : refonte structurelle où un `ALTER TABLE` patchwork serait fragile (FK multiples, index, contraintes).
|
||||||
|
- Quand l'éviter : ajout simple de colonne nullable, refactor cosmétique d'index.
|
||||||
|
- Avantage :
|
||||||
|
- DROP + CREATE plus sûr qu'un patchwork ALTER quand la PK change
|
||||||
|
- les rows historiques (`status = 'consumed'`) sont conservées pour audit
|
||||||
|
- sentinelle d'archive non-collisionnable garantit qu'aucun login ne peut matcher une row archivée
|
||||||
|
- Limites / vigilance :
|
||||||
|
- Phase 1 (DELETE des rows non-migrables) impose une communication aux admins pré-deploy
|
||||||
|
- inspection manuelle obligatoire du SQL généré par `prisma migrate dev --create-only`
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||||
|
|
||||||
|
### Recette
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Phase 1 : invalidation propre des données non-migrables
|
||||||
|
-- Les tokens en clair ne peuvent pas être convertis en SHA-256 (one-way).
|
||||||
|
-- Les rows 'consumed' sont conservées pour audit historique.
|
||||||
|
DELETE FROM invitations WHERE status != 'consumed';
|
||||||
|
|
||||||
|
-- Phase 2 : refonte de la table
|
||||||
|
CREATE TEMP TABLE invitations_archive AS
|
||||||
|
SELECT email, status, consumed_at FROM invitations WHERE status = 'consumed';
|
||||||
|
|
||||||
|
DROP TABLE invitations CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE invitations (
|
||||||
|
id TEXT NOT NULL DEFAULT gen_random_uuid()::text PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
...
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX invitations_one_active_per_user
|
||||||
|
ON invitations(user_id) WHERE status = 'active';
|
||||||
|
|
||||||
|
-- Phase 3 : restauration des rows consommées avec sentinelle non-collisionnable
|
||||||
|
-- '_legacy_' n'est jamais produit par crypto.randomBytes(32).toString('hex')
|
||||||
|
INSERT INTO invitations (id, user_id, token_hash, email, status, consumed_at)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
u.id,
|
||||||
|
'_legacy_' || gen_random_uuid()::text,
|
||||||
|
a.email,
|
||||||
|
'consumed',
|
||||||
|
a.consumed_at
|
||||||
|
FROM invitations_archive a
|
||||||
|
JOIN users u ON u.email = a.email;
|
||||||
|
|
||||||
|
DROP TABLE invitations_archive;
|
||||||
|
|
||||||
|
-- Phase 4 : drop des colonnes obsolètes sur d'autres tables
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS must_change_password;
|
||||||
|
```
|
||||||
|
|
||||||
|
Côté repository, filtrer la sentinelle :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const LEGACY_TOKEN_HASH_PREFIX = '_legacy_';
|
||||||
|
|
||||||
|
export const findInvitationByTokenHash = async (tokenHash: string) => {
|
||||||
|
if (tokenHash.startsWith(LEGACY_TOKEN_HASH_PREFIX)) return null;
|
||||||
|
// … lookup normal
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Phase 1 communiquée aux admins pré-deploy si tokens actifs en cours
|
||||||
|
- [ ] Phase 2 préfère `DROP + CREATE` quand la PK change
|
||||||
|
- [ ] Phase 3 utilise un préfixe **garanti non-collisionnable** par construction cryptographique
|
||||||
|
- [ ] Idempotence (`IF EXISTS` / `IF NOT EXISTS`) sur les changements réversibles
|
||||||
|
- [ ] Procédure rollback documentée (`pg_dump` avant migration)
|
||||||
|
- [ ] Smoke test post-deploy (login, création, magic link)
|
||||||
|
|
||||||
|
|||||||
378
knowledge/backend/patterns/tests.md
Normal file
378
knowledge/backend/patterns/tests.md
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
---
|
||||||
|
title: Backend — Patterns : Tests
|
||||||
|
domain: backend
|
||||||
|
bucket: patterns
|
||||||
|
tags: [tests, vitest, prisma, integration, isolation]
|
||||||
|
applies_to: [implementation, review, debug]
|
||||||
|
severity: high
|
||||||
|
validated_on: 2026-05-02
|
||||||
|
source_projects: [RL799_V2]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend — Patterns : Tests
|
||||||
|
|
||||||
|
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-cleanup-track-tests-integration-db"></a>
|
||||||
|
## Pattern : `cleanup.track()` LIFO pour tests d'intégration DB
|
||||||
|
|
||||||
|
- Objectif : nettoyer proprement les artefacts créés par chaque test sans recourir à un `TRUNCATE CASCADE` qui casserait les transactions du SUT.
|
||||||
|
- Contexte : tests qui écrivent dans une vraie base (pas mockée), en cohabitation avec un seed durable et des tables FK-liées.
|
||||||
|
- Quand l'utiliser : dès qu'un test crée des rows à la volée et qu'on veut éviter la pollution inter-tests.
|
||||||
|
- Quand l'éviter : si le projet utilise `transactions + rollback par test` (déjà isolé), ou si le SUT n'écrit jamais en DB.
|
||||||
|
- Avantage :
|
||||||
|
- ordre LIFO = ordre FK-safe (les enfants tombent avant les parents)
|
||||||
|
- résistant aux throws intermédiaires (le `afterEach` tourne quand même)
|
||||||
|
- pas de `TRUNCATE` qui casserait les transactions ouvertes par le SUT
|
||||||
|
- Limites / vigilance :
|
||||||
|
- convention "100 % via `cleanup.track`" : ne pas mélanger avec des `prisma.*.deleteMany` directs
|
||||||
|
- la queue est pour le teardown, pas pour le setup : ne jamais tracker une création
|
||||||
|
- Validé le : 24-04-2026
|
||||||
|
- Contexte technique : Vitest / Prisma / Postgres — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation (helper minimal)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// __tests__/helpers/db.ts
|
||||||
|
type CleanupFn = () => Promise<void> | void;
|
||||||
|
|
||||||
|
export function createCleanup() {
|
||||||
|
const queue: CleanupFn[] = [];
|
||||||
|
return {
|
||||||
|
track(fn: CleanupFn) { queue.push(fn); },
|
||||||
|
async run() {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const fn = queue.pop()!;
|
||||||
|
try { await fn(); }
|
||||||
|
catch (err) { console.warn('cleanup.run error:', err); }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage typique
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const cleanup = createCleanup();
|
||||||
|
afterEach(async () => { await cleanup.run(); });
|
||||||
|
|
||||||
|
test('crée une tenue', async () => {
|
||||||
|
const tenue = await prisma.tenue.create({ data: { /* … */ } });
|
||||||
|
cleanup.track(async () => {
|
||||||
|
await prisma.tenue.deleteMany({ where: { id: tenue.id } });
|
||||||
|
});
|
||||||
|
// … assertions
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Ordre LIFO respecté (FK-safe)
|
||||||
|
- [ ] Aucun `prisma.*.deleteMany` direct hors `cleanup.track` dans les fichiers concernés
|
||||||
|
- [ ] Cleanup défensif (`try/catch + warn`) — pas de partial leak
|
||||||
|
- [ ] Aucune création (seulement des suppressions/restores) dans la queue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-globalsetup-vitest-purge-residus"></a>
|
||||||
|
## Pattern : `globalSetup` vitest pour purger les résidus DB inter-runs
|
||||||
|
|
||||||
|
- Objectif : repartir d'une DB propre en début de suite quand des cleanups intra-test sont incomplets et que les artefacts s'accumulent entre sessions.
|
||||||
|
- Contexte : projet vitest avec `maxWorkers: 1` (DB partagée), tables 100 % alimentées par les tests (notifications, audit logs, payments éphémères).
|
||||||
|
- Quand l'utiliser : symptômes de "tests verts en isolation, rouges en suite complète après plusieurs jours" liés à des `findFirst({ type })` qui tombent sur des résidus.
|
||||||
|
- Quand l'éviter : si la suite tourne déjà avec une stratégie `transactions + rollback` ou DB-per-worker.
|
||||||
|
- Avantage :
|
||||||
|
- hook standard vitest, pas de cron externe ni de `prisma:reset` manuel entre sessions
|
||||||
|
- 1 seule passe au démarrage de la suite, coût négligeable
|
||||||
|
- Limites / vigilance :
|
||||||
|
- **NE PAS purger les tables seed** (users, soirees seed, profiles, etc.) — réservé aux tables 100 % "test-only"
|
||||||
|
- ne corrige pas la flakiness intra-run entre fichiers consécutifs
|
||||||
|
- Validé le : 25-04-2026
|
||||||
|
- Contexte technique : Vitest / Prisma — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// vitest.config.ts
|
||||||
|
test: {
|
||||||
|
maxWorkers: 1,
|
||||||
|
globalSetup: ['./src/__tests__/globalSetup.ts'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/__tests__/globalSetup.ts
|
||||||
|
export default async function globalSetup() {
|
||||||
|
await prisma.notification.deleteMany();
|
||||||
|
await prisma.auditLog.deleteMany();
|
||||||
|
await prisma.cotisationPayment.deleteMany();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérification avant d'ajouter une table
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tables référencées par un snapshot/seed → NE PAS purger
|
||||||
|
grep -rln "prisma.<entity>.findMany\|seedSnapshot" src/__tests__/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Aucune table seed dans la liste des `deleteMany`
|
||||||
|
- [ ] Hook référencé dans `vitest.config.ts`
|
||||||
|
- [ ] Justifier en commentaire pourquoi chaque table listée est test-only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-template-database-isolation-fichiers"></a>
|
||||||
|
## Pattern : Template database Postgres pour isoler les fichiers de tests
|
||||||
|
|
||||||
|
- Objectif : éliminer la flakiness inter-fichiers en garantissant que chaque fichier de tests démarre avec une DB seedée pristine.
|
||||||
|
- Contexte : suite vitest qui partage une DB Postgres unique (cas typique : `maxWorkers: 1` ou DB unique côté CI).
|
||||||
|
- Quand l'utiliser : projet avec ≥50 fichiers de tests partageant une DB, présence de tests qui mutent les données seed sans restaurer systématiquement.
|
||||||
|
- Quand l'éviter : si les tests utilisent transactions+rollback (déjà isolés), DB-per-worker, ou si le rôle Postgres n'a pas le droit `CREATEDB`.
|
||||||
|
- Avantage :
|
||||||
|
- flakiness inter-fichiers éliminée par construction
|
||||||
|
- les tests ne polluent plus la DB de dev partagée
|
||||||
|
- réutilisation entre sessions : ~27 s au 1er run, < 1 s aux suivants
|
||||||
|
- Limites / vigilance :
|
||||||
|
- surcoût ~50 s sur 134 fichiers (cycle `drop + create FROM TEMPLATE` ~380 ms par fichier)
|
||||||
|
- les sub-processes (`prisma db seed`) doivent recevoir les envs critiques explicitement (`APP_BASE_URL`, `JWT_SECRET`, `ENCRYPTION_KEY`)
|
||||||
|
- Validé le : 01-05-2026
|
||||||
|
- Contexte technique : Prisma 7 / pg 8 / Postgres 17 / vitest 4 — RL799_V2
|
||||||
|
|
||||||
|
### Mécanique en 3 lots
|
||||||
|
|
||||||
|
**Lot 1 — DSN + primitives admin SQL** (`dbUrls.ts`, `dbAdmin.ts`) : `getAdminDsn()`, `getTemplateDsn()`, `getTestDsn()`, `databaseExists`, `dropDatabase` (`pg_terminate_backend` + `DROP IF EXISTS`), `createDatabase(name, { template })`. Whitelist stricte des noms de DB (les SQL admin ne supportent pas `$1`).
|
||||||
|
|
||||||
|
**Lot 2 — Bootstrap idempotent du template** (`bootstrapTemplate.ts`) :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function ensureTemplateReady() {
|
||||||
|
if (await databaseExists(TEMPLATE)) return; // no-op si déjà là
|
||||||
|
await createDatabase(TEMPLATE);
|
||||||
|
spawnSync('pnpm', ['-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy'], {
|
||||||
|
env: { ...process.env, DB_URL: getTemplateDsn() },
|
||||||
|
});
|
||||||
|
spawnSync('pnpm', ['-C', 'apps/api', 'exec', 'prisma', 'db', 'seed'], {
|
||||||
|
env: { ...process.env, DB_URL: getTemplateDsn() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lot 3 — Câblage vitest** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// globalSetup.ts
|
||||||
|
export default async function globalSetup() {
|
||||||
|
await ensureTemplateReady();
|
||||||
|
if (await databaseExists(TEST)) await dropDatabase(TEST);
|
||||||
|
return async () => {
|
||||||
|
if (await databaseExists(TEST)) await dropDatabase(TEST);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupFile.ts (avant chaque fichier)
|
||||||
|
if (await databaseExists(TEST)) await dropDatabase(TEST);
|
||||||
|
await createDatabase(TEST, { template: TEMPLATE });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pièges anticipés
|
||||||
|
|
||||||
|
- **`resolveDbUrl()` qui force le pathname `/test`** : doit préserver le DSN s'il pointe déjà sur la template (sinon le seed sub-process écrit dans la mauvaise DB).
|
||||||
|
- **`prisma db seed` sub-process** : les envs critiques chargées par `lib/prisma.ts` côté app doivent être propagées au sub-process (`loadEnv.ts` importé en tête de `globalSetup.ts`).
|
||||||
|
- **`globalForPrisma` cache un PrismaClient** : c'est le **contenu** de la DB qui change (drop+create), pas le DSN — pas de `$disconnect()` nécessaire grâce à `pg_terminate_backend`.
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Whitelist stricte des noms de DB autorisés (anti-injection)
|
||||||
|
- [ ] Bootstrap idempotent (réutilise la template entre sessions de dev)
|
||||||
|
- [ ] Sub-processes du bootstrap reçoivent les envs critiques
|
||||||
|
- [ ] Drop + create FROM TEMPLATE au début de chaque fichier
|
||||||
|
- [ ] Test de flakiness avant/après pour valider le gain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-helper-waitfor-fire-and-forget"></a>
|
||||||
|
## Pattern : Helper `waitForX()` polling-borné pour les attentes fire-and-forget
|
||||||
|
|
||||||
|
- Objectif : remplacer les `setTimeout(50) + findFirst` dispersés par un helper centralisé robuste à la charge CPU et auto-documenté.
|
||||||
|
- Contexte : tests qui valident un side-effect async (audit log, notification, mail log) déclenché par `setImmediate` / `Promise.resolve().then(...)` côté SUT.
|
||||||
|
- Quand l'utiliser : tout test qui attend qu'un side-effect non-awaited apparaisse en DB.
|
||||||
|
- Quand l'éviter : pour vérifier l'**absence** d'un event — là il faut un délai fixe puis assertion d'absence (le polling jusqu'au timeout ne prouve rien).
|
||||||
|
- Avantage :
|
||||||
|
- robuste aux variations de charge (pas de durée arbitraire)
|
||||||
|
- fail rapide si l'event n'arrive pas (timeout 1500 ms par défaut)
|
||||||
|
- lisibilité : intention claire (`waitForX` vs `setTimeout` + `findFirst`)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- le polling consomme des requêtes DB (1 toutes les 50 ms) — négligeable en `maxWorkers: 1`
|
||||||
|
- **NE PAS** utiliser pour vérifier l'absence d'un event
|
||||||
|
- Validé le : 25-04-2026
|
||||||
|
- Contexte technique : Vitest / Prisma — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// __tests__/helpers/asyncWait.ts
|
||||||
|
export const waitForAudit = async (
|
||||||
|
query: { action: string; targetId?: string; userId?: string },
|
||||||
|
options: { timeoutMs?: number; intervalMs?: number } = {},
|
||||||
|
) => {
|
||||||
|
const timeout = options.timeoutMs ?? 1500;
|
||||||
|
const interval = options.intervalMs ?? 50;
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const audit = await prisma.auditLog.findFirst({ where: query });
|
||||||
|
if (audit) return audit;
|
||||||
|
await new Promise((r) => setTimeout(r, interval));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ délai arbitraire et fragile
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const notif = await prisma.notification.findFirst({ where: { ... } });
|
||||||
|
|
||||||
|
// ✅ polling borné, intention claire
|
||||||
|
const notif = await waitForNotification({ type: 'X', recipientId: userId });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Filtre exhaustif (au minimum `recipientId` ou `targetId` pour éviter de matcher les artefacts d'un autre test)
|
||||||
|
- [ ] Timeout par défaut court (1500 ms)
|
||||||
|
- [ ] Migration progressive — pas tous les tests d'un coup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-test-atomicite-transaction"></a>
|
||||||
|
## Pattern : Tester l'atomicité d'une transaction (audit + mutation)
|
||||||
|
|
||||||
|
- Objectif : prouver par un test que la promesse "X est garanti par une transaction" tient — sans ce test, un refactor "fire-and-forget" passe en review sans alarme.
|
||||||
|
- Contexte : code de service qui revendique `mutation persistée ⇔ audit log existe` via `prisma.$transaction(async tx => { await tx.X.update(...); await logActionSync(tx, ...); })`.
|
||||||
|
- Quand l'utiliser : sur tout chemin critique où l'audit ou un side-effect transactionnel est un livrable métier.
|
||||||
|
- Quand l'éviter : pour les audits purement informatifs (statistiques d'usage) où le fire-and-forget est acceptable.
|
||||||
|
- Avantage :
|
||||||
|
- le test fait office de contrat exécutable du pattern transactionnel
|
||||||
|
- une régression silencieuse (passer `prisma` au lieu de `tx`) casse le test immédiatement
|
||||||
|
- Limites / vigilance :
|
||||||
|
- le mock doit cibler le seul appel ciblé (pas un mock global qui fait tout throw)
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : Vitest / Prisma — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation (recipe vitest + spyOn)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as auditRepository from '../repositories/admin/auditRepository';
|
||||||
|
|
||||||
|
test('mutation rollback si audit throw', async () => {
|
||||||
|
vi.spyOn(auditRepository, 'createAuditLog').mockImplementation(async (data) => {
|
||||||
|
if (data.action === 'X.cross_soiree_update') {
|
||||||
|
throw new Error('audit-failure-simulee');
|
||||||
|
}
|
||||||
|
return null as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST_HANDLER(...);
|
||||||
|
assert.ok(res.status >= 500);
|
||||||
|
assert.equal(await prisma.X.findFirst(...), null); // rollback
|
||||||
|
assert.equal(await prisma.auditLog.count(...), 0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Mock chirurgical sur l'action ciblée, pas un mock global
|
||||||
|
- [ ] Assertions sur **trois** invariants : status HTTP 5xx, donnée non persistée, audit non écrit
|
||||||
|
- [ ] Restaurer le mock après le test (`vi.restoreAllMocks()` en `afterEach`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-describe-convention-tests-integration"></a>
|
||||||
|
## Pattern : Convention `describe()` minimale pour tests d'intégration
|
||||||
|
|
||||||
|
- Objectif : rendre les fichiers de tests > 150 LOC navigables, regroupables par cause d'échec et lisibles dans le reporter.
|
||||||
|
- Contexte : tests d'intégration API où les assertions s'enchaînent sans regroupement, ou fichiers historiquement plats.
|
||||||
|
- Quand l'utiliser : tout fichier > 150 LOC ou > 5 tests.
|
||||||
|
- Quand l'éviter : fichier court (< 50 LOC, < 3 tests) où le `describe` ajoute du bruit.
|
||||||
|
- Avantage :
|
||||||
|
- le reporter vitest groupe les échecs logiquement (toutes les erreurs d'auth ensemble)
|
||||||
|
- navigation facilitée par recherche (`describe('Auth'`)
|
||||||
|
- un groupe devient candidat naturel à l'extraction en fichier dédié
|
||||||
|
- Limites / vigilance :
|
||||||
|
- 2 niveaux maximum — au-delà ça devient illisible
|
||||||
|
- Validé le : 24-04-2026
|
||||||
|
- Contexte technique : Vitest — RL799_V2
|
||||||
|
|
||||||
|
### Convention recommandée (2 niveaux)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('POST /api/tenues', () => {
|
||||||
|
describe('Auth', () => {
|
||||||
|
test('refuse un user non-admin → 403', async () => { /* … */ });
|
||||||
|
test('refuse un token invalide → 401', async () => { /* … */ });
|
||||||
|
});
|
||||||
|
describe('Validation', () => {
|
||||||
|
test('refuse un body sans date → 400', async () => { /* … */ });
|
||||||
|
});
|
||||||
|
describe('Happy path', () => {
|
||||||
|
test('crée une tenue valide → 201', async () => { /* … */ });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seuils
|
||||||
|
|
||||||
|
- < 50 LOC et < 3 tests : optionnel
|
||||||
|
- 50–150 LOC : 1 niveau de contexte
|
||||||
|
- \> 150 LOC ou > 5 tests : 2 niveaux obligatoires
|
||||||
|
|
||||||
|
### Niveau 2 — vocabulaire stable
|
||||||
|
|
||||||
|
`Auth`, `Validation`, `Happy path`, `Edge cases`, `Error handling`, `Integration`. Ne pas inventer un nom local — la cohérence inter-fichiers vaut plus que l'expressivité ponctuelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-refactor-iteratif-tests-monolithe"></a>
|
||||||
|
## Pattern : Refactor itératif d'un fichier de tests monolithe
|
||||||
|
|
||||||
|
- Objectif : découper un fichier > 1000 LOC mêlant plusieurs domaines métier en fichiers thématiques sans introduire de régression.
|
||||||
|
- Contexte : fichier de tests historique qui partage un `beforeEach` lourd, n'a pas de `describe`, et accueille tous les nouveaux tests par défaut.
|
||||||
|
- Quand l'utiliser : > 1000 LOC, plusieurs domaines mélangés, navigation pénible.
|
||||||
|
- Quand l'éviter : fichier ciblé, déjà cohérent même volumineux.
|
||||||
|
- Avantage :
|
||||||
|
- 1 commit par domaine extrait → review possible commit par commit
|
||||||
|
- helpers réutilisables émergent naturellement
|
||||||
|
- reporter vitest groupe les échecs par domaine
|
||||||
|
- Limites / vigilance :
|
||||||
|
- flakiness inter-fichiers possible pendant la transition (cleanups incomplets temporaires)
|
||||||
|
- tolérer 1-2 fails au 1er run pendant le chantier, investiguer après
|
||||||
|
- Validé le : 25-04-2026
|
||||||
|
- Contexte technique : Vitest — RL799_V2
|
||||||
|
|
||||||
|
### Étapes (ordre obligatoire)
|
||||||
|
|
||||||
|
1. **Analyse** : `grep -nE "^test|^// ---" fichier.test.ts` → carte des sections + repérage des dépendances closure.
|
||||||
|
2. **Extraction des helpers d'abord** : `helpers/<domaine>.ts` avec `setup<Domaine>Fixtures(cleanup)` qui retourne explicitement les IDs (pas de closure module-level).
|
||||||
|
3. **POC sur le domaine le plus simple** (4-7 tests, peu de dépendances). Commit + push dès que vert.
|
||||||
|
4. **Itération domaine par domaine** par ordre croissant de complexité. Une seule règle : ne jamais commencer un nouveau domaine sans avoir commit le précédent.
|
||||||
|
5. **Suppression finale du fichier monolithe** quand le dernier domaine est extrait.
|
||||||
|
|
||||||
|
### Pièges à éviter
|
||||||
|
|
||||||
|
- **Ne jamais dupliquer le fichier** pour le scinder : toujours **déplacer** (extraction → suppression dans l'original → commit).
|
||||||
|
- **Closure variables partagées** : si les tests utilisent `let X = ''` au niveau module, les ids doivent passer par le retour de `setup<Domaine>`.
|
||||||
|
- **Snapshot/restore seed** : si le fichier original prenait un snapshot d'entries seed, **chaque** nouveau fichier doit le faire (sinon le 1er fichier qui tourne snapshot un état déjà pollué).
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Helpers extraits **avant** le 1er domaine
|
||||||
|
- [ ] 1 commit par domaine
|
||||||
|
- [ ] `wc -l` du fichier original baisse à chaque commit (preuve de progrès)
|
||||||
|
- [ ] Suite verte à chaque commit
|
||||||
@@ -14,5 +14,6 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
|
|||||||
| `stripe.md` | Stripe, paiements, webhooks, subscriptions | billing_cycle_anchor vs current_period_end, list() sans has_more, concurrence trial→payant, non-idempotence, 200 pendant processing |
|
| `stripe.md` | Stripe, paiements, webhooks, subscriptions | billing_cycle_anchor vs current_period_end, list() sans has_more, concurrence trial→payant, non-idempotence, 200 pendant processing |
|
||||||
| `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète |
|
| `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète |
|
||||||
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h |
|
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h |
|
||||||
| `nextjs.md` | Next.js, build, routing | Prisma init au chargement module, server-only dans repositories, redirect boucle infinie feature flags |
|
| `nextjs.md` | Next.js, build, routing | Prisma init au chargement module, server-only dans repositories, redirect boucle infinie feature flags, dossiers `_*` exclus du routing App Router |
|
||||||
| `general.md` | Observabilité, migrations, performance, architecture | Observabilité insuffisante, migrations non reproductibles, upsert N+1, authorize-after-fetch, valeur sentinelle DTO, idempotence endpoint, fichier orphelin, mélange Date UTC/locale, champ fantôme Zod, catch vide, params non validés, cast TS brut, chevauchement temporel, TOCTOU, biais agrégation, couplage types erreur, service HTTP-aware, count sans filtre, env top-level |
|
| `general.md` | Observabilité, migrations, performance, architecture | Observabilité insuffisante, migrations non reproductibles, upsert N+1, authorize-after-fetch, valeur sentinelle DTO, idempotence endpoint, fichier orphelin, mélange Date UTC/locale, champ fantôme Zod, catch vide, params non validés, cast TS brut, chevauchement temporel, TOCTOU, biais agrégation, couplage types erreur, service HTTP-aware, count sans filtre, env top-level, dérive DTO liste vs détail, notification linkUrl rôle-aware, matrice documentée vs code, format `User.id` mixte, Web Push topic > 32 chars, lib npm types non embarqués, form HTML POST dans un mail, env vars frontend-facing fail-fast |
|
||||||
|
| `tests.md` | Isolation des tests d'intégration | `vi.stubEnv` sans restauration, `maxWorkers: 1` masque l'isolation, flakiness inter-fichiers DB partagée |
|
||||||
|
|||||||
@@ -366,3 +366,96 @@ it('retourne 403 si subscription inactive', async () => {
|
|||||||
- Contexte technique : auth / cycle de vie compte — RL799_V2 17-04-2026
|
- Contexte technique : auth / cycle de vie compte — RL799_V2 17-04-2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<a id="risque-helpers-x-actif-derivants"></a>
|
||||||
|
## Helpers "X actif" qui dérivent silencieusement
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Plusieurs helpers répondent à la même question — *"l'entité X est-elle active / opérante ?"* — avec des filtres légèrement différents
|
||||||
|
- Un user passe la guard A mais pas la guard B sur la même ressource (ou inversement). Bugs silencieux, pas d'erreur, juste une asymétrie de comportement
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Délégation `secretaireDeSeance` "active" filtrée sur `status: 'published', closedAt: null, cancelledAt: null` dans un helper, juste `cancelledAt: null` dans l'autre
|
||||||
|
- Un ex-délégué d'une soirée clôturée garde l'autorité cross-soirée indéfiniment
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
1. **Un seul helper canonique** par notion d'activité (ex : `isDelegationActive`, `isSoireeOpenForRappel`). Les autres l'appellent
|
||||||
|
2. Si la centralisation n'est pas faisable immédiatement (ex : helper appelé en N+1 query, perf), au moins un test qui compare leur output sur des fixtures partagées et casse à la moindre divergence
|
||||||
|
3. Au minimum : un commentaire en tête du helper "secondaire" qui pointe vers le canonique et liste explicitement les filtres à maintenir synchronisés
|
||||||
|
|
||||||
|
- Contexte technique : auth / RBAC — RL799_V2 27-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-guard-charge-objets-riches"></a>
|
||||||
|
## Guard d'autorisation qui charge des objets riches
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Une guard d'autorisation s'exécute à CHAQUE requête sur une route protégée
|
||||||
|
- Si la guard a besoin de "trouver une candidate" (ex : "cette tenue est-elle dans les 'dernières rappelables' du grade pour une de mes délégations ?"), le repo helper utilisé doit avoir un select **minimal**, PAS le select complet utilisé par les services métier
|
||||||
|
- Pour un user avec N délégations actives, on charge N agrégats volumineux à chaque requête
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Même fonction repo appelée par (1) un service qui a besoin de toutes les relations (rendu UI) et (2) une guard qui n'a besoin que de l'id
|
||||||
|
- La guard paie le coût du fetch riche inutilement
|
||||||
|
- Latence guard qui croît avec le nombre de relations chargées
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Exposer **deux variantes** du repo helper :
|
||||||
|
|
||||||
|
- `findX(...)` — select riche, utilisé par les services métier
|
||||||
|
- `findXIdOnly(...)` — select `{ id: true }`, utilisé par les guards
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Guard
|
||||||
|
export const requireXAccess = async (request, id, { roleSet }) => {
|
||||||
|
// utilise findXIdOnly (select minimal) — pas findX
|
||||||
|
const candidate = await repo.findXIdOnly({ ... });
|
||||||
|
if (!candidate || candidate.id !== id) return forbidden();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Service métier
|
||||||
|
export const getXFullDetails = async (id) => {
|
||||||
|
return repo.findX({ ... }); // include riche
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Coût : duplication de la clause `where` (acceptable, factorisable en constante). Bénéfice : la guard reste O(1) en payload même quand les relations grossissent.
|
||||||
|
|
||||||
|
- Contexte technique : auth / performance — RL799_V2 27-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-suppression-flag-auth-global"></a>
|
||||||
|
## Suppression d'un flag auth global (DB + DTO + tests) — cleanup atomique obligatoire
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un flag profondément câblé dans Prisma (ex : `mustChangePassword`, `isVerified`) ne peut pas être supprimé incrémentalement : chaque cleanup partiel produit un état non-compilable
|
||||||
|
- Les fixtures de tests qui posent `mustChangePassword: false` cassent à la compilation TS au moment du drop — bloque tout commit séparé
|
||||||
|
- Les helpers `helpers/db.ts` et les DTO partagés (`packages/shared`) sont prioritaires, sinon les imports cross-package échouent en cascade
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `Property 'mustChangePassword' does not exist on type 'User'` après un drop partiel
|
||||||
|
- Tentative de découpage en sous-lots qui échoue au typecheck
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Quand on prévoit de supprimer un flag auth profondément câblé :
|
||||||
|
|
||||||
|
1. **Le cleanup ne peut pas être incrémental** — soit on supprime tout dans un chantier, soit on garde le flag avec un nullable de transition
|
||||||
|
2. **Les fixtures de tests doivent être nettoyées dans le même PR** — grep systématique avant de démarrer (`grep -rn "mustChangePassword" apps/`) pour estimer l'ampleur
|
||||||
|
3. **Les helpers `helpers/db.ts`** sont prioritaires — un seul fichier touché casse tous les tests qui l'importent
|
||||||
|
4. **Les DTO partagés (`packages/shared`)** doivent être alignés en premier
|
||||||
|
5. Considérer un sous-lot dédié au cleanup si le flag est transverse — éviter de l'inclure dans un sous-lot fonctionnel
|
||||||
|
|
||||||
|
**Anti-pattern** : déprécier en douceur en gardant le flag avec un commentaire `// @deprecated` sans supprimer les usages. Le code mort s'accumule, les futurs devs hésitent à le nettoyer ("pourquoi c'est encore là ?"), la dépréciation ne se finit jamais.
|
||||||
|
|
||||||
|
- Contexte technique : auth / refactor schema — RL799_V2 28-04-2026
|
||||||
@@ -222,3 +222,40 @@ Quand un repository ou service crée une nouvelle valeur pour un champ enum-like
|
|||||||
3. Vérifier que les endpoints de lecture qui parsent ces données acceptent la nouvelle valeur
|
3. Vérifier que les endpoints de lecture qui parsent ces données acceptent la nouvelle valeur
|
||||||
|
|
||||||
- Contexte technique : Zod / contrats partagés — RL799_V2 03-04-2026
|
- Contexte technique : Zod / contrats partagés — RL799_V2 03-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-zod-email-tolowercase-trim"></a>
|
||||||
|
## Bug Zod 4 — `z.string().email().toLowerCase().trim()` rejette les emails à trim
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Le pattern `z.string().email().toLowerCase().trim()` ne fait **pas** ce qu'il prétend en Zod 4 : `.email()` est une assertion qui valide le format **brut**, **avant** que les transforms `.toLowerCase()` / `.trim()` s'appliquent
|
||||||
|
- Un email avec espace trailing (`"BOB@X.FR "`) est rejeté `Invalid email` au lieu d'être trim+lower
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Test fixture `BOB@X.FR ` (trailing space) → 400 alors que l'intention est `bob@x.fr`
|
||||||
|
- Pattern présent dans plusieurs schémas du projet (`visitorProfileLookupSchema`, `tenueVisitorCreateSchema`, etc.)
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Pattern legacy faux (Zod 4) — assertion AVANT transforms
|
||||||
|
const emailSchema = z.string().email().max(254).toLowerCase().trim();
|
||||||
|
|
||||||
|
// ✅ Pattern correct : trim/lower AVANT email assertion via pipe
|
||||||
|
const emailSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.pipe(z.string().email().max(254));
|
||||||
|
```
|
||||||
|
|
||||||
|
`.pipe()` chaîne deux schémas — le premier transforme (trim+lower), le second valide (email+max). L'ordre devient explicite et l'assertion est appliquée après normalisation.
|
||||||
|
|
||||||
|
**Tests à ajouter** : `BOB@X.FR ` (trailing space) → `bob@x.fr`, ` ALICE@TEST.FR` (leading + casse) → `alice@test.fr`. Si le schéma rejette `Invalid email`, le bug est présent.
|
||||||
|
|
||||||
|
**À auditer projet-wide** : grep tous les schémas avec ce pattern (`.email().toLowerCase().trim()`) et migrer en `.pipe()`.
|
||||||
|
|
||||||
|
- Contexte technique : Zod 4 — RL799_V2 01-05-2026
|
||||||
|
|||||||
@@ -868,3 +868,280 @@ try {
|
|||||||
- Définir une timezone métier unique pour les communications utilisateur.
|
- Définir une timezone métier unique pour les communications utilisateur.
|
||||||
|
|
||||||
- Contexte technique : dates / formatage serveur — RL799_V2 15-04-2026
|
- Contexte technique : dates / formatage serveur — RL799_V2 15-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-derive-dto-liste-vs-detail"></a>
|
||||||
|
## Dérive silencieuse DTO liste vs DTO détail
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un DTO "détail" expose un ensemble complet de champs métier, pendant qu'un DTO "liste" ne propage qu'un sous-ensemble jugé "suffisant" au moment où il est créé
|
||||||
|
- Au fil du temps, le front a besoin de plus de champs et découvre que les DTOs de liste sont **amputés** — workarounds ad-hoc, champs morts produits jamais consommés, helpers partagés impossibles à appeler sur les listes sans cast
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Un consommateur front appelle l'endpoint détail juste pour obtenir un champ qui existe côté détail mais pas liste (N+1 réseau déguisé)
|
||||||
|
- Workarounds ad-hoc (`soireeClosedAt: Date | null` dans un mapper TenueSummary, copie partielle de champs) parce que le champ racine manque
|
||||||
|
- Helper partagé (`getSoireeLifecycle(input)`) qui accepte un `SoireeLifecycleInput` qu'**aucun** DTO de liste n'implémente réellement
|
||||||
|
- Type "sous-ensemble" (`SoireeCalendarStatus = 'draft' | 'pending_vm_approval' | 'published'`) aligné sur un filtre SQL transitoire plutôt que sur la sémantique du domaine
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- **Règle par défaut** : DTO liste = sous-ensemble de DTO détail, pas un type parallèle. Extraire une base commune si besoin (`SoireeCore`).
|
||||||
|
- Pour chaque champ scalaire ajouté au DTO détail, se poser la question : doit-il aussi être dans les DTOs de liste ? Si oui, le propager sur-le-champ
|
||||||
|
- **Typage fort sur les sous-ensembles** : `SoireeCalendarStatus = SoireeStatus` (alias) plutôt qu'une union locale qui reflète un filtre SQL
|
||||||
|
- Test de coverage statique qui vérifie, pour chaque DTO ciblé, que tous ses mappers exposent les champs requis
|
||||||
|
- Audit périodique après une livraison qui ajoute des champs (ex : `openedAt` persistant) : lister les DTOs de liste et vérifier qu'aucun n'est amputé
|
||||||
|
|
||||||
|
- Contexte technique : DTO / contrats partagés — RL799_V2 23-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-notif-linkurl-non-role-aware"></a>
|
||||||
|
## Notification `linkUrl` non rôle-aware → page vide / 403 silencieux
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Une notification envoyée à N destinataires multi-rôles avec un `linkUrl` constant route certains utilisateurs vers une page à laquelle ils n'ont pas accès
|
||||||
|
- Symptôme côté membre : "la notif m'envoie sur une page vide" — UX cassée sans message d'erreur explicite
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Code de création de notif qui fait `recipients.map((r) => ({ linkUrl: 'constant' }))` sans lire `r.role`
|
||||||
|
- Notif qui cible plusieurs rôles (ex : "tous les membres") mais utilise un linkUrl pointant vers un module à accès restreint
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Toujours sélectionner role dans le select des recipients
|
||||||
|
const recipients = await prisma.user.findMany({
|
||||||
|
where: { isActive: true, role: { in: [...ROLES_ALL_ACTIVE] } },
|
||||||
|
select: { id: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Brancher le linkUrl par rôle
|
||||||
|
const secretariatRoles = new Set(['secretaire', 'venerable', 'admin']);
|
||||||
|
linkUrl: secretariatRoles.has(recipient.role)
|
||||||
|
? `/secretariat?soireeId=${id}`
|
||||||
|
: `/tenues?tab=calendrier`;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle d'or** : le `linkUrl` d'une notif doit ouvrir une page **que l'utilisateur a le droit de voir ET où le contexte de la notif est visible**. Un membre qui reçoit "Soirée annulée" doit atterrir sur le calendrier (carte rouge), pas sur un module secrétariat qu'il ne peut pas consulter.
|
||||||
|
|
||||||
|
**Test E2E suggéré** : publier une notif multi-rôles, se connecter avec chaque rôle, cliquer, vérifier que chacun arrive sur une page accessible et pertinente.
|
||||||
|
|
||||||
|
- Contexte technique : notifications / RBAC — RL799_V2 23-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-matrice-documentee-vs-code"></a>
|
||||||
|
## Matrice documentée ≠ code — dérive silencieuse
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Une matrice de permissions / contrats publiée dans une story (markdown) diverge discrètement de l'implémentation
|
||||||
|
- La doc dit "X peut Y", le code refuse Y à X (ou inversement). Aucun test ne couvre la combinaison rare
|
||||||
|
- La divergence se paye au prochain audit RBAC ou au touchement suivant du module — souvent par surprise
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Story d'origine qui annonce une perm que le code ne grant pas (ou inversement)
|
||||||
|
- Un nouvel agent lit la story et la matrice, pense que la perm est active, et écrit du code qui repose dessus → faux positif aval
|
||||||
|
- Bug détecté plusieurs cycles après publication, par hasard
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
1. **Audit pré-flight systématique** avant tout PATCH d'un module RBAC : `grep -rn '<helper-perm>' apps/` pour confirmer les call sites, comparer avec la matrice de la story d'origine
|
||||||
|
2. **Réconciliation atomique** : si on touche un helper de permission, mettre à jour les **deux couches** (granulaire `permissions.ts` + fonctionnelle `documentPermissions.ts`) dans la même PR
|
||||||
|
3. **Test de matrice dédié** : un test unitaire qui itère la matrice de la story et vérifie chaque cellule. Casse à la première dérive
|
||||||
|
4. Préférer **un seul source of truth** (le code) et générer la doc automatiquement (markdown depuis tests, ou inverse)
|
||||||
|
|
||||||
|
- Contexte technique : RBAC / documentation — RL799_V2 20-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-format-user-id-mixte"></a>
|
||||||
|
## Format `User.id` : UUID OU slug, jamais les deux
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un schéma où `User.id` est un `String` libre finit par mélanger deux formats : IDs lisibles du seed (`admin`, `membre-m05`) et vrais UUIDs générés à l'invitation
|
||||||
|
- Conséquence : impossible de mettre `z.string().uuid()` dans les DTOs qui prennent un `userId` sans casser la prod
|
||||||
|
- Surface d'injection grande (payloads de 100 caractères acceptés au lieu de UUID stricts)
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Schéma Zod avec `z.string().min(1).max(128)` là où on voudrait `z.string().uuid()`
|
||||||
|
- Commentaire "l'ID n'est pas forcément un UUID, on accepte toute chaîne"
|
||||||
|
- Deux populations d'ids coexistantes en base (seed slug + invitations UUID)
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Décider tôt : soit `@default(uuid())` côté Prisma partout, soit IDs structurés documentés avec une regex stricte (`^[a-z]+-[a-z0-9]+$`) publiée dans un helper shared (`isValidUserId`)
|
||||||
|
- **Ne jamais mélanger**
|
||||||
|
- Ajouter un test d'invariant : à la fin du seed, assert que tous les `users.id` matchent le format choisi
|
||||||
|
- Si migration vers UUID en cours de route : prévoir un script qui propage sur **toutes** les FKs (`audit_logs.user_id`, `notifications.recipient_id`, `refresh_tokens.user_id`, et tout `@relation` vers `User`)
|
||||||
|
- Pattern de migration : UUID v5 déterministe via `seedUserId(slug)` (cf. `pattern-uuid-v5-deterministe-seed` dans `patterns/prisma.md`)
|
||||||
|
|
||||||
|
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-web-push-topic-32-chars"></a>
|
||||||
|
## Web Push `topic` header > 32 chars rejeté/tronqué (RFC 8030)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- La [RFC 8030 §5.4](https://datatracker.ietf.org/doc/html/rfc8030#section-5.4) limite le header `Topic` à 32 caractères URL-safe
|
||||||
|
- FCM tronque silencieusement (topics distincts pour deux notifs censées dédupliquer), Apple Push rejette la requête, Mozilla autopush comportement variable
|
||||||
|
- Symptôme : déduplication absente → avalanche de notifs au reconnect d'un device offline
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Push provider qui retourne 4xx sur des `topic` longs
|
||||||
|
- Plusieurs notifs reçues là où une seule devrait l'être
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const hashTopic = (seed: string): string =>
|
||||||
|
crypto.createHash('sha256').update(seed).digest('base64url').slice(0, 32);
|
||||||
|
|
||||||
|
await webpush.sendNotification(sub, body, {
|
||||||
|
TTL: 86_400,
|
||||||
|
urgency: 'high',
|
||||||
|
topic: hashTopic(`${type}-${contextId}`), // toujours ≤ 32 chars URL-safe
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes** :
|
||||||
|
- `base64url` (Node `crypto` natif depuis 16.x) produit un encoding URL-safe (`A-Za-z0-9_-`)
|
||||||
|
- Tronquer à 32 chars **après** encoding base64url, pas avant le hash
|
||||||
|
- Test unitaire : assert `topic.length <= 32` ET `topic.match(/^[A-Za-z0-9_-]+$/)` pour toutes les seeds réalistes
|
||||||
|
|
||||||
|
- Contexte technique : Web Push / RFC 8030 — RL799_V2 28-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-lib-npm-types-non-embarques"></a>
|
||||||
|
## Lib npm avec types annoncés mais non embarqués
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Certaines libs Node prétendent embarquer leurs types TS depuis une version donnée mais le package npm publié ne les contient pas
|
||||||
|
- `@types/<lib>` DefinitelyTyped existe mais peut être legacy, non maintenu, ou en conflit avec les exports réels du package
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `Could not find a declaration file for module '<lib>'. … Try \`npm i --save-dev @types/<lib>\``
|
||||||
|
- TS7016 après `pnpm add <lib>` alors que la doc annonce que les types sont embarqués
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Créer une déclaration TS locale minimaliste qui couvre uniquement la surface consommée par le projet :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/api/src/types/web-push.d.ts (exemple)
|
||||||
|
declare module 'web-push' {
|
||||||
|
export interface PushSubscriptionLike {
|
||||||
|
endpoint: string;
|
||||||
|
keys: { p256dh: string; auth: string };
|
||||||
|
}
|
||||||
|
export interface RequestOptions {
|
||||||
|
TTL?: number;
|
||||||
|
urgency?: 'very-low' | 'low' | 'normal' | 'high';
|
||||||
|
topic?: string;
|
||||||
|
}
|
||||||
|
export function setVapidDetails(subject: string, publicKey: string, privateKey: string): void;
|
||||||
|
export function sendNotification(
|
||||||
|
sub: PushSubscriptionLike,
|
||||||
|
payload?: string | Buffer | null,
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<{ statusCode: number; body: string; headers: Record<string, string> }>;
|
||||||
|
const _default: { setVapidDetails: typeof setVapidDetails; sendNotification: typeof sendNotification };
|
||||||
|
export default _default;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bénéfices** :
|
||||||
|
- on est maître du contrat utilisé (si la lib évolue, on étend volontairement)
|
||||||
|
- pas de dépendance `@types/*` legacy
|
||||||
|
- documentable : commentaire JSDoc en tête `Pourquoi pas @types/<lib>`
|
||||||
|
|
||||||
|
**Préventif** :
|
||||||
|
- `tsconfig.json` doit `include` le dossier `src/types/**/*.d.ts`
|
||||||
|
- documenter en commentaire en tête du `.d.ts` POURQUOI on a écrit ça soi-même
|
||||||
|
|
||||||
|
- Contexte technique : TypeScript / npm — RL799_V2 28-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-form-html-post-mail"></a>
|
||||||
|
## Form HTML POST dans un mail = neutralisé par tous les clients
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un `<form method="POST" action="...">` placé dans le corps HTML d'un mail transactionnel est **neutralisé par tous les clients mail majeurs** — c'est une mesure anti-phishing universelle, pas un bug
|
||||||
|
- Toute donnée structurée doit transiter par **l'URL** d'un GET (query string ou path), donc visible côté visiteur
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
| Client | Comportement réel sur `<form method="POST">` |
|
||||||
|
| --- | --- |
|
||||||
|
| Gmail web | Rewrite l'action en GET, body en query string |
|
||||||
|
| Gmail iOS/Android | Bouton inactif ou ouvre en GET |
|
||||||
|
| Outlook web | Strip le `<form>` complètement |
|
||||||
|
| Apple Mail (macOS/iOS) | Désactive le submit, bouton no-op |
|
||||||
|
| Thunderbird | Bloqué par sécurité |
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Mitigations pour ne pas exposer la donnée dans l'URL navigable :
|
||||||
|
|
||||||
|
1. **Pattern token signé court** (HMAC ou JWT) : encode la donnée dans un token opaque dans la query string, échangé immédiatement côté client contre un état serveur, puis `history.replaceState()` pour nettoyer l'URL (cf. `pattern-magic-link-url-clean` dans `patterns/auth.md`)
|
||||||
|
2. **Token one-shot DB** : génère un token aléatoire stocké en DB, consommé à la 1ʳᵉ requête, expire ensuite
|
||||||
|
3. **Cookie de session courte** : le 1ᵉʳ hit set un cookie httpOnly puis redirige vers une URL clean
|
||||||
|
|
||||||
|
À documenter dans toute spec de magic link / RSVP / one-shot URL pour éviter qu'un dev parte sur un POST mail.
|
||||||
|
|
||||||
|
- Contexte technique : email transactionnel — RL799_V2 30-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-env-vars-frontend-facing-fail-fast"></a>
|
||||||
|
## env vars frontend-facing — fail-fast strict hors dev (pas de fallback `localhost`)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un mail prod qui contient un lien `http://localhost:3000/foo` parce que `APP_URL` n'a pas été défini sur l'instance prod
|
||||||
|
- Aucun signal serveur, aucune erreur au déploiement, aucune trace en logs. L'utilisateur final clique → page introuvable
|
||||||
|
- Le fallback dev-friendly (`process.env.APP_URL ?? 'http://localhost:3000'`) cache l'erreur de config en non-dev
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- URL `localhost` dans des emails reçus par des utilisateurs réels
|
||||||
|
- Détection uniquement par un humain qui reçoit le mail, pas par le serveur
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const getBaseUrl = (): string => {
|
||||||
|
const raw = process.env.APP_URL;
|
||||||
|
if (raw !== undefined && raw !== '') return raw.replace(/\/+$/, '');
|
||||||
|
if (process.env.NODE_ENV === 'development') return 'http://localhost:3000';
|
||||||
|
throw new Error('APP_URL non configuré (requis hors dev). Le bouton ... pointerait vers un host invalide.');
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- Dev local : fallback silencieux (workflow attendu)
|
||||||
|
- Prod / staging / test : throw au premier appel → erreur visible dans les logs du dispatch
|
||||||
|
- Le throw au boot du dispatch est préférable à un mail dégradé silencieux
|
||||||
|
|
||||||
|
**Variantes à étendre** : tout helper qui construit une URL frontend depuis le backend (reset password, invitation, convocation, notification mail) doit utiliser le même helper centralisé. Une seule source de vérité par projet — éviter le doublon `APP_URL` + `APP_BASE_URL`.
|
||||||
|
|
||||||
|
**Test** : couvrir les 4 cas (env défini avec slash, env défini sans slash, env undefined NODE_ENV=dev → fallback, env undefined NODE_ENV=prod → throw).
|
||||||
|
|
||||||
|
- Contexte technique : config / mails transactionnels — RL799_V2 29-04-2026
|
||||||
|
|||||||
@@ -164,3 +164,28 @@ return buildLocalizedPath(locale, "home");
|
|||||||
- **Signal review** : logique dupliquée dans `middleware.ts` avec un commentaire "Edge incompatible"
|
- **Signal review** : logique dupliquée dans `middleware.ts` avec un commentaire "Edge incompatible"
|
||||||
|
|
||||||
- Contexte technique : Next.js / middleware — RL799_V2 08-04-2026
|
- Contexte technique : Next.js / middleware — RL799_V2 08-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-app-router-private-folders"></a>
|
||||||
|
## App Router — dossiers `_*` exclus silencieusement du routing
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Tout segment d'URL préfixé par `_` (ex : `_e2e`, `_helpers`, `__internal`) est traité par Next.js App Router comme un *private folder* et **exclu silencieusement du routing**
|
||||||
|
- Le `route.ts` ou `page.tsx` existe sur le filesystem, le typecheck passe, mais l'URL retourne 404
|
||||||
|
- Aucune erreur au boot, aucun warning
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- "Ma route existe pourtant je vois le fichier" — `apps/api/src/app/api/__e2e/visitor-token/route.ts` qui retourne 404
|
||||||
|
- Temps de debug perdu à chercher une cause obscure
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Ne **jamais** préfixer un segment de route par `_` ou `__` même pour signaler une intention "interne / e2e / debug"
|
||||||
|
- Utiliser des noms explicites : `/api/e2e/`, `/api/internal/`, `/api/dev/` (sans underscore initial)
|
||||||
|
- Pour gater l'accès en prod : check `process.env` au début du handler (`if (process.env.E2E !== '1') return 404`)
|
||||||
|
- Référence : Next.js docs — Project Structure → Private folders. Convention héritée de l'écosystème React/Webpack pour exclure les dossiers de la résolution
|
||||||
|
|
||||||
|
- Contexte technique : Next.js App Router — RL799_V2 30-04-2026
|
||||||
|
|||||||
@@ -441,3 +441,181 @@ Checklist minimale après `prisma migrate resolve --applied` :
|
|||||||
- Ajouter des tests ciblés sur payload partiel et concurrence logique.
|
- Ajouter des tests ciblés sur payload partiel et concurrence logique.
|
||||||
|
|
||||||
- Contexte technique : Prisma / partition logique — RL799_V2 09-04-2026
|
- Contexte technique : Prisma / partition logique — RL799_V2 09-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-capture-pre-updatemany-race-window"></a>
|
||||||
|
## Capture pré-`updateMany` sans transaction — race window silencieuse
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- `findUnique` + `updateMany` non atomiques : entre les deux, un autre process peut modifier le champ capturé. L'audit log ment (enregistre une `previousValue` qui n'était plus la valeur courante au moment de l'écriture). Notif envoyée au mauvais target.
|
||||||
|
- Si l'`updateMany` ne filtre que sur `status` sans inclure la valeur attendue, il peut écraser une nouvelle valeur sans erreur
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Race window entre les deux requêtes
|
||||||
|
const before = await prisma.entity.findUnique({ where: { id }, select: { x: true } });
|
||||||
|
// … un autre process modifie entity.x ici …
|
||||||
|
const after = await prisma.entity.updateMany({
|
||||||
|
where: { id, status: 'X' },
|
||||||
|
data: { status: 'Y', x: null },
|
||||||
|
});
|
||||||
|
audit.log({ previousX: before.x }); // ← MENT
|
||||||
|
notify(before.x); // ← mauvais target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
**Solution 1 — `SELECT ... FOR UPDATE` dans une transaction** (cf. `pattern-revocation-atomique-etat-transversal` dans `patterns/prisma.md`) :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let previousValue: T | null = null;
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const locked = await tx.$queryRaw<Array<{ x: T }>>`
|
||||||
|
SELECT x FROM "entities" WHERE id = ${id} FOR UPDATE
|
||||||
|
`;
|
||||||
|
if (locked.length === 0) return;
|
||||||
|
previousValue = locked[0].x;
|
||||||
|
await tx.entity.updateMany({ where: { id, ... }, data: { ... } });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution 2 — `WHERE` qui inclut la valeur attendue** (CAS-light, sans transaction) :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const updated = await prisma.entity.updateMany({
|
||||||
|
where: { id, status: 'X', x: expectedX }, // ← guard sur la valeur
|
||||||
|
data: { ... },
|
||||||
|
});
|
||||||
|
if (updated.count === 0) {
|
||||||
|
// soit déjà transitionné, soit x a changé — relire et décider
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Détecteur mental
|
||||||
|
|
||||||
|
Si tu écris :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const before = await prisma.X.findUnique(...);
|
||||||
|
await prisma.X.updateMany(...);
|
||||||
|
// … tu utilises before.<champ> dans l'audit ou la notif
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Stop**. Tu as une race. Soit `before.<champ>` n'a pas changé entre les deux (et alors pourquoi le capturer ?), soit il a pu changer (et tu mens).
|
||||||
|
|
||||||
|
- Contexte technique : Prisma / concurrence — RL799_V2 27-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-slugs-metier-user-id"></a>
|
||||||
|
## Slugs métier comme `User.id` — schémas Zod laxistes obligés
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un `User.id` en `String` libre (slug lisible côté seed, UUID `@default(uuid())` côté invitations) empêche toute rigidification Zod `.uuid()` sur les champs `userId` côté API
|
||||||
|
- Couplage tests/seed invisible : des dizaines de tests hardcodent `'membre-m05'` côté input, sans contrat explicite. Tout renommage du seed casse la suite en cascade sans warning compilateur
|
||||||
|
- Drift silencieux : deux populations d'ids coexistent en base, validation impossible à uniformiser
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `prisma.user.create({ data: { id: '<texte-lisible>', ... } })` dans un fichier de seed
|
||||||
|
- Schéma Zod avec `z.string().min(1).max(128)` là où on voudrait `z.string().uuid()`
|
||||||
|
- Test qui référence `userId: 'membre-m05'` en argument d'une requête API
|
||||||
|
- Commentaire "l'ID n'est pas forcément un UUID, on accepte toute chaîne" → dette déguisée
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Décider tôt : soit `@default(uuid())` côté Prisma partout, soit IDs structurés documentés avec une regex stricte (`^[a-z]+-[a-z0-9]+$`) publiée dans un helper shared (`isValidUserId`)
|
||||||
|
- **Ne jamais mélanger** : si le seed utilise des slugs et les comptes produits utilisent des UUIDs, les schémas Zod sont condamnés à être laxistes
|
||||||
|
- Migration : utiliser un UUID v5 déterministe (`seedUserId(slug)`) — cf. `pattern-uuid-v5-deterministe-seed` dans `patterns/prisma.md`
|
||||||
|
- Test d'invariant post-seed obligatoire (cf. pattern dédié)
|
||||||
|
- Si migration en cours de route : prévoir un script qui propage sur **toutes** les FKs (`audit_logs.user_id`, `notifications.recipient_id`, `refresh_tokens.user_id`, etc.)
|
||||||
|
|
||||||
|
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-prisma-where-relation-every-vide"></a>
|
||||||
|
## `where: { relation: { every: ... } }` trivialement vrai sur relation vide
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- La clause `every` sur une relation est **trivialement vraie** quand la relation est vide. Sans coupler avec `some: {}`, on capture aussi les rows qui n'ont aucune entrée liée — risque de purge à tort sur les nouvelles entités
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Faux : capture aussi les profiles SANS aucune VR liée
|
||||||
|
const orphans = await prisma.visitorProfile.findMany({
|
||||||
|
where: {
|
||||||
|
lastSeenAt: { lt: cutoff },
|
||||||
|
registrations: { every: { status: 'rejected' } }, // vacuously true si pas de VR
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Test "purge orphelins après 30 j" qui supprime un profile fraîchement créé
|
||||||
|
- Tests qui passent sur des fixtures avec relations existantes mais cassent dès qu'une entité sans relation est créée
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct : exige au moins une VR liée ET toutes rejected
|
||||||
|
where: {
|
||||||
|
lastSeenAt: { lt: cutoff },
|
||||||
|
registrations: {
|
||||||
|
some: {}, // au moins une VR existe
|
||||||
|
every: { status: 'rejected' }, // toutes rejected
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle générale** : à chaque fois qu'on cherche « toutes les X de Y sont Z », vérifier si Y peut avoir 0 X. Si oui, ajouter `some: {}` pour exclure le cas vide.
|
||||||
|
|
||||||
|
- Contexte technique : Prisma — RL799_V2 01-05-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-resolvedburl-template-based-testing"></a>
|
||||||
|
## `resolveDbUrl()` testing template-based — préserver le DSN explicite vers la template
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un helper `resolveDbUrl()` qui force `pathname='/<projet>_test'` quand `NODE_ENV=test` écrase un DSN appelant qui pointe explicitement vers `<projet>_test_template`
|
||||||
|
- Le bootstrap template (`runPrisma(['db', 'seed'])` en sub-process avec `DB_URL=...test_template + NODE_ENV=test`) écrit dans la mauvaise DB ou échoue avec "database does not exist"
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `bootstrapTemplate échec "pnpm prisma db seed" (exit 1)`
|
||||||
|
- Tests vitest échouent ensuite avec `Database <projet>_test does not exist on the database server`
|
||||||
|
- Sub-process de seed qui logge un DSN différent de celui passé en `env`
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const resolveDbUrl = (): string | undefined => {
|
||||||
|
const url = process.env.DB_URL;
|
||||||
|
if (!url) return url;
|
||||||
|
if (process.env.NODE_ENV !== 'test') return url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
// Exception : préserver le DSN si déjà sur la template
|
||||||
|
// (cas bootstrap migrate/seed, sinon le seed pointe sur <projet>_test inexistante)
|
||||||
|
if (parsed.pathname === '/<projet>_test_template') {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
parsed.pathname = '/<projet>_test';
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle générale** : toute stratégie template-based doit auditer le chemin du `DB_URL` à travers les sub-processes de bootstrap. Le bootstrap ouvre une connexion sur la template, mais le seed transitif exécuté via un sub-process peut être sujet à des transformations agressives du DSN qui le redirigent ailleurs.
|
||||||
|
|
||||||
|
- Contexte technique : Prisma / template database / Vitest — RL799_V2 01-05-2026
|
||||||
|
|||||||
123
knowledge/backend/risques/tests.md
Normal file
123
knowledge/backend/risques/tests.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
title: Backend — Risques & vigilance : Tests
|
||||||
|
domain: backend
|
||||||
|
bucket: risques
|
||||||
|
tags: [tests, vitest, isolation, env-vars, flakiness]
|
||||||
|
applies_to: [analysis, implementation, review, debug]
|
||||||
|
severity: high
|
||||||
|
validated_on: 2026-05-02
|
||||||
|
source_projects: [RL799_V2]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend — Risques & vigilance : Tests
|
||||||
|
|
||||||
|
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/risques/README.md` pour l'index complet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-vi-stubenv-sans-restauration"></a>
|
||||||
|
## `vi.stubEnv` sans restauration — fuite env vars inter-fichiers
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un test qui stub une env var dans `beforeAll` sans `vi.unstubAllEnvs()` en `afterAll` affecte silencieusement tous les fichiers exécutés après lui dans le même process
|
||||||
|
- En séquentiel (`maxWorkers: 1`), l'ordre est déterministe et la fuite est invisible — la suite passe au vert
|
||||||
|
- En passant à `maxWorkers > 1`, les env vars stubbées sont partagées entre workers → tests imprévisibles
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Tests qui passent en isolation mais échouent dans la suite complète, ou inversement
|
||||||
|
- Comportement d'un endpoint qui dépend d'une env définie dans un fichier qui n'a rien à voir
|
||||||
|
- Migration de `maxWorkers: 1` vers `maxWorkers: 4` qui rouge la suite d'un coup
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubEnv('RESEND_API_KEY', 'test-key');
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Variante encore plus robuste (isolation parfaite par test) :
|
||||||
|
beforeEach(() => { vi.stubEnv('X', 'y'); });
|
||||||
|
afterEach(() => { vi.unstubAllEnvs(); });
|
||||||
|
```
|
||||||
|
|
||||||
|
- Détection : `rg "vi\.stubEnv\(" __tests__ | wc -l` doit être ≤ `rg "vi\.unstubAllEnvs\(\)" __tests__ | wc -l` regroupé par fichier
|
||||||
|
- Avant toute migration vers `maxWorkers > 1` : sweep complet `stubEnv` / `unstubAllEnvs`
|
||||||
|
|
||||||
|
- Contexte technique : Vitest — RL799_V2 24-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-maxworkers-1-masque-isolation"></a>
|
||||||
|
## `maxWorkers: 1` masque les problèmes d'isolation
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- L'exécution séquentielle cache systématiquement tous les bugs d'isolation : `vi.stubEnv` non restaurée, mutations de seed non restaurées, `deleteMany` direct, compteurs globaux non resets
|
||||||
|
- La CI actuelle ne peut pas détecter ces problèmes → faux sentiment de sécurité
|
||||||
|
- Le jour où on veut paralléliser pour gagner du temps, on découvre une dizaine de bugs d'isolation simultanément
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- CI verte en `maxWorkers: 1`, rouge dès `maxWorkers > 1`
|
||||||
|
- Tests "verts depuis 6 mois" qui rougent soudainement après un changement de config
|
||||||
|
- Pas de détection possible avant le passage en parallèle
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Tester la parallélisation tôt, même si la suite est petite — passer `maxWorkers: 2` force l'équipe à écrire des tests isolés
|
||||||
|
- Si on hérite d'un projet en `maxWorkers: 1`, ne pas migrer d'un coup. Audit ciblé d'abord :
|
||||||
|
- `grep "vi\.stubEnv\(" / "vi\.unstubAllEnvs\(" / "deleteMany\(" / "TEST_USER\."` pour repérer les patterns suspects
|
||||||
|
- Ajouter un audit "hidden_by_serial_execution" en review de tests : lister les patterns qui marcheraient aujourd'hui mais casseraient en parallèle
|
||||||
|
- Heuristique : projet > 200 tests → impératif de passer en parallèle (sinon CI > 15 min = friction dev majeure)
|
||||||
|
|
||||||
|
- Contexte technique : Vitest — RL799_V2 24-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-flakiness-inter-fichiers-db-partagee"></a>
|
||||||
|
## Flakiness inter-fichiers vitest avec DB partagée
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un fichier de tests laisse des artefacts résiduels en DB que le fichier suivant ne s'attend pas à voir : audits orphelins, notifications, entries seed mutées, rate-limiters non resets
|
||||||
|
- Le pattern se "résout" au 2e run par chance (le `beforeEach` finit par nettoyer par effet de bord), donnant une fausse confiance
|
||||||
|
- En CI, un retry automatique masque la vraie cause
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- 2-4 tests rouges au 1er run, vert au 2e run sans aucune modification
|
||||||
|
- `vitest run <fichier-isolé>` vert, suite complète rouge
|
||||||
|
- Compteurs `count({ type: 'X' })` qui tombent sur des résidus d'anciens tests
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
**Diagnostic** :
|
||||||
|
```bash
|
||||||
|
# 1. Run isolé sur le fichier suspect
|
||||||
|
pnpm -C apps/api test mon-fichier
|
||||||
|
# 2. 2 runs consécutifs de la suite complète
|
||||||
|
pnpm -C apps/api test && pnpm -C apps/api test
|
||||||
|
# Si 1er rouge / 2e vert → flakiness inter-fichiers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stratégies par horizon** :
|
||||||
|
- **Court terme** : accepter comme dette connue si le 2e run est stable, documenter dans le commit message
|
||||||
|
- **Moyen terme** : identifier le fichier qui pollue, ajouter le cleanup manquant dans son `afterEach`
|
||||||
|
- **Long terme** : DB-per-worker ou `transactions + rollback` (chantier d'infra dédié, voir `knowledge/backend/patterns/tests.md` pattern template database)
|
||||||
|
|
||||||
|
**À ne PAS faire** :
|
||||||
|
- Ajouter des `setTimeout` pour "attendre que ça se stabilise"
|
||||||
|
- Wrapper les assertions dans des try/catch silencieux
|
||||||
|
- Marquer les tests `.skip`
|
||||||
|
|
||||||
|
**Heuristique gravité** :
|
||||||
|
- 1 fail intermittent toutes les 5 runs : acceptable temporairement
|
||||||
|
- 1+ fail systématique au 1er run, vert au 2e : à diagnostiquer mais pas urgent
|
||||||
|
- Fails aléatoires différents à chaque run : urgent (state corruption)
|
||||||
|
|
||||||
|
- Contexte technique : Vitest / Prisma — RL799_V2 25-04-2026
|
||||||
@@ -8,9 +8,10 @@ Avant toute proposition frontend, identifie le fichier dont le nom et la descrip
|
|||||||
|
|
||||||
| Fichier | Domaine | Entrées clés |
|
| Fichier | Domaine | Entrées clés |
|
||||||
|---------|---------|--------------|
|
|---------|---------|--------------|
|
||||||
| `state.md` | State management, UI states, Zustand, listes paginées | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère |
|
| `state.md` | State management, UI states, Zustand, listes paginées, refactor monolithe Vue | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère, refactor monolithe Vue sous-lots Go/No-Go, convention `pages/<module>/`, `styles.css` partagé non-scoped, annuaire client-side TTL |
|
||||||
| `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité |
|
| `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité, AppInput Outlined Material thème dark, fusion DRY composants jumeaux par prop discriminante |
|
||||||
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique |
|
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique, factorisation page mode dynamique via `meta.mode` typé |
|
||||||
| `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes |
|
| `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes |
|
||||||
| `nextjs.md` | Next.js App Router, embeds, ESLint | Click-to-load embeds tiers, ESLint flat config Next.js |
|
| `nextjs.md` | Next.js App Router, embeds, ESLint | Click-to-load embeds tiers, ESLint flat config Next.js |
|
||||||
| `tests.md` | Tests styles React Native, Jest node env | Tests de styles sans renderer JSX |
|
| `tests.md` | Tests styles React Native, smoke checks, mount + mock composable | Tests de styles sans renderer JSX, smoke checks `readFileSync`, classe CSS modifier vs texte, cleanup E2E best-effort, helpers SW purs, mount + mock composable, assertions React Email |
|
||||||
|
| `general.md` | Focus visible, inputs date HTML5, journaux/audit logs, pages admin | Focus visible interne pour overflow clip, restyle global `<input type="date">`, UI patterns journaux d'audit, structuration pages admin (eyebrows + grille filtres + variante danger) |
|
||||||
|
|||||||
@@ -128,3 +128,166 @@ setItems((prev) => [...prev, created]); // pas de router.refresh()
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Pour les entités avec relations :** utiliser un helper `findItemById(tenantId, id)` appelé après la mutation pour retourner la forme complète avec les relations résolues.
|
**Pour les entités avec relations :** utiliser un helper `findItemById(tenantId, id)` appelé après la mutation pour retourner la forme complète avec les relations résolues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-app-input-outlined-material-dark"></a>
|
||||||
|
## Pattern : AppInput Outlined Material adapté thème dark
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : homogénéiser tous les inputs de l'app avec un design "outlined Material" adapté à un thème dark custom (label flottant, encoche opaque calée sur la card parente).
|
||||||
|
- **Contexte** : projet Vue/React avec un thème dark où les inputs natifs cassent le design system (couleur d'encoche, débordement Safari iOS, fond input transparent).
|
||||||
|
- **Quand l'utiliser** : design system app-wide où tous les inputs doivent suivre la même grammaire visuelle.
|
||||||
|
- **Quand l'éviter** : design strictement neutre (inputs natifs OS-style) ou framework UI déjà opinioné (Vuetify, Material UI).
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- design cohérent sur tous les formulaires (login, profil, modales)
|
||||||
|
- encoche calée sur la **card parente**, pas sur le bg global → fusion visuelle propre
|
||||||
|
- `appearance: none` + `min-width: 0` + `min-height: 48px` corrigent les inputs date Safari iOS
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- les pièges (couleur d'encoche, débordement, fond transparent) sont non-évidents et coûtent du temps à chaque itération si non documentés
|
||||||
|
- `inheritAttrs: false` + séparation manuelle class/style obligatoires pour permettre le layout grid externe sans fuite des attrs HTML
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 01-05-2026
|
||||||
|
- Contexte technique : Vue 3 Composition API — RL799_V2
|
||||||
|
|
||||||
|
### Composant central
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue?: string | number;
|
||||||
|
label: string; // requis — pas d'input sans label
|
||||||
|
type?: string;
|
||||||
|
staticLabel?: boolean; // label toujours haut (utile pour selects)
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const attrs = useAttrs();
|
||||||
|
// Séparation manuelle class/style — permet layout grid externe (--col-2)
|
||||||
|
// sans que les attrs HTML fuitent sur le wrapper
|
||||||
|
const wrapperClass = computed(() => attrs.class);
|
||||||
|
const wrapperStyle = computed(() => attrs.style);
|
||||||
|
const inputAttrs = computed(() => {
|
||||||
|
const { class: _c, style: _s, ...rest } = attrs;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label :class="['app-input', wrapperClass]" :style="wrapperStyle">
|
||||||
|
<input
|
||||||
|
:value="modelValue"
|
||||||
|
placeholder=" "
|
||||||
|
v-bind="inputAttrs"
|
||||||
|
class="app-input__control"
|
||||||
|
@input="$emit('update:modelValue', $event.target.value)"
|
||||||
|
/>
|
||||||
|
<span class="app-input__label">{{ label }}</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-input__control {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0; /* CRITIQUE Safari iOS — sinon débordement */
|
||||||
|
min-height: 48px; /* homogénéise date/datetime-local */
|
||||||
|
padding: 12px;
|
||||||
|
background: transparent; /* fusion totale avec la card parente */
|
||||||
|
border: 1px solid var(--color-border-base);
|
||||||
|
border-radius: 6px;
|
||||||
|
appearance: none; /* CRITIQUE iOS pour datetime-local */
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label flottant quand input rempli OU focus */
|
||||||
|
.app-input__control:focus + .app-input__label,
|
||||||
|
.app-input__control:not(:placeholder-shown) + .app-input__label {
|
||||||
|
top: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
/* Encoche opaque calée sur la CARD parente, pas le bg global */
|
||||||
|
background: var(--app-input-notch-bg, var(--color-surface-raised));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pièges documentés
|
||||||
|
|
||||||
|
1. **Encoche du mauvais bg** : utiliser `--color-bg-elevated` (canvas global) au lieu de `--color-surface-raised` (card) → patch coloré visible. Toujours caler sur la couleur de la card parente. Si l'input vit dans un contexte différent (modale, header), exposer `--app-input-notch-bg` en custom property pour override.
|
||||||
|
2. **Bordure traverse le label** : si le label flottant n'a pas de `background` opaque, la bordure passe derrière le texte. L'encoche n'est pas optionnelle.
|
||||||
|
3. **Fond transparent obligatoire** : si le fond de l'input est différent de la card, l'encoche révèle un patch. Solution : `background: transparent` → fusion totale.
|
||||||
|
4. **`placeholder=" "` imposé** : le sélecteur `:placeholder-shown` ne marche que si un placeholder existe. Un espace suffit, n'apparaît pas visuellement.
|
||||||
|
5. **`inheritAttrs: false` + séparation class/style** : sans ça, un parent qui pose `class="--col-2"` voit cette classe ET tous les attrs HTML atterrir sur le wrapper.
|
||||||
|
|
||||||
|
### Variantes
|
||||||
|
|
||||||
|
- **`AppSelect`** : même base, label toujours flottant haut. Chevron SVG `stroke="currentColor"` 1.5px.
|
||||||
|
- **`AppTextarea`** : label toujours flottant haut, pas de slot trailing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-fusion-dry-composants-jumeaux"></a>
|
||||||
|
## Pattern : Fusion DRY de composants jumeaux par prop discriminante
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : factoriser deux composants partageant la même UI à 80 %+ avec des contextes d'appel différents, sans extraire un 3ᵉ composant `Body` qui multiplie les fichiers et les indirections.
|
||||||
|
- **Contexte** : ex `ConvocationResponseCard` (autonome, charge ses données) + `ConvocationResponseForm` (reçoit la convocation du parent) — même UI, deux modes de consommation.
|
||||||
|
- **Quand l'utiliser** : diff entre les deux composants tient en une dizaine de `computed`/`v-if` discriminés sur **un seul flag de mode**.
|
||||||
|
- **Quand l'éviter** :
|
||||||
|
- cycles de vie ou stores différents (un consomme un store Pinia, l'autre est purement contrôlé) — le `computed` discriminant pollue tout le composant
|
||||||
|
- UI diverge à > 30 % (sections présentes dans l'un, absentes dans l'autre)
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- une seule source de vérité pour markup et styles
|
||||||
|
- tests structurels consolidés sur un seul fichier
|
||||||
|
- évolution UX synchronisée par construction
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- **anti-pattern à refuser** : extraire un 3ᵉ composant `Body` partagé entre les deux composants originaux. Multiplie les fichiers, ajoute une couche d'indirection (props drilling, events bubbling) sans réduire la complexité réelle
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 01-05-2026
|
||||||
|
- Contexte technique : Vue 3 — RL799_V2 (530 lignes dupliquées → 310 lignes uniques)
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
// Mode A : Card autonome (consomme un dataset complet)
|
||||||
|
data?: ProchaineTenueData;
|
||||||
|
// Mode B : Form simple (reçoit juste l'objet à éditer)
|
||||||
|
convocation?: ProchaineTenueConvocation;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isCardMode = computed(() => props.data !== undefined);
|
||||||
|
|
||||||
|
// Convocation dérivée selon le mode → le reste du composant manipule
|
||||||
|
// uniquement `convocation.value`, sans se soucier du mode
|
||||||
|
const convocation = computed(() => {
|
||||||
|
if (isCardMode.value) {
|
||||||
|
const primary = props.data!.primaryGrade;
|
||||||
|
return props.data!.gradeInfos[primary]?.convocation;
|
||||||
|
}
|
||||||
|
return props.convocation;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Émission typée en union — chaque mode émet son type approprié
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updated: [ProchaineTenueData | ConvocationResponseData];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critère de décision
|
||||||
|
|
||||||
|
Si le diff entre les deux composants tient en une dizaine de `computed`/`v-if` discriminés sur **un seul flag de mode**, fusionner. Si ça déborde, garder distincts.
|
||||||
|
|||||||
377
knowledge/frontend/patterns/general.md
Normal file
377
knowledge/frontend/patterns/general.md
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
# Frontend — Patterns : Général
|
||||||
|
|
||||||
|
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/patterns/README.md` pour l'index complet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-focus-visible-interne-overflow-clip"></a>
|
||||||
|
## Pattern : Focus visible interne pour champs sous overflow clip
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : préserver le focus visible des champs `<input>` / `<select>` quand l'app applique `overflow-x: clip` sur ses conteneurs (PageShell, panel, layout) — le focus outline natif est dessiné **hors** de la box du champ et se fait clipper.
|
||||||
|
- **Contexte** : app mobile-first où `overflow-x: clip` (ou `hidden`) est fréquent sur les conteneurs pour empêcher le débordement horizontal.
|
||||||
|
- **Quand l'utiliser** : tout projet avec une chaîne de parents en `overflow: hidden|clip` autour des champs de saisie.
|
||||||
|
- **Quand l'éviter** : si la chaîne de parents n'a pas d'`overflow: hidden|clip` — l'outline natif suffit.
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- aucun pixel ne sort de la box, donc aucun clip possible
|
||||||
|
- `box-shadow: inset` + `border-color` est portable Chrome/Firefox/Safari
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- `outline-offset: -2px` marche sur Chrome mais le rendu varie : Firefox/Safari peuvent ignorer selon la combinaison `outline-style`
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : CSS / mobile-first — RL799_V2
|
||||||
|
|
||||||
|
### Pattern correctif (par composant)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.my-input:focus-visible,
|
||||||
|
.my-select:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application globale au thème (recommandée pour mobile-first)
|
||||||
|
|
||||||
|
Plutôt que de répéter le pattern dans chaque composant, le pousser dans le fichier de thème global. Tous les inputs/selects/textareas en bénéficient automatiquement.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Dans theme/<theme>.css ou globals.css, hors :root */
|
||||||
|
input[type='text']:focus-visible,
|
||||||
|
input[type='search']:focus-visible,
|
||||||
|
input[type='email']:focus-visible,
|
||||||
|
input[type='tel']:focus-visible,
|
||||||
|
input[type='url']:focus-visible,
|
||||||
|
input[type='number']:focus-visible,
|
||||||
|
input[type='password']:focus-visible,
|
||||||
|
input[type='date']:focus-visible,
|
||||||
|
input[type='datetime-local']:focus-visible,
|
||||||
|
input[type='time']:focus-visible,
|
||||||
|
input[type='month']:focus-visible,
|
||||||
|
input[type='week']:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pourquoi sélecteurs d'attribut explicites et pas `input:focus-visible`
|
||||||
|
|
||||||
|
`input` couvre aussi `type='checkbox'`, `type='radio'`, `type='file'`, `type='range'`, `type='color'` qui ont leur propre design natif. La règle pourrait casser leur rendu (carrés or autour des cases à cocher, etc.). On liste explicitement les types texte/saisie.
|
||||||
|
|
||||||
|
### Conflits avec composants ayant leur propre `:focus`
|
||||||
|
|
||||||
|
Un composant qui a déjà `.my-input:focus { ... }` (sans `-visible`) gardera la priorité par spécificité (classe > tag). La règle globale ne joue que pour les composants qui n'ont rien défini → sûr à introduire en mid-projet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-restyle-input-date-sans-wrapper-js"></a>
|
||||||
|
## Pattern : Restyle global de `<input type="date">` sans wrapper JS
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : aligner les inputs date HTML5 sur l'identité visuelle du thème via une règle CSS globale, sans wrapper JS custom.
|
||||||
|
- **Contexte** : projet avec un thème dark/light custom où les inputs date natifs (icône calendrier blanche, placeholder gris OS, popover light par défaut) cassent le design.
|
||||||
|
- **Quand l'utiliser** : 80 % des cas (audit log, formulaires admin, profils) où la datepicker custom serait du sur-engineering.
|
||||||
|
- **Quand l'éviter** :
|
||||||
|
- validation custom synchrone (range, dates blackout, format spécifique)
|
||||||
|
- format d'affichage différent (DD/MM vs MM/DD vs ISO)
|
||||||
|
- intégration profonde dans un design system
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- `color-scheme` + `accent-color` donnent un popover natif cohérent gratuitement
|
||||||
|
- filtre SVG pour teinter l'icône calendrier
|
||||||
|
- zéro JavaScript, zéro bundle additionnel
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- **Firefox** : `accent-color` respecté, mais pas de pseudo-element pour customiser le placeholder
|
||||||
|
- **Safari iOS** : popover sheet OS, peu personnalisable. Acceptable car cohérent avec le reste des UI iOS natives
|
||||||
|
- filtre `filter()` à calibrer pour matcher la couleur du thème — chaque thème nécessite son tuning
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : CSS / inputs date HTML5 — RL799_V2
|
||||||
|
|
||||||
|
### Pattern minimal
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Toutes les variantes de pickers HTML5 */
|
||||||
|
input[type='date'],
|
||||||
|
input[type='datetime-local'],
|
||||||
|
input[type='time'],
|
||||||
|
input[type='month'],
|
||||||
|
input[type='week'] {
|
||||||
|
color-scheme: dark; /* ou 'light' selon le thème de l'app */
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Place-holder OS (jj/mm/aaaa) — couleur soft */
|
||||||
|
input[type='date']::-webkit-datetime-edit-fields-wrapper,
|
||||||
|
input[type='datetime-local']::-webkit-datetime-edit-fields-wrapper {
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Une fois saisie, couleur normale */
|
||||||
|
input[type='date']:not(:placeholder-shown)::-webkit-datetime-edit-fields-wrapper {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icône calendrier teintée (filtre SVG noir → couleur d'accent soft) */
|
||||||
|
input[type='date']::-webkit-calendar-picker-indicator,
|
||||||
|
input[type='datetime-local']::-webkit-calendar-picker-indicator,
|
||||||
|
input[type='time']::-webkit-calendar-picker-indicator,
|
||||||
|
input[type='month']::-webkit-calendar-picker-indicator,
|
||||||
|
input[type='week']::-webkit-calendar-picker-indicator {
|
||||||
|
/* Calibrer pour matcher la couleur du thème */
|
||||||
|
filter: invert(70%) sepia(40%) saturate(450%) hue-rotate(5deg) brightness(95%);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.85;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
|
||||||
|
- Construire un mini calendrier JS custom pour gagner 5 % d'esthétique → effort énorme (a11y clavier, focus management, mobile, edge cases), bénéfice marginal
|
||||||
|
- Hardcoder les couleurs `filter()` au lieu d'utiliser des tokens du thème
|
||||||
|
- Restyler sans `color-scheme: dark/light` → le popover natif reste en mode clair sur thème sombre
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-ui-journaux-audit-logs"></a>
|
||||||
|
## Pattern : UI pour journaux / audit logs / timelines
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : passer d'un rendu naïf en cards uniformes "acteur · code · cible (uuid) · metadata" à une lecture rapide pour l'admin en surveillance, sans toucher au backend.
|
||||||
|
- **Contexte** : tout projet finit par afficher un journal d'événements (audit, activité, historique, timeline) avec metadata variable.
|
||||||
|
- **Quand l'utiliser** : journal avec ≥ 10 types d'actions et besoin de scanner rapidement.
|
||||||
|
- **Quand l'éviter** : log technique brut destiné aux devs (un `<pre>` peut suffire).
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- 5 patterns combinables qui améliorent radicalement la scanabilité
|
||||||
|
- aucun changement backend (le DTO reste plat)
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- reset de l'état d'expansion à chaque rechargement (l'expansion est éphémère, pas une préférence durable)
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : Vue 3 / CSS — RL799_V2 (Journal d'audit admin, 45+ types d'actions)
|
||||||
|
|
||||||
|
### 1. `<optgroup>` dérivé du préfixe label
|
||||||
|
|
||||||
|
Quand l'API retourne un catalogue d'actions avec convention `Catégorie — Libellé` (ex : `Soirée — annulation`, `Tenue — création`), dériver les groupes côté front au lieu de modifier le DTO.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const groupsFromLabels = computed(() => {
|
||||||
|
const groups = new Map<string, Entry[]>();
|
||||||
|
for (const opt of catalog.value) {
|
||||||
|
const sep = opt.label.indexOf(' — ');
|
||||||
|
const category = sep > 0 ? opt.label.slice(0, sep) : 'Divers';
|
||||||
|
groups.set(category, [...(groups.get(category) ?? []), opt]);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b, 'fr', { sensitivity: 'base' }));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<select>
|
||||||
|
<option value="">Toutes les actions</option>
|
||||||
|
<optgroup v-for="[cat, opts] in groupsFromLabels" :label="cat" :key="cat">
|
||||||
|
<option v-for="o in opts" :value="o.value" :key="o.value">{{ o.label }}</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
Bénéfice : 1 seul clic pour filtrer (vs cascade 2 selects), accessible natif, zéro modif backend.
|
||||||
|
|
||||||
|
### 2. Code couleur sémantique par catégorie
|
||||||
|
|
||||||
|
Barre de couleur de 3 px à gauche de chaque card de la liste, mappée sur la catégorie de l'événement. Transforme un mur de cards uniformes en lecture instantanée.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.log-item {
|
||||||
|
border-left: 3px solid var(--log-cat-color, var(--color-border));
|
||||||
|
}
|
||||||
|
.log-item--cat-soiree { --log-cat-color: var(--color-accent-primary); }
|
||||||
|
.log-item--cat-rgpd { --log-cat-color: var(--color-accent-danger); }
|
||||||
|
```
|
||||||
|
|
||||||
|
Pourquoi pas le fond complet : trop bruyant, perd la sobriété d'un journal admin. La barre latérale signale sans crier.
|
||||||
|
|
||||||
|
### 3. UUIDs rétrogradés en monospace soft
|
||||||
|
|
||||||
|
Les UUIDs / IDs techniques affichés en plein texte cassent la lecture humaine. Les détecter via regex et les rendre en font monospace + couleur soft + taille réduite, sans les masquer (utiles pour forensics).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const UUID_RE = /[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.uuid {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Date relative + absolue en tooltip
|
||||||
|
|
||||||
|
Pour la lecture humaine, "il y a 5 min" / "hier" / "il y a 3 j" bat toujours "27 avril 2026 à 08:37". La date absolue reste accessible en `title` du `<time>` pour les forensics.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const formatRelative = (iso: string) => {
|
||||||
|
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||||
|
if (diff < 60) return 'à l\'instant';
|
||||||
|
if (diff < 3600) return `il y a ${Math.round(diff / 60)} min`;
|
||||||
|
if (diff < 86400) return `il y a ${Math.round(diff / 3600)} h`;
|
||||||
|
if (diff < 172800) return 'hier';
|
||||||
|
if (diff < 604800) return `il y a ${Math.round(diff / 86400)} j`;
|
||||||
|
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Détails techniques repliables
|
||||||
|
|
||||||
|
La metadata détaillée (clés/valeurs verboses, IDs internes, raisons null) sert à l'investigation, pas à la lecture courante. La masquer derrière un `Voir les détails / Masquer les détails`, et reset l'état d'expansion à chaque rechargement.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const expanded = ref<Set<string>>(new Set());
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
const next = new Set(expanded.value);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
expanded.value = next;
|
||||||
|
};
|
||||||
|
// Reset après chaque load
|
||||||
|
const loadList = async () => {
|
||||||
|
/* … */
|
||||||
|
expanded.value = new Set();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Le bouton "voir détails" n'apparaît **que si** la card a effectivement de la metadata. Un toggle vide pollue la grille visuelle.
|
||||||
|
|
||||||
|
### Anti-patterns à éviter
|
||||||
|
|
||||||
|
- Cards uniformes en couleur/border quel que soit le type d'événement → tue la scanabilité
|
||||||
|
- Date absolue toujours visible (`27 avril 2026 à 08:37`) sur des cards serrées → bruit cognitif inutile
|
||||||
|
- UUIDs en plein texte sans rétrogradation visuelle
|
||||||
|
- Cascade 2 selects quand un `<optgroup>` natif suffit
|
||||||
|
- Bouton "voir détails" affiché même sans détails à voir
|
||||||
|
- Persister l'expansion entre navigations / paginations → état orphelin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-structuration-pages-admin"></a>
|
||||||
|
## Pattern : Structuration de pages admin (eyebrows + grille filtres + variante danger)
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : poser une grammaire visuelle commune sur les écrans admin (filtres + liste/form + sections multiples) sans framework lourd.
|
||||||
|
- **Contexte** : module Admin avec plusieurs panels (Audit, Utilisateurs, Corbeille, etc.) qui partagent la même structure.
|
||||||
|
- **Quand l'utiliser** : ≥ 3 panels admin avec structure similaire.
|
||||||
|
- **Quand l'éviter** : page admin unique sans cohérence inter-panels à maintenir.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : Vue 3 / CSS — RL799_V2 (5 panels admin)
|
||||||
|
|
||||||
|
### 1. Eyebrows de section
|
||||||
|
|
||||||
|
Au lieu de titres H2/H3 trop forts, utiliser des "eyebrows" : mini-labels uppercase, letter-spacing élargi, taille `caption`, couleur d'accent.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p class="eyebrow">Filtres</p>
|
||||||
|
<div class="my-filters"><!-- … --></div>
|
||||||
|
|
||||||
|
<p class="eyebrow">Résultats <span class="eyebrow-count">· {{ total }} entrées</span></p>
|
||||||
|
<div class="my-list"><!-- … --></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Le compteur (`· N entrées`) est en `font-weight: normal`, sans uppercase, en `color-text-secondary` — typographie en cascade.
|
||||||
|
|
||||||
|
Bénéfice : structure visuelle sans concurrencer un éventuel titre de page. Cohérence inter-écrans dans tout un module si l'eyebrow est utilisé partout.
|
||||||
|
|
||||||
|
Quand ne pas afficher l'eyebrow `Résultats` : sur loading / error / empty state. Le `StateBlock` (ou équivalent) prend le relais.
|
||||||
|
|
||||||
|
### 2. Grille de filtres hiérarchique
|
||||||
|
|
||||||
|
Quand 3+ filtres dont l'un est dominant (typiquement une recherche), au lieu d'un flex-wrap chaotique, utiliser une grille avec le filtre primaire en pleine largeur :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="filters">
|
||||||
|
<label class="filters__label filters__label--full">Recherche
|
||||||
|
<input type="search">
|
||||||
|
</label>
|
||||||
|
<label class="filters__label">Statut <select><!-- … --></select></label>
|
||||||
|
<label class="filters__label">Rôle <select><!-- … --></select></label>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.filters__label--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Variante `danger` pour actions destructives
|
||||||
|
|
||||||
|
Sur un écran qui mélange actions constructives et destructives (ex : saisie initiale + bypass admin) :
|
||||||
|
|
||||||
|
```css
|
||||||
|
.btn--danger {
|
||||||
|
border-color: color-mix(in srgb, var(--color-danger) 48%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--danger {
|
||||||
|
border-left: 3px solid var(--color-danger);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`color-mix` produit un rouge **soft** (12-20 % saturation), pas un flash rouge violent.
|
||||||
|
|
||||||
|
**Anti-pattern** : `background: red` / `color: red` directs ou hex hardcodés (`#ef4444`). Toujours via tokens du thème.
|
||||||
|
|
||||||
|
### Anti-patterns à éviter
|
||||||
|
|
||||||
|
- Filtres en `flex-wrap` qui produisent 3 lignes asymétriques selon le viewport
|
||||||
|
- Hint général qui répète ce que l'empty state dit déjà → 1 message, 1 niveau d'info
|
||||||
|
- Confondre "titre de page" (l'onglet actif suffit souvent) et "structure de section" (eyebrows)
|
||||||
|
- Action destructive en variant primary or → danger explicite
|
||||||
@@ -198,3 +198,82 @@ Sans focus trap, le clavier peut sortir de la sheet/panel et casser l'ordre de n
|
|||||||
- Restaurer le focus au trigger à la fermeture.
|
- Restaurer le focus au trigger à la fermeture.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<a id="pattern-factorisation-page-meta-mode"></a>
|
||||||
|
## Pattern : Factorisation page mode dynamique via `route.meta.mode` typé
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : factoriser un composant Vue qui partage 95 % de sa logique entre plusieurs routes ne différant que par le wording, sans recourir à `route.name` (fragile) ou query string (manipulable).
|
||||||
|
- **Contexte** : projet Vue avec deux routes (ex : `/invitation` + `/reset-password`) qui partagent la mécanique technique stricte (validation token + saisie mot de passe + consume) et ne diffèrent que par le wording.
|
||||||
|
- **Quand l'utiliser** : factorisation justifiée par un partage > 90 % de logique entre routes.
|
||||||
|
- **Quand l'éviter** : routes qui diffèrent fonctionnellement au-delà du wording — préférer deux composants distincts.
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- explicite, déclaratif, type-checkable
|
||||||
|
- augmenter `RouteMeta` dans `vue-router` pour qu'un mode manquant produise une erreur build
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- wording centralisé en un seul `computed` — pas de `v-if` éparpillés dans le template
|
||||||
|
- endpoint dynamique en un seul `if/else` dans le handler
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : Vue 3 / vue-router — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// router/index.ts
|
||||||
|
{
|
||||||
|
path: '/invitation',
|
||||||
|
name: 'invitation',
|
||||||
|
component: ResetPasswordPage,
|
||||||
|
meta: { requiresGuest: true, mode: 'invitation' as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/reset-password',
|
||||||
|
name: 'reset-password',
|
||||||
|
component: ResetPasswordPage,
|
||||||
|
meta: { requiresGuest: true, mode: 'reset' as const },
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ResetPasswordPage.vue
|
||||||
|
const pageMode = computed<PageMode>(() => {
|
||||||
|
const meta = route.meta as { mode?: PageMode } | undefined;
|
||||||
|
return meta?.mode === 'invitation' ? 'invitation' : 'reset';
|
||||||
|
});
|
||||||
|
|
||||||
|
const wording = computed(() => {
|
||||||
|
if (pageMode.value === 'invitation') {
|
||||||
|
return { eyebrow: 'Bienvenue', title: 'Définissez votre mot de passe', /* … */ };
|
||||||
|
}
|
||||||
|
return { eyebrow: 'Réinitialisation', title: '...', /* … */ };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (pageMode.value === 'invitation') {
|
||||||
|
await consumeInvitationToken(token.value, newPassword.value);
|
||||||
|
} else {
|
||||||
|
await consumeResetToken(token.value, newPassword.value);
|
||||||
|
}
|
||||||
|
// Post-consume uniforme : redirige /login pour les deux modes
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Règles d'or
|
||||||
|
|
||||||
|
- `meta.mode` typé en literal union (`'invitation' | 'reset'`)
|
||||||
|
- A11y obligatoire : autofocus sur le champ password au mount post-validation, `<label for>` associés, `<AppMessage variant="error">` avec `role="alert"`, `<AppMessage variant="success">` avec `aria-live="polite"`
|
||||||
|
- Test obligatoire : monter la page avec chaque `meta.mode` et vérifier que le wording correspond
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
|
||||||
|
- Détecter le mode via `route.name` (fragile, couplage implicite, signal d'intention faible)
|
||||||
|
- Détecter via query string (pollue URL, manipulable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -208,3 +208,270 @@ Sans état transitoire formel, les guards lisent des valeurs incomplètes et dé
|
|||||||
- Store : `hydrateStatus` + promesse partagée en cours pour les appels concurrents.
|
- Store : `hydrateStatus` + promesse partagée en cours pour les appels concurrents.
|
||||||
- Guards : `await hydrate()` avant toute décision d'accès.
|
- Guards : `await hydrate()` avant toute décision d'accès.
|
||||||
- UI : fallback de rendu tant que l'hydratation n'est pas `ready`.
|
- UI : fallback de rendu tant que l'hydratation n'est pas `ready`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-refactor-monolithe-vue-sous-lots"></a>
|
||||||
|
## Pattern : Refactor monolithe Vue — sous-lots Go/No-Go + ordre topologique
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : découper un composant Vue monolithique (> 1500 lignes script ou > 2000 lignes total) en composables + sous-composants livrés en commits successifs validés un à un.
|
||||||
|
- **Contexte** : page Vue avec plusieurs responsabilités métier mêlées, peu ou pas de `describe`, sans sous-découpage interne.
|
||||||
|
- **Quand l'utiliser** : fichier > 1500 lignes script, fenêtre de calme sans PR concurrente prévue, tests E2E robustes en place.
|
||||||
|
- **Quand l'éviter** : page < 1000 lignes, pas de tests E2E pour servir de filet, ou planning serré sans Go/No-Go possible entre commits.
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- aucune régression à chaque sous-lot validé indépendamment
|
||||||
|
- helpers réutilisables émergent naturellement
|
||||||
|
- reporter vitest / Playwright groupe les échecs par responsabilité
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- les `data-testid` E2E doivent être **copiés-collés exactement** dans les composants enfants (sinon les E2E rotent silencieusement)
|
||||||
|
- les bindings template doivent rester alignés avec les noms destructurés du composable
|
||||||
|
- le typecheck `tsc --noEmit` ne suffit pas — utiliser `vue-tsc` (cf. `frontend/risques/state.md` risque-templates-vue-references-orphelines)
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 29-04-2026
|
||||||
|
- Contexte technique : Vue 3 / Composition API — RL799_V2 (4 pages refactorées, 17 commits, 644/644 tests verts)
|
||||||
|
|
||||||
|
### Stratégie en 3 étapes
|
||||||
|
|
||||||
|
1. **Audit complet préalable** : sections du template avec plages de lignes + rôle métier + `v-if` clé, blocs script regroupés par responsabilité avec évaluation `autonome` vs `couplé`, imports + composables + DTOs consommés, tests existants, couplages externes (deep-links, sélecteurs CSS).
|
||||||
|
2. **Plan en sous-lots ordonnés par risque croissant** :
|
||||||
|
- L1 : composables purement autonomes (zéro dépendance interne)
|
||||||
|
- L2 : composants enfants auto-contenus (modales)
|
||||||
|
- L3 : composables avec couplage modéré (cache + watchers)
|
||||||
|
- L4 : composables avec couplage métier subtil (cascade, propagation)
|
||||||
|
- L5 : composant enfant complexe (D&D, drag-handle conditionnel)
|
||||||
|
3. **Go/No-Go explicite entre chaque lot** : 1 commit thématique par lot avec validation typecheck + tests + diff montré au pair avant push.
|
||||||
|
|
||||||
|
### Ordre topologique des dépendances dans le script post-refactor
|
||||||
|
|
||||||
|
Quand plusieurs composables se consomment mutuellement, respecter strictement l'ordre topologique (la déclaration de la donnée doit précéder son usage, sous peine de TDZ) :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Data centrale de la page
|
||||||
|
const currentSoiree = ref<SoireeData | null>(null);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
// 2. Composables qui ne consomment que la data centrale
|
||||||
|
const { lifecycle, isLiveView } = useSoireeLifecycle(currentSoiree, allTenuesCancelled);
|
||||||
|
|
||||||
|
// 3. Composables qui produisent des refs consommées par d'autres
|
||||||
|
const { responsesData } = useResponseTracking(currentSoiree, error);
|
||||||
|
|
||||||
|
// 4. Composables qui consomment les refs produites
|
||||||
|
const { pastActiveGrade } = useGradeSelection(soireeTenues, responsesData);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
|
||||||
|
- ❌ Grouper plusieurs préoccupations dans un même composable juste parce qu'elles sont voisines dans le script
|
||||||
|
- ❌ Sortir un composable qui consomme directement `process.cwd()` ou un store global sans le passer en argument (couplage caché)
|
||||||
|
- ❌ Extraire le CSS scoped vers le composant enfant **avant** de vérifier que toutes les classes y sont effectivement utilisées (certaines classes vivent en CSS global)
|
||||||
|
- ❌ Sauter le grep des références orphelines avant de supprimer un bloc
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] `data-testid` copiés-collés exactement dans les composants enfants
|
||||||
|
- [ ] Bindings template alignés avec les noms destructurés
|
||||||
|
- [ ] Props/events des composants enfants alignés avec les usages
|
||||||
|
- [ ] `vue-tsc` (pas `tsc`) en vérification typecheck
|
||||||
|
- [ ] QA visuel obligatoire post-refactor (mount réel en browser)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-convention-pages-module-scope"></a>
|
||||||
|
## Pattern : Convention `pages/<module>/{composables,components,utils,__tests__}/`
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : structurer une app Vue qui dépasse 20 pages avec plusieurs domaines métier, en regroupant la logique extraite par module.
|
||||||
|
- **Contexte** : app Vue avec routing par page et logique extraite (composables, sous-composants, tests).
|
||||||
|
- **Quand l'utiliser** : page > 1000 lignes envisage le scope, > 1500 lignes le crée systématiquement.
|
||||||
|
- **Quand l'éviter** : page < 500 lignes sans logique extraite — laisser au niveau racine.
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- un module métier = un sous-dossier, navigation simplifiée
|
||||||
|
- l'alias `@/pages/<module>/<X>` rend les fichiers résilients aux déplacements
|
||||||
|
- les tests d'un module vivent avec lui (pas dans un dossier global qui mélange tout)
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- les tests scopés calculent leur `root` avec **4 niveaux** de remontée (`'../../../..'`), pas 3 — source d'erreur fréquente lors d'un déplacement
|
||||||
|
- le dossier `pages/__tests__/` global reste réservé aux tests transverses + tests des pages legacy
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 29-04-2026
|
||||||
|
- Contexte technique : Vue 3 / Vite / Vitest — RL799_V2 (5 modules scopés, 35 fichiers extraits)
|
||||||
|
|
||||||
|
### Structure type
|
||||||
|
|
||||||
|
```
|
||||||
|
pages/<module>/
|
||||||
|
├── <Module>Page.vue # page principale (carcasse + template)
|
||||||
|
├── <Autre>Page.vue # autres pages du même module si existent
|
||||||
|
├── composables/ # logique métier extraite
|
||||||
|
│ ├── use<X>.ts
|
||||||
|
│ └── use<Y>.ts
|
||||||
|
├── components/ # sous-composants .vue scopés au module
|
||||||
|
├── utils/ # helpers purs (formatters, defensive wrappers)
|
||||||
|
├── styles.css # CSS partagé non-scoped (cf. pattern dédié)
|
||||||
|
└── __tests__/ # tests scopés au module
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ce qui reste dans `pages/__tests__/` global
|
||||||
|
|
||||||
|
Trois cas légitimes uniquement :
|
||||||
|
|
||||||
|
1. **Tests transverses** qui couvrent plusieurs modules (`lifecycleUnification.test.mjs`)
|
||||||
|
2. **Tests d'infrastructure** non rattachés à un module métier (`OfflineIntegration.test.mjs` pour le SW PWA)
|
||||||
|
3. **Tests des pages encore à plat** (legacy non-encore scopées) — `LoginPage.test.mjs` reste à `pages/__tests__/` tant que `LoginPage.vue` est à `pages/`
|
||||||
|
|
||||||
|
### Calcul de `root` dans les tests scopés
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
// 4 niveaux : __tests__ → <module> → pages → src → frontend
|
||||||
|
const root = resolve(here, '../../../..');
|
||||||
|
```
|
||||||
|
|
||||||
|
Si le test crashe avec `ENOENT: no such file … '<frontend>/src/src/pages/...'`, c'est que le `root` n'a pas été ajusté.
|
||||||
|
|
||||||
|
### Imports : alias `@/` plutôt que relatif
|
||||||
|
|
||||||
|
Toujours utiliser l'alias `@/pages/<module>/<X>` plutôt que `./X` ou `../X`. Bénéfice : déplacer un fichier ne casse pas ses imports internes (juste les imports depuis l'extérieur, qu'on met à jour via sed bulk).
|
||||||
|
|
||||||
|
### Critère extraction composable vs composant
|
||||||
|
|
||||||
|
| Cas | Préférer composable | Préférer composant |
|
||||||
|
|-----|---------------------|---------------------|
|
||||||
|
| Logique pure (state + actions, pas de markup) | ✓ | |
|
||||||
|
| Modale auto-contenue, > 30 lignes template | | ✓ |
|
||||||
|
| Form > 50 lignes avec validation | | ✓ |
|
||||||
|
| Plusieurs refs/computeds entrelacés | ✓ | |
|
||||||
|
| CSS spécifique > 50 lignes | | ✓ (avec styles.css si partagé) |
|
||||||
|
|
||||||
|
Préférence générale : composables script-only quand possible (risque CSS nul, plus simple à tester).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-styles-css-module-non-scoped"></a>
|
||||||
|
## Pattern : `styles.css` partagé non-scoped pour modules avec composants extraits
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : partager des classes CSS entre la page parente et ses sous-composants extraits sans dupliquer le CSS dans chaque `<style scoped>` enfant.
|
||||||
|
- **Contexte** : refactor d'une page Vue monolithique en N composants enfants (modales, forms) qui partagent des classes communes.
|
||||||
|
- **Quand l'utiliser** : ≥ 2 composants enfants partagent des classes (modales, forms, badges du module).
|
||||||
|
- **Quand l'éviter** : composants enfants strictement indépendants, ou règle CSS utilisée nulle part ailleurs.
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- une seule définition par classe partagée
|
||||||
|
- dérive impossible (un changement profite à tous les composants du module)
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- tentation de tout sortir en non-scoped "au cas où" → refusé, pollue le namespace global
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 29-04-2026
|
||||||
|
- Contexte technique : Vue 3 / scoped CSS — RL799_V2 (152 lignes CSS migrées dans `pages/venerable/styles.css`)
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- pages/<module>/<Module>Page.vue -->
|
||||||
|
<template>
|
||||||
|
<!-- … -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Styles partagés du module : importés en non-scoped pour atteindre
|
||||||
|
les composants enfants extraits (modales/forms) qui ne peuvent
|
||||||
|
pas hériter d'un `<style scoped>` parent -->
|
||||||
|
<style src="@/pages/<module>/styles.css"></style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Classes spécifiques au layout de la page parente uniquement */
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
Les composants enfants utilisent les classes sans rien importer :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="vm-modal"> <!-- vient de styles.css -->
|
||||||
|
<h2 class="vm-modal__title">…</h2>
|
||||||
|
<button class="primary">…</button> <!-- vient du CSS global -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quoi mettre dans `styles.css`
|
||||||
|
|
||||||
|
- Classes utilisées par > 1 composant enfant du module
|
||||||
|
- Classes utilisées par la page ET par un composant enfant
|
||||||
|
- Conventions/tokens visuels propres au module
|
||||||
|
|
||||||
|
### Quoi NE PAS y mettre
|
||||||
|
|
||||||
|
- Classes utilisées uniquement dans la page parente → `<style scoped>` de la page
|
||||||
|
- Classes utilisées uniquement dans un seul composant enfant → `<style scoped>` du composant
|
||||||
|
- Classes globales (`primary`, `ghost`, etc.) qui vivent déjà dans `style.css` global
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-annuaire-client-side-ttl-refresh"></a>
|
||||||
|
## Pattern : Annuaire client-side avec TTL + refresh + `lastFetchedAt`
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : permettre un load complet en mémoire au mount avec filtre client (annuaire de N membres, catalog de produits) tout en évitant le refetch inutile entre ouvertures.
|
||||||
|
- **Contexte** : composable Vue qui charge un dataset moyen (< quelques milliers d'items) consommé par filtres client.
|
||||||
|
- **Quand l'utiliser** : modale ou page consultée fréquemment qui n'a pas besoin de pagination serveur.
|
||||||
|
- **Quand l'éviter** : dataset > 10k items (utiliser pagination/keyset), ou besoin de temps réel cross-clients (basculer SSE/WS).
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- cache TTL configurable (par défaut 5 min) → pas de refetch entre ouvertures
|
||||||
|
- `refresh()` méthode publique pour forcer après création/update
|
||||||
|
- `lastFetchedAt` exposé pour debug / UI ("annuaire mis à jour il y a X")
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- si user B crée une entrée pendant que user A a la modale ouverte, A ne voit pas l'entrée tant qu'il ne ferme/rouvre pas ou clique refresh
|
||||||
|
- TTL atténue mais ne résout pas — pour temps réel, basculer SSE/WS
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 01-05-2026
|
||||||
|
- Contexte technique : Vue 3 / Composition API — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const useEntityDirectory = (options: { ttlMs?: number } = {}) => {
|
||||||
|
const ttlMs = options.ttlMs ?? 5 * 60 * 1000;
|
||||||
|
const directory = ref<Entry[]>([]);
|
||||||
|
const lastFetchedAt = ref<Date | null>(null);
|
||||||
|
|
||||||
|
const isCacheStale = (): boolean => {
|
||||||
|
if (!lastFetchedAt.value) return true;
|
||||||
|
return Date.now() - lastFetchedAt.value.getTime() > ttlMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDirectory = async (params: { force?: boolean } = {}) => {
|
||||||
|
if (!params.force && !isCacheStale() && directory.value.length > 0) return;
|
||||||
|
const data = await api.fetchDirectory();
|
||||||
|
directory.value = data;
|
||||||
|
lastFetchedAt.value = new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => loadDirectory({ force: true });
|
||||||
|
return { directory, lastFetchedAt, loadDirectory, refresh };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|||||||
@@ -153,3 +153,368 @@ Les classes et la structure DOM changent fréquemment sans régression fonctionn
|
|||||||
- Préférer `data-testid` paramétré par identifiant métier stable.
|
- Préférer `data-testid` paramétré par identifiant métier stable.
|
||||||
- Éviter `locator.first()` si l'ordre peut muter.
|
- Éviter `locator.first()` si l'ordre peut muter.
|
||||||
- Isoler les tests mutateurs avec stratégie de remise à l'état (snapshot/restore).
|
- Isoler les tests mutateurs avec stratégie de remise à l'état (snapshot/restore).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-tests-statiques-vitest-smoke-checks"></a>
|
||||||
|
## Pattern : Tests statiques `readFileSync` annotés comme smoke checks
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : utiliser les tests `readFileSync + includes()` pour leur force réelle (présence de signaux structurels) tout en évitant la fausse confiance qu'ils donnent sur le comportement runtime.
|
||||||
|
- **Contexte** : projets Vue où le pattern `readFileSync` est largement utilisé pour vérifier la présence de testid, rôle ARIA, appel de service.
|
||||||
|
- **Quand l'utiliser** : assertions sur **présence** d'un pattern (testid, import, regex interdite). À doubler par un mount pour les comportements interactifs.
|
||||||
|
- **Quand l'éviter** : composants interactifs (focus trap, submit delegation, navigation clavier) — utiliser un test mount.
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- rapide à écrire et lire, résistant aux refactors de structure
|
||||||
|
- bon pour vérifier la présence de comportements clés sans monter
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- **ne valide PAS** que le focus trap cycle correctement, que le composant émet les bons événements, que la navigation clavier Escape ferme la modal
|
||||||
|
- **ne distingue pas** si une référence est dans le `<script>` ou le `<template>` (variable supprimée du script mais référencée dans le template → string-match passe, crash runtime)
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 21-04-2026
|
||||||
|
- Contexte technique : Vue 3 / Vitest — RL799_V2
|
||||||
|
|
||||||
|
### Annotation honnête recommandée
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Note honnête : ces assertions sont des smoke checks structurels, pas une
|
||||||
|
// vérification runtime du focus trap. Le comportement réel (Tab cycle, Escape,
|
||||||
|
// focus restauré) se valide manuellement en QA ou via un test happy-dom dédié.
|
||||||
|
test('AppDialog : pattern previousActiveElement présent', () => {
|
||||||
|
expect(content.includes('previousActiveElement')).toBeTruthy();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Règle
|
||||||
|
|
||||||
|
Si un comportement runtime est critique (focus trap, submit delegation, validation conditionnelle), écrire un test happy-dom séparé avec `@vue/test-utils`. Le smoke check ne dispense pas du test comportemental — il documente juste la présence d'un signal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-asserter-classe-css-modifier-vs-texte"></a>
|
||||||
|
## Pattern : Asserter classe CSS modifier vs texte (E2E robuste aux refactors visuels)
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : rendre les tests E2E robustes aux refactors visuels (texte → icône SVG, label v1 → label v2, i18n future).
|
||||||
|
- **Contexte** : tests E2E qui valident un état rendu d'un composant.
|
||||||
|
- **Quand l'utiliser** : composant utilisant une convention BEM stricte avec modifier sémantique (`.grade-badge--apprenti`, `.status-badge--published`).
|
||||||
|
- **Quand l'éviter** : framework CSS-in-JS qui génère des classes hashées (`_grade_x4f3z`) — la classe modifier n'est pas accessible.
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- la classe modifier porte la **sémantique** et change beaucoup moins souvent que le texte
|
||||||
|
- typiquement liée à la prop ou au state, pas au rendu
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- ne remplace pas la validation visuelle (snapshot, screenshot) si le besoin est de protéger le rendu pixel-perfect
|
||||||
|
- reste pertinent : validation de contenu utilisateur (notes, prix) où le rendu textuel **est** la spec
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 25-04-2026
|
||||||
|
- Contexte technique : Playwright / Vue 3 — RL799_V2
|
||||||
|
|
||||||
|
### Exemple
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Fragile : casse au refactor texte → SVG ou label v1 → v2
|
||||||
|
await expect(badge).toHaveText('A∴');
|
||||||
|
|
||||||
|
// ✅ Robuste : casse uniquement si le grade change ou si BEM est rompu
|
||||||
|
await expect(badge).toHaveClass(/grade-badge--apprenti/);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cas combinable
|
||||||
|
|
||||||
|
- Regex tolérante sur le texte (`/convoqu/i`) pour les cas où le texte est la seule prise (badges sans classe modifier)
|
||||||
|
- Validation visuelle (snapshot) en complément quand le rendu pixel-perfect est protégé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-cleanup-e2e-best-effort"></a>
|
||||||
|
## Pattern : Cleanup E2E best-effort (try/catch + timeout court)
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : empêcher qu'un cleanup post-test (réinitialisation d'un statut, suppression d'une fixture) fasse échouer un scénario métier qui a déjà passé.
|
||||||
|
- **Contexte** : tests Playwright avec `finally { await cleanup() }` qui font des PATCH/DELETE/POST sur l'API.
|
||||||
|
- **Quand l'utiliser** : tout cleanup post-test non critique pour les tests suivants (parce qu'on a un seed ou un autre cleanup global).
|
||||||
|
- **Quand l'éviter** : si le cleanup est critique pour l'isolation (UNIQUE constraint au prochain test) — monter le timeout à 60 s plutôt que silencer.
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- une lenteur transitoire de l'API de cleanup (audit log, notif fanout, lock DB) ne fait plus passer le test du vert au rouge
|
||||||
|
- la dette d'état après échec de cleanup est mineure (le test suivant restaure souvent ou un seed le fera)
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- setup pré-test (`beforeEach`) : un setup qui échoue **doit** faire échouer le test, sinon on teste un état inconnu
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 25-04-2026
|
||||||
|
- Contexte technique : Playwright — RL799_V2
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function restoreEntry(page: Page, snap: Snapshot): Promise<void> {
|
||||||
|
try {
|
||||||
|
await page.request.patch(`/api/.../entries/${snap.entryId}/status`, {
|
||||||
|
data: { status: snap.status },
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[spec-name] restoreEntry échoué (cleanup best-effort):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mon scénario métier', async ({ page }) => {
|
||||||
|
const snap = await snapshotEntry(page, USER_ID);
|
||||||
|
try {
|
||||||
|
// … scénario métier …
|
||||||
|
} finally {
|
||||||
|
await restoreEntry(page, snap); // best-effort
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-helpers-sw-purs-extraits"></a>
|
||||||
|
## Pattern : Helpers Service Worker purs extraits pour tests Vitest
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : permettre des tests unitaires Vitest fiables sur la logique du Service Worker, sans monter de stubs élaborés pour `self` / `registration` / `caches` / `clients`.
|
||||||
|
- **Contexte** : SW custom (mode `injectManifest` ou similaire) qui contient de la logique non triviale (parsing payload push, validation linkUrl anti open-redirect, sélection client focused).
|
||||||
|
- **Quand l'utiliser** : dès que le SW dépasse 50 lignes ou contient une regex / une condition métier.
|
||||||
|
- **Quand l'éviter** : SW trivial (juste un `precacheAndRoute(self.__WB_MANIFEST)`).
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- tests Vitest standards, pas de mock `self`/`registration`
|
||||||
|
- logique partageable avec d'autres parties du frontend (regex linkUrl partagée serveur ↔ SW ↔ store)
|
||||||
|
- le `sw.ts` final reste lisible : précache + routes + thin event handlers
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- les listeners `addEventListener('push'|...)` restent non testés unitairement → couverture par E2E + checklist DevTools manuelle
|
||||||
|
- bien isoler les imports : pas de dépendance Vue/Pinia dans `sw-helpers.ts` (le SW n'a pas d'accès au DOM)
|
||||||
|
- regex dupliquée serveur ↔ SW : extraire dans `@<scope>/shared` quand possible (cf. `pattern-regex-critique-partagee-anti-divergence` dans `backend/patterns/contracts.md`)
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : Vite + vite-plugin-pwa `injectManifest` / Vitest — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/frontend/src/sw-helpers.ts (testable pure)
|
||||||
|
const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
|
||||||
|
|
||||||
|
export const isInternalPath = (url: string): boolean =>
|
||||||
|
INTERNAL_PATH_REGEX.test(url);
|
||||||
|
|
||||||
|
export const parsePushPayload = (
|
||||||
|
eventData: { json: () => unknown } | null | undefined,
|
||||||
|
): SwPushPayload => {
|
||||||
|
try {
|
||||||
|
const raw = eventData?.json() as Record<string, unknown> | null;
|
||||||
|
if (!raw || typeof raw.title !== 'string') throw new Error('invalid');
|
||||||
|
return {
|
||||||
|
title: raw.title.slice(0, 80),
|
||||||
|
body: typeof raw.body === 'string' ? raw.body.slice(0, 160) : undefined,
|
||||||
|
linkUrl: typeof raw.linkUrl === 'string' && isInternalPath(raw.linkUrl) ? raw.linkUrl : '/',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { title: 'AppDefault', body: 'Notification', linkUrl: '/' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/frontend/src/sw.ts (thin wrappers)
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
import { isInternalPath, parsePushPayload, selectFocusedClient } from './sw-helpers';
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
const payload = parsePushPayload(event.data ?? undefined);
|
||||||
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
const focused = selectFocusedClient(clients);
|
||||||
|
if (focused) {
|
||||||
|
focused.postMessage({ type: 'push:received', payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await self.registration.showNotification(payload.title, {
|
||||||
|
body: payload.body,
|
||||||
|
data: { url: payload.linkUrl },
|
||||||
|
});
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Toute logique conditionnelle / regex / parsing extraite dans `sw-helpers.ts`
|
||||||
|
- [ ] `sw.ts` ne contient que precache, routes runtime, thin `addEventListener` qui appellent les helpers
|
||||||
|
- [ ] Helpers testés avec Vitest standard (pas de mock `self`)
|
||||||
|
- [ ] Listeners SW couverts par E2E + checklist manuelle DevTools post-build
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-test-mount-mock-composable-controllable"></a>
|
||||||
|
## Pattern : Test mount + mock composable contrôlable
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : tester un composant Vue qui consomme un composable réactif en contrôlant l'état du composable depuis le test, sans monter de fixtures lourdes.
|
||||||
|
- **Contexte** : composant `<script setup>` qui appelle `const x = useFooBar()` et utilise `x.isReady.value` / `x.action()` dans le template.
|
||||||
|
- **Quand l'utiliser** : composant interactif avec branches conditionnelles dépendant du composable (5 états du footer push, modes admin vs user).
|
||||||
|
- **Quand l'éviter** : composant trivial sans logique conditionnelle (wrapper de markup).
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages** :
|
||||||
|
- couvre la cohérence script ↔ template (un attribut renommé dans le composable casse le test)
|
||||||
|
- test indépendant de la VRAIE implémentation (pas de duplication des stubs DOM `Notification`, `serviceWorker`, etc.)
|
||||||
|
- chaque scénario UI testable isolément en mutant l'état du mock entre tests
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- le mock doit retourner exactement la même SHAPE que le composable réel — un test peut passer alors que le composable a évolué et casse en runtime
|
||||||
|
- mitigation : déclarer un type `ReturnType<typeof useFooBar>` ou `export type UseFooBar` exporté par le composable, et typer le mock dessus
|
||||||
|
- `vi.mock` est hoisté → définir l'état du mock dans `beforeEach`, pas en module-level
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 28-04-2026
|
||||||
|
- Contexte technique : Vitest + @vue/test-utils + Vue 3 — RL799_V2
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composable
|
||||||
|
export type UsePushNotifications = {
|
||||||
|
isSupported: ComputedRef<boolean>;
|
||||||
|
isSubscribed: Ref<boolean>;
|
||||||
|
subscribe: () => Promise<void>;
|
||||||
|
};
|
||||||
|
export const usePushNotifications = (): UsePushNotifications => { /* … */ };
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Test du composant
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { UsePushNotifications } from '@/composables/usePushNotifications';
|
||||||
|
|
||||||
|
let mockState: { isSupported: boolean; isSubscribed: boolean };
|
||||||
|
const subscribeSpy = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePushNotifications', () => ({
|
||||||
|
usePushNotifications: (): UsePushNotifications => ({
|
||||||
|
isSupported: computed(() => mockState.isSupported),
|
||||||
|
isSubscribed: ref(mockState.isSubscribed),
|
||||||
|
subscribe: subscribeSpy,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MyComponent from '@/components/MyComponent.vue';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
subscribeSpy.mockReset();
|
||||||
|
mockState = { isSupported: true, isSubscribed: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CTA visible quand !isSubscribed', () => {
|
||||||
|
mount(MyComponent);
|
||||||
|
expect(document.querySelector('[data-testid="cta"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Composable expose un type `Use<Name>` réutilisable dans les tests
|
||||||
|
- [ ] Mock retourne une shape conforme à ce type
|
||||||
|
- [ ] État du mock défini dans `beforeEach` (réinitialisé entre tests)
|
||||||
|
- [ ] Tests utilisent `data-testid` (pas de couplage CSS class)
|
||||||
|
- [ ] Couvre TOUS les états observables (pas que le happy path)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-assertions-html-react-email"></a>
|
||||||
|
## Pattern : Assertions sur le HTML rendu par React Email — pièges à éviter
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : écrire des tests Vitest fiables sur le HTML produit par un template React Email sans buter sur les commentaires JSX, les glyphes Unicode bruts, et les `<link rel="preload">` injectés automatiquement.
|
||||||
|
- **Contexte** : tests qui vérifient la présence ou l'absence de patterns dans le HTML rendu (côté mail Resend ou côté PDF Puppeteer en `previewOnly`).
|
||||||
|
- **Quand l'utiliser** : assertions sur le HTML rendu par `@react-email/components`.
|
||||||
|
- **Quand l'éviter** : tests qui valident uniquement la présence d'un composant React (snapshot par exemple).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 29-04-2026
|
||||||
|
- Contexte technique : Vitest / React Email — RL799_V2
|
||||||
|
|
||||||
|
### Piège 1 — Commentaires React entre fragments JSX
|
||||||
|
|
||||||
|
React Email insère `<!-- -->` entre 2 fragments `{var}` adjacents :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p>V∴M∴ <!-- -->Pierre Vénérable</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
Une regex `/V∴M∴ Pierre/` échoue. Helper utile :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const matchAroundComments = (_source: string, ...parts: string[]): RegExp => {
|
||||||
|
const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
|
return new RegExp(escaped.join('\\s*(?:<!--[^>]*-->\\s*)*'), 'i');
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.match(html, matchAroundComments(html, 'V∴M∴ ', 'Pierre Vénérable'));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Piège 2 — Caractères Unicode bruts dans les constantes shared
|
||||||
|
|
||||||
|
Une constante avec `’` (U+2019) brut est rendue **telle quelle** dans le HTML par React Email (pas d'encodage). Une regex avec `'` ASCII ou `'` échoue.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
assert.match(html, /Dans l(?:'|’|'|')attente/);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Piège 3 — `<link rel="preload">` injecté automatiquement par `<Img>`
|
||||||
|
|
||||||
|
Le composant `<Img>` de `@react-email/components` injecte `<link rel="preload" as="image" href="…">` dans le `<head>` en mode mail (pas en mode `previewOnly`). Une assertion `expect(html).not.toMatch(/<link\s+rel/i)` échoue sur le mail.
|
||||||
|
|
||||||
|
Solution : autoriser les `<link rel>` qui pointent vers `appBaseUrl` (URL maîtrisée), bloquer les autres :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const linkMatches = html.match(/<link\s+[^>]*href=["']([^"']+)["']/gi) ?? [];
|
||||||
|
for (const linkTag of linkMatches) {
|
||||||
|
const href = /href=["']([^"']+)["']/i.exec(linkTag)?.[1];
|
||||||
|
assert.ok(href.startsWith(appBaseUrl) || href.startsWith('data:'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Piège 4 — Assertions trop génériques sur des termes ambigus
|
||||||
|
|
||||||
|
Une regex `/V∴M∴/` pour vérifier "ligne signature V∴M∴ vacante absente" matche aussi le footer "Le V∴M∴ et les officiers..." qui contient le même token. Solution : cibler un pattern plus précis du contexte (la classe CSS du style `vmSignature` : `font-weight:600`).
|
||||||
|
|
||||||
|
### Garde-fou Puppeteer mode PDF
|
||||||
|
|
||||||
|
En mode `previewOnly`, le HTML doit être strictement offline-safe :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
assert.doesNotMatch(html, /<link\s+rel/i); // pas de preload (≠ mode mail)
|
||||||
|
assert.doesNotMatch(html, /<script\s+src/i);
|
||||||
|
assert.doesNotMatch(html, /<img[^>]+src=["'](?!data:)/i); // pas de http(s)
|
||||||
|
```
|
||||||
|
|||||||
@@ -319,3 +319,474 @@ Poser `role="menu"` / `role="menuitem"` implique obligatoirement :
|
|||||||
- Contexte technique : PWA / install prompt — RL799_V2 18-04-2026
|
- Contexte technique : PWA / install prompt — RL799_V2 18-04-2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<a id="risque-cache-pwa-soft-delete-fuite"></a>
|
||||||
|
## Cache offline PWA + soft-delete — invalidation diff-based scopée
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Une PWA qui cache des blobs en IndexedDB pour offline reading **ne reçoit aucun signal automatique** quand un document est soft-deleted côté serveur
|
||||||
|
- Le contenu reste lisible hors-ligne indéfiniment. Pour des données réglementaires ou sensibles, c'est un gap de sécurité non négligeable
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Document soft-deleted en base, encore consultable offline par les utilisateurs qui l'ont mis en cache
|
||||||
|
- Aucun mécanisme automatique de purge
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
**Mitigation V1 (best-effort, diff-based)** au prochain `loadEntries` online :
|
||||||
|
|
||||||
|
1. Lire la liste serveur courante (filtrée `deletedAt IS NULL`)
|
||||||
|
2. Lire les IDs cachés localement **scopés au même périmètre** (type+grade) — sinon on supprime à tort un doc d'un autre onglet
|
||||||
|
3. Diff : `cached - server = soft-deleted` → `removeCachedDocument(id)` pour chaque
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const bustCachedIfMissing = async (candidateIds: string[], serverIds: Set<string>) => {
|
||||||
|
for (const id of candidateIds) {
|
||||||
|
if (!serverIds.has(id)) await removeCachedDocument(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mitigation V2 (push server-initiated)** : Service Worker abonné à un canal (postMessage / WebSocket / SSE), serveur publie `{ type: 'document-soft-deleted', id }` sur soft-delete, SW intercepte et fait `caches.delete()` immédiat. Coût : infra push + gestion connectivité partielle. À garder en backlog si le risque devient critique (audit GDPR).
|
||||||
|
|
||||||
|
**Tests recommandés** :
|
||||||
|
- doc caché + recharge avec serveur qui omet ce doc → assert `removeCachedDocument` appelé
|
||||||
|
- doc caché + serveur qui retourne le doc → assert pas d'effet (non-régression)
|
||||||
|
- doc caché pour scope `rituels` + recharge sur scope `mementos` qui omet ce doc → assert pas d'effet (scope isolation)
|
||||||
|
|
||||||
|
- Contexte technique : PWA / IndexedDB — RL799_V2 20-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-duplication-focus-parent-modal"></a>
|
||||||
|
## Duplication parent ↔ modal de la capture de focus
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Quand un composant modal implémente correctement le pattern a11y `previousActiveElement` (capture à `onMounted`, restitution à `close`/`submit`), le composant parent **ne doit PAS** stocker un `lastTrigger` en parallèle
|
||||||
|
- Code mort avec commentaire trompeur : un lecteur qui cherche à comprendre le flux focus va s'y perdre
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Le parent a une ref `lastFabTrigger` / `lastTrigger` écrite au clic du trigger
|
||||||
|
- Elle n'est **jamais lue** — le commentaire prétend "pour restitution du focus par la modal", mais la modal a son propre mécanisme
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- **Une seule responsabilité** pour la restitution du focus : la modal
|
||||||
|
- Le parent se contente d'ouvrir la modal (`uploadModalOpen.value = true`)
|
||||||
|
- Le parent ne capture le focus QUE si la modal ne le fait pas (cas d'un overlay maison sans pattern `previousActiveElement`)
|
||||||
|
- **Repérage en code review** : `grep -n "lastTrigger\|previousActive" components/ pages/` → s'il y a des occurrences dans BOTH un parent et une modal du même flux, c'est le signal
|
||||||
|
|
||||||
|
- Contexte technique : Vue 3 / a11y modales — RL799_V2 20-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-touch-action-none-card-mobile"></a>
|
||||||
|
## `touch-action: none` sur card mobile bloque scroll vertical
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Une card mobile gérant un swipe horizontal avec `touch-action: none` capture **tout** le toucher, laissant le JS gérer le scroll vertical
|
||||||
|
- Le JS détecte scroll vs swipe via un seuil mais doit **libérer** l'événement après l'avoir analysé → un délai imperceptible s'installe, le scroll vertical devient saccadé et souvent ignoré
|
||||||
|
- L'utilisateur trouve la liste "inscrollable" quand son pouce touche directement les cards
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Poser le pouce sur une card puis scroller ne marche qu'une fois sur 20
|
||||||
|
- Scroll OK dans les marges vides à côté
|
||||||
|
- Aucune erreur console
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* AVANT — bug : scroll vertical capturé */
|
||||||
|
.member-card {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* APRÈS — scroll natif OK, swipe horizontal toujours fonctionnel */
|
||||||
|
.member-card {
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Combiné avec un handler JS qui `preventDefault` uniquement sur mouvement horizontal significatif (> 10 px) :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle** :
|
||||||
|
- Card qui gère swipe horizontal ET scroll vertical : `touch-action: pan-y` (le navigateur gère nativement le scroll vertical, seul l'axe horizontal est laissé au JS)
|
||||||
|
- Card qui ne gère QUE du pan/zoom custom (rare) : `touch-action: none` peut se justifier
|
||||||
|
- Tous les autres cas : laisser la valeur par défaut (`auto`)
|
||||||
|
|
||||||
|
**Repérage en code review** : `grep -rn "touch-action: none" components/` → chaque occurrence est suspecte.
|
||||||
|
|
||||||
|
- Contexte technique : CSS / mobile — RL799_V2 21-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-button-wrapper-card-color-inherit"></a>
|
||||||
|
## `<button>` wrapper card — toujours `color: inherit` (reset user-agent)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Quand on utilise un `<button>` comme wrapper cliquable d'une card (pattern idiomatique pour l'a11y clavier), il hérite du `color` user-agent par défaut
|
||||||
|
- Sur certains setups (Safari/dark mode notamment), ce `color` peut être bleu — le texte enfant hérite et contamine titres et paragraphes qui devraient prendre la couleur du thème
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Titre de card en bleu sur fond sombre alors que le thème prévoit de l'or
|
||||||
|
- Bug non visible en dev light mode sur Chrome — apparaît uniquement sur certains setups → difficile à reproduire
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```css
|
||||||
|
.my-card-button {
|
||||||
|
/* reset user-agent */
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
/* … */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle** : tout `<button>` qui wrappe du contenu stylé par le thème (card, liste, ligne de tableau) doit reset `color`, `font-family`, `font-size`, `border`, `background`. C'est un bootstrap user-agent minimal à prévoir dans tout design system.
|
||||||
|
|
||||||
|
**Alternative** : `<div role="button" tabindex="0">` avec `@keydown.enter/space`. Plus verbeux, mais évite les resets. Pattern valide si l'équipe est à l'aise avec les implications a11y.
|
||||||
|
|
||||||
|
- Contexte technique : CSS / a11y — RL799_V2 21-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-erreur-silencieuse-4-etats"></a>
|
||||||
|
## Erreur silencieuse = blanc indistinguable de "aucune donnée" — 4 états distincts (loading / empty / error / forbidden)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un composant qui affiche le résultat d'un fetch sans distinguer ses 4 états produit du **blanc** dans 3 cas sur 4 — l'utilisateur ne peut pas savoir si la donnée est en chargement, légitimement vide, en erreur réseau, ou refusée par RBAC
|
||||||
|
- Sur les flows critiques (rituel, opérations sensibles), un blanc silencieux est inacceptable : l'utilisateur prend des décisions sur la base de l'affichage
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Plusieurs cards d'une vue qui auto-fetchent et tombent toutes en `[]` côté front quand l'API renvoie 403 — affichage indistinguable de "vide légitime"
|
||||||
|
- Toast générique "Une erreur est survenue" sans corrélation avec un retry actionnable
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Tout composant qui fetch une ressource doit avoir **4 états distincts** dans son rendu :
|
||||||
|
|
||||||
|
1. **Loading** : skeleton, spinner, ou texte explicite (« Chargement… »)
|
||||||
|
2. **Empty (donnée légitimement vide)** : message explicite *« Aucune donnée enregistrée pour … »*
|
||||||
|
3. **Error (réseau / serveur)** : message + bouton retry. Ne jamais se contenter d'un blanc
|
||||||
|
4. **Forbidden (403)** : message explicite *« Vous n'avez pas accès à cette donnée »* + suggestion d'action (recharger / contacter admin)
|
||||||
|
|
||||||
|
Le frontend doit savoir **distinguer 403** des autres erreurs au niveau de son service HTTP, et propager l'info au composant. Ne pas traiter `!response.ok` en bloc avec un message générique.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const getXxx = async (id: string) => {
|
||||||
|
const response = await apiFetch(`/api/xxx/${id}`);
|
||||||
|
if (response.status === 403) throw new ForbiddenError(...);
|
||||||
|
if (response.status === 404) throw new NotFoundError(...);
|
||||||
|
if (!response.ok) throw new Error(await parseError(response));
|
||||||
|
return (await response.json()).data;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Le composant catche les types d'erreur et choisit le rendu approprié.
|
||||||
|
|
||||||
|
- Contexte technique : Vue 3 / fetch — RL799_V2 27-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-service-worker-non-secure-context"></a>
|
||||||
|
## Service Worker invisible en accès non-secure (HTTP via IP réseau)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Les Service Workers exigent un [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) — HTTPS strict, OU URL `http://localhost:*` ou `http://127.0.0.1:*`
|
||||||
|
- Un accès via IP réseau en HTTP (Tailscale `100.x.x.x`, LAN `192.168.x.x`) est non-secure → `navigator.serviceWorker` est `undefined` → la PWA fonctionne en mode "navigateur classique" mais sans cache offline, sans push, sans badge, sans installation
|
||||||
|
- Les tests E2E en Tailscale loupent silencieusement les régressions SW
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `navigator.serviceWorker.register('/sw.js')` lève `Cannot read properties of undefined (reading 'register')`
|
||||||
|
- DevTools > Application > Service Workers ne montre rien
|
||||||
|
- Tests "ça marche en LAN" qui ne reflètent pas la prod HTTPS
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Détection
|
||||||
|
console.log(window.isSecureContext, location.protocol, location.hostname);
|
||||||
|
// true "https:" "..." → OK
|
||||||
|
// true "http:" "localhost" → OK
|
||||||
|
// false "http:" "192.168.1.42" → SW désactivé
|
||||||
|
```
|
||||||
|
|
||||||
|
Stratégies par contexte :
|
||||||
|
|
||||||
|
1. **Test local** : utiliser `http://localhost:<port>` strict (jamais l'IP, même en LAN)
|
||||||
|
2. **Test réseau / mobile** : reverse proxy HTTPS (Caddy/Traefik avec Let's Encrypt, ou `tailscale cert` pour le magicDNS Tailscale)
|
||||||
|
3. **Préview Vite** : `vite preview --https` avec un certificat auto-signé (acceptable en dev test)
|
||||||
|
|
||||||
|
**Préventif** :
|
||||||
|
- documenter dans le README projet que le SW exige HTTPS/localhost
|
||||||
|
- en CI E2E, toujours utiliser `localhost` (le webServer Playwright tourne sur `localhost` par défaut)
|
||||||
|
- ne PAS supposer qu'un test "ça marche en LAN" reflète la prod HTTPS
|
||||||
|
|
||||||
|
- Contexte technique : PWA / Service Worker — RL799_V2 28-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-vite-pwa-bascule-strategies-runtime-caching"></a>
|
||||||
|
## Vite-plugin-pwa : bascule `generateSW` → `injectManifest` rend `runtimeCaching` inerte
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- En mode `injectManifest`, Vite PWA n'injecte PAS de runtime workbox — il bundle le `src/sw.ts` fourni tel quel
|
||||||
|
- Toute la config `workbox.*` du `vite.config.ts` (sauf `globPatterns`/`globIgnores` déplacés sous `injectManifest.*`) est ignorée silencieusement, sans warning
|
||||||
|
- Régression directe : leak de cookies API en cache, contenu sensible en cache, 404 transformés en `index.html`
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Après bascule, les routes runtime configurées dans `workbox.runtimeCaching` n'ont plus aucun effet
|
||||||
|
- Le build passe, aucune erreur visible, mais en DevTools > Application > Service Worker on ne voit pas les routes attendues
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Détection : examiner `dist/sw.js` après build — chercher des strings clés (`/api/`, `uploads`, `manifest.webmanifest`, `addEventListener`). Si elles sont absentes du SW custom, c'est qu'on a perdu la protection.
|
||||||
|
|
||||||
|
**Mitigation** : RÉIMPLÉMENTER À LA MAIN dans `src/sw.ts` toutes les routes runtime, denylist, et cleanup :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
|
||||||
|
import { registerRoute, NavigationRoute } from 'workbox-routing';
|
||||||
|
import { NetworkOnly, NetworkFirst } from 'workbox-strategies';
|
||||||
|
import { ExpirationPlugin } from 'workbox-expiration';
|
||||||
|
|
||||||
|
cleanupOutdatedCaches();
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly());
|
||||||
|
registerRoute(({ url }) => url.pathname.startsWith('/uploads/'), new NetworkOnly());
|
||||||
|
registerRoute(
|
||||||
|
({ url }) => url.pathname === '/manifest.webmanifest',
|
||||||
|
new NetworkFirst({
|
||||||
|
cacheName: 'manifest-cache',
|
||||||
|
plugins: [new ExpirationPlugin({ maxAgeSeconds: 300, maxEntries: 1 })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
registerRoute(
|
||||||
|
new NavigationRoute(async () => { /* fallback handler */ }, {
|
||||||
|
denylist: [/^\/api\//, /^\/uploads\//, /^\/manifest\.webmanifest$/],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérifications obligatoires post-bascule** (DevTools sur build/preview) :
|
||||||
|
|
||||||
|
1. Application > Service Worker : `sw.js` activé
|
||||||
|
2. Network : `POST /api/auth/login` → pas de "(from ServiceWorker)", pas de cache
|
||||||
|
3. Network : GET `/uploads/foo.pdf` → réseau direct
|
||||||
|
4. Network mode Offline : navigation `/page` → fallback `index.html` ; `/api/foo` → erreur réseau (PAS index.html)
|
||||||
|
5. Déploiement v2 : ancien cache purgé après activation
|
||||||
|
|
||||||
|
`setCatchHandler` ne suffit PAS à remplacer `navigateFallback` — il ne se déclenche que si une route enregistrée throw. Pour le navigation fallback, utiliser `NavigationRoute` explicitement.
|
||||||
|
|
||||||
|
- Contexte technique : Vite / vite-plugin-pwa / Workbox — RL799_V2 28-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-ts-strict-uint8array-buffersource"></a>
|
||||||
|
## TS strict — `Uint8Array<ArrayBufferLike>` non assignable à `BufferSource`
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- TS 5.7+ avec lib DOM récente paramètre `Uint8Array` par défaut sur `ArrayBufferLike` (qui inclut `SharedArrayBuffer`)
|
||||||
|
- Beaucoup d'APIs DOM (Push API, WebCrypto certaines surfaces) attendent un `BufferSource` strict avec `buffer: ArrayBuffer` — d'où une erreur TS au build
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
```
|
||||||
|
Type 'Uint8Array<ArrayBufferLike>' is not assignable to type 'BufferSource'.
|
||||||
|
Types of property 'buffer' are incompatible.
|
||||||
|
Type 'ArrayBufferLike' is not assignable to type 'ArrayBuffer'.
|
||||||
|
Type 'SharedArrayBuffer' is missing the following properties from type 'ArrayBuffer'…
|
||||||
|
```
|
||||||
|
|
||||||
|
L'erreur est au build, pas au runtime (le code marche en JS).
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Créer explicitement un `ArrayBuffer` strict, puis remplir via une vue `Uint8Array` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Ne compile pas en TS strict
|
||||||
|
const urlBase64ToUint8Array = (s: string): Uint8Array => {
|
||||||
|
const raw = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const out = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Compile et fonctionne identiquement
|
||||||
|
const urlBase64ToArrayBuffer = (s: string): ArrayBuffer => {
|
||||||
|
const raw = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const buf = new ArrayBuffer(raw.length);
|
||||||
|
const view = new Uint8Array(buf);
|
||||||
|
for (let i = 0; i < raw.length; i++) view[i] = raw.charCodeAt(i);
|
||||||
|
return buf;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative déconseillée** : `as ArrayBuffer` cast — masque le problème, peut rater une vraie incompatibilité si la lib DOM évolue.
|
||||||
|
|
||||||
|
- Contexte technique : TypeScript 5.x / lib DOM — RL799_V2 28-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-safe-areas-ios-viewport-fit-cover"></a>
|
||||||
|
## Safe-areas iOS — `viewport-fit=cover` indispensable
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Sur iPhone Pro/Max (notch + home indicator + Dynamic Island), `padding-top: env(safe-area-inset-top)` ou `padding-bottom: env(safe-area-inset-bottom)` retourne **0** sans `<meta viewport content="… viewport-fit=cover">`
|
||||||
|
- Le développeur conclut à tort que le pattern ne fonctionne pas, alors qu'il manque juste l'opt-in
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Header fixed rogné par le notch
|
||||||
|
- Nav bottom rognée par le home indicator
|
||||||
|
- `env(safe-area-inset-*)` retourne 0 sur iPhone Pro+
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
**Pattern complet (3 endroits) à appliquer ensemble** :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 1) index.html — opt-in safe-areas -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 2) Header sticky : top + safe-area-inset-top */
|
||||||
|
.app-header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
height: calc(var(--size-header-height) + env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3) Nav bottom : bottom + safe-area-inset-bottom + safe-area-inset-left/right */
|
||||||
|
.app-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
height: calc(var(--size-nav-height) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAB / menu flottant : bottom au-dessus de la nav + safe-area + offset */
|
||||||
|
.fab {
|
||||||
|
bottom: calc(var(--size-nav-height) + env(safe-area-inset-bottom) + 16px);
|
||||||
|
/* `max()` empêche les boutons d'être collés au bord rond du device */
|
||||||
|
right: max(16px, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle de débogage** : si `env(safe-area-inset-bottom)` semble retourner 0 sur iPhone Pro+, **vérifier `<meta viewport>` AVANT de chercher ailleurs**. C'est presque toujours la cause.
|
||||||
|
|
||||||
|
`safe-area-inset-left` et `safe-area-inset-right` ne sont non-nuls qu'en mode paysage (notch latéral). Garder le `padding-left/right` quand même → no-op en portrait, fix en paysage.
|
||||||
|
|
||||||
|
- Contexte technique : CSS / iOS — RL799_V2 02-05-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-input-date-safari-ios-min-width"></a>
|
||||||
|
## `<input type="date">` Safari iOS — `appearance: none` + `min-width: 0` + `min-height` obligatoires
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Sur Safari iOS (et Chrome iOS car webkit sous-jacent), un `<input type="date">` ou `datetime-local` :
|
||||||
|
- déborde de son conteneur sur la droite (largeur intrinsèque > 100 %)
|
||||||
|
- apparaît plus mince que les autres inputs (hauteur intrinsèque différente)
|
||||||
|
- affiche un styling natif iOS qui casse le design system
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `width: 100%` ne suffit pas — la largeur intrinsèque écrase la contrainte
|
||||||
|
- Bug non reproductible sur Chrome desktop, visible uniquement sur iPhone réel ou Safari Responsive Design Mode
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```css
|
||||||
|
input[type="date"],
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="time"] {
|
||||||
|
appearance: none; /* neutralise le styling natif */
|
||||||
|
-webkit-appearance: none; /* Safari iOS — ne PAS oublier */
|
||||||
|
min-width: 0; /* permet à width: 100% de gagner */
|
||||||
|
min-height: 48px; /* aligne avec les autres inputs */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Les 4 propriétés sont nécessaires** :
|
||||||
|
|
||||||
|
- `appearance: none` seul : le styling natif disparaît mais la largeur intrinsèque reste → débordement
|
||||||
|
- `min-width: 0` seul : le styling natif reste, on a juste cassé sa hauteur
|
||||||
|
- `min-height: 48px` : nécessaire pour homogénéiser avec les inputs text classiques
|
||||||
|
- `-webkit-appearance: none` : redondant en théorie avec `appearance: none` mais nécessaire en pratique sur certaines versions Safari iOS
|
||||||
|
|
||||||
|
- Contexte technique : CSS / Safari iOS — RL799_V2 01-05-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-fieldset-legend-flex-grid"></a>
|
||||||
|
## `<fieldset>` / `<legend>` cassent un layout flex inline
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Pour un champ "label + valeur inline" (ex : `Grade [GradeBadge]` sur la même ligne), le réflexe sémantique est `<fieldset>` + `<legend>`
|
||||||
|
- `<legend>` a un comportement natif particulier : interrompt visuellement le `border` du fieldset, son `display` est traité spécialement, il ne se comporte pas comme un enfant flex/grid normal
|
||||||
|
- Le legend prend toute la largeur, l'input passe en dessous, impossible de les aligner sans hacks `position: absolute`
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `<fieldset style="display: flex">` avec `<legend>` + `<input>` qui ne s'alignent pas sur la même ligne
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Pour un groupe de champs **avec layout custom** (flex/grid inline), utiliser `<div>` + `<span class="label">` + le champ :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ❌ Layout cassé : legend ne se comporte pas comme un enfant flex -->
|
||||||
|
<fieldset class="field-inline">
|
||||||
|
<legend>Grade</legend>
|
||||||
|
<GradeBadge :grade="grade" />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- ✅ Layout custom OK : div + span — perte sémantique mineure
|
||||||
|
compensée par aria-labelledby si besoin -->
|
||||||
|
<div class="field-inline">
|
||||||
|
<span class="field-inline__label" id="grade-label">Grade</span>
|
||||||
|
<GradeBadge :grade="grade" aria-labelledby="grade-label" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Garder `<fieldset>/<legend>` uniquement quand on accepte le rendu natif (groupe vertical avec border + legend qui chevauche le border supérieur — pattern formulaire admin classique).
|
||||||
|
|
||||||
|
**Trade-off à assumer** : `<fieldset>` apporte une sémantique a11y (groupe de champs liés). En remplaçant par `<div>`, on peut compenser via `role="group" aria-labelledby="..."` si le besoin d'a11y est fort. Pour un simple label+badge inline, c'est rarement nécessaire.
|
||||||
|
|
||||||
|
- Contexte technique : HTML / a11y / CSS — RL799_V2 02-05-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -382,3 +382,176 @@ followingsError: string | null; // erreur de fetchFollowings
|
|||||||
|
|
||||||
- Règle : dans un store qui gère à la fois des mutations et des listes paginées, chaque opération doit avoir sa propre clé d'erreur
|
- Règle : dans un store qui gère à la fois des mutations et des listes paginées, chaque opération doit avoir sa propre clé d'erreur
|
||||||
- Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026
|
- Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-emit-vue-mutation-serveur-sans-listener"></a>
|
||||||
|
## `emit` Vue annonçant une mutation serveur sans listener parent → caches stale
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un composant enfant émet un événement (`emit('approved')`) après une mutation côté serveur, mais aucun parent n'écoute
|
||||||
|
- L'enfant met bien à jour son état local, mais les caches parents qui dérivent du même statut (badges accordion, verrous cascade, prop `previousAggregate` consommée par d'autres enfants) restent stale **indéfiniment**, jusqu'au prochain changement d'écran/route
|
||||||
|
- Bug invisible dans les tests structurels (`content.includes`) et passe inaperçu en revue parce que le code enfant est "correct"
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Tout autre affichage du même statut dans le parent ou dans des sous-composants frères reste "Publiée" alors que la planche est "Approuvée" en DB
|
||||||
|
- L'UI ne se rafraîchit qu'au prochain reload manuel ou navigation
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Repérer les emits qui annoncent une mutation serveur
|
||||||
|
grep -rn "defineEmits" apps/frontend/src/components
|
||||||
|
|
||||||
|
# Pour chaque emit trouvé, chercher s'il a au moins un listener parent
|
||||||
|
grep -rn "@<eventName>=" apps/frontend/src/pages apps/frontend/src/components
|
||||||
|
```
|
||||||
|
|
||||||
|
Zéro listener = bug latent (sauf si l'emit est purement informatif — analytics, debug).
|
||||||
|
|
||||||
|
**Règle** : tout `emit` qui annonce une **mutation persistée serveur** (création, suppression, changement de statut, validation) doit avoir au moins un listener parent qui :
|
||||||
|
|
||||||
|
1. Invalide les caches locaux dérivés de la même donnée (Map de statuts, computed, props transitives)
|
||||||
|
2. Recharge la slice agrégée si le parent passe une prop construite à partir d'un autre fetch (`previousAggregate`, dashboard data)
|
||||||
|
3. **Ne se contente PAS de l'optimistic update** de l'enfant — la source de vérité reste serveur, le cache parent doit refléter l'état serveur post-mutation
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ❌ Enfant émet, parent ignore. Le badge de l'accordion reste stale -->
|
||||||
|
<PlancheTraceeCard :tenue-id="planche.tenueId" mode="previous" />
|
||||||
|
|
||||||
|
<!-- ✅ Listener explicite qui invalide les caches dérivés -->
|
||||||
|
<PlancheTraceeCard
|
||||||
|
:tenue-id="planche.tenueId"
|
||||||
|
mode="previous"
|
||||||
|
@approved="onPlancheApproved(planche.tenueId)"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Couverture** : test de mount complet via `@vue/test-utils` qui simule l'événement et vérifie que le rendu parent change. Les tests `readFileSync + content.includes('emit')` valident que le code enfant émet, pas que le parent écoute.
|
||||||
|
|
||||||
|
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-templates-vue-references-orphelines"></a>
|
||||||
|
## Templates Vue — références orphelines invisibles à `tsc --noEmit`
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Une variable supprimée du `<script setup>` mais encore référencée dans le `<template>` ne génère pas d'erreur compile avec `tsc --noEmit` seul (Volar moins strict que `tsc` pur sur les expressions template)
|
||||||
|
- Le composant crashe **uniquement au runtime** : `Cannot read properties of undefined` ou `[Vue warn] Property "X" was accessed during render but is not defined`
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Refactor où on extrait un composable et oublie de destructurer une variable, ou on retire un import devenu (apparemment) inutilisé
|
||||||
|
- Typecheck passe, tests structurels passent, page charge mais une section ne s'affiche pas / un bouton ne fait rien
|
||||||
|
- `[Vue warn]` dans la console au mount
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
**Recommandation outillage** : migrer le `typecheck` du projet de `tsc --noEmit` vers `vue-tsc -p tsconfig.typecheck.json --noEmit`. Avec `vue-tsc`, les expressions template sont strictement typées contre le `<script setup>` exposé. Une ref orpheline → erreur de compile, pas warning runtime.
|
||||||
|
|
||||||
|
**Checklist étendue avant de marquer une extraction "done"** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pour chaque symbole supprimé/non destructuré, grep dans le template
|
||||||
|
for symbol in normalizedQuery directoryOfficeLabel; do
|
||||||
|
echo "=== $symbol ==="
|
||||||
|
# Patterns template critiques
|
||||||
|
grep -nE "v-(if|else-if|show)=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
|
||||||
|
grep -nE "\\{\\{[^}]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
|
||||||
|
grep -nE "(:|@)[a-z-]+=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**QA visuel obligatoire post-refactor** : pour tout refactor qui touche une page importante, ouvrir la page en browser dev avant de pousser :
|
||||||
|
- naviguer aux états critiques (search, modals, toggle accordion)
|
||||||
|
- vérifier la console : zéro `[Vue warn]` ou `ReferenceError`
|
||||||
|
- tester au moins un workflow complet par section refactorée
|
||||||
|
|
||||||
|
- Contexte technique : Vue 3 / Volar / `vue-tsc` — RL799_V2 29-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-symboles-orphelins-suppression-bloc"></a>
|
||||||
|
## Symboles orphelins après suppression d'un bloc — checklist grep
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Refactor où on supprime un bloc cohérent (state + computeds + handlers) en utilisant un Edit ciblé. Tout compile, tous les tests passent, mais au runtime : `ReferenceError: <symbole> is not defined` au mount
|
||||||
|
- Le symbole supprimé était encore référencé dans un **lifecycle hook** (`onMounted`, `onUnmounted`), un **watcher**, ou un **handler asynchrone** non inclus dans le bloc supprimé
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Page qui ne mount plus après refactor, alors que typecheck OK + tests verts
|
||||||
|
- Erreur visible uniquement à `cmd+R` sur la page
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pour chaque symbole déclaré dans le bloc à supprimer
|
||||||
|
for symbol in updatePointerType pointerMql closeInsertMenuOnOutsideClick; do
|
||||||
|
echo "=== $symbol ==="
|
||||||
|
grep -n "\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Doit retourner **uniquement les lignes du bloc à supprimer**. Si une référence apparaît hors du bloc → ne pas supprimer sans traiter explicitement (déplacer, adapter, ou laisser).
|
||||||
|
|
||||||
|
**Lieux à vérifier en priorité** :
|
||||||
|
|
||||||
|
| Lieu | Fréquence du piège |
|
||||||
|
|------|--------------------|
|
||||||
|
| `onMounted(() => { ... })` | ⚠️⚠️⚠️ très fréquent |
|
||||||
|
| `onUnmounted(() => { ... })` | ⚠️⚠️⚠️ très fréquent (cleanup) |
|
||||||
|
| `watch(() => x, () => { fn() })` | ⚠️⚠️ fréquent |
|
||||||
|
| Handler async (`.then(() => fn())`) | ⚠️ rare mais existe |
|
||||||
|
| Computed dans une autre section du fichier | ⚠️ rare |
|
||||||
|
| Template (`@click`, `:disabled`) | typecheck attrape via SFC plugin |
|
||||||
|
|
||||||
|
**Garde-fou complémentaire** : QA visuel obligatoire post-refactor (cf. `risque-templates-vue-references-orphelines`).
|
||||||
|
|
||||||
|
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-extraction-vue-ts-bug-typage-latent"></a>
|
||||||
|
## Extraction `.vue` → composable `.ts` révèle des bugs de typage latents
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Vue 3 + Volar compile les blocs `<script setup>` avec une stratégie d'inférence moins agressive que `tsc` strict pur. Un handler peut compiler dans le `.vue` si le runtime n'utilise pas le champ manquant
|
||||||
|
- Le composable extrait est compilé par `tsc -p tsconfig.typecheck.json` **hors contexte Vue** → toutes les règles strictes s'appliquent → la divergence de type devient une erreur bloquante
|
||||||
|
- C'est un effet de bord **positif** mais qui peut bloquer l'extraction tant qu'on n'a pas diagnostiqué la divergence
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
```
|
||||||
|
Type 'X' is not assignable to type 'Y'.
|
||||||
|
The types of '<champ>.<sous-champ>' are incompatible…
|
||||||
|
Type 'A' is missing the following properties from type 'B': …
|
||||||
|
```
|
||||||
|
|
||||||
|
Cas typique : un emit Vue annonçait `Detail` (type pour la liste) alors que la fonction service renvoyait `Data` (type pour la mutation). Silencieux dans le `.vue` d'origine, devenu visible dans le `.ts` extrait.
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Trois cas typiques quand l'erreur apparaît après extraction :
|
||||||
|
|
||||||
|
1. **Le type annoncé ne matche pas le type réellement émis** : aligner sur le type réellement émis plutôt que sur le type annoncé dans `defineEmits`. Si possible, corriger aussi la signature de l'émetteur — mais c'est un autre scope
|
||||||
|
2. **Le `.vue` exploitait un cast implicite** : `v-if="x.foo"` réduit le union type. Dans un `.ts` extrait, narrow explicitement avec un `if` ou un type guard
|
||||||
|
3. **Volar n'analysait pas un chemin de type complexe** : type récursif, génériques imbriqués, `Pick<...>` dans une union → extraire un alias intermédiaire propre dans `@<module>/types.ts`
|
||||||
|
|
||||||
|
**Quoi faire face à l'erreur** :
|
||||||
|
|
||||||
|
1. **NE PAS** mettre `as Foo` pour faire taire le compilateur — c'est probablement masquer le même bug sous un autre nom
|
||||||
|
2. Identifier lequel des deux types est correct (généralement celui que la fonction service / l'API renvoie réellement)
|
||||||
|
3. Aligner la signature du handler/composable sur ce type-là
|
||||||
|
4. Documenter dans un commentaire au-dessus du handler que le type émis diverge du type annoncé dans `defineEmits` (si on ne corrige pas l'émetteur dans le même refactor)
|
||||||
|
5. Ouvrir un TODO si la correction de l'émetteur est hors scope
|
||||||
|
|
||||||
|
**Recommandation outillage** : `vue-tsc` plutôt que `tsc` pur en typecheck (cf. `risque-templates-vue-references-orphelines`). Ce genre de divergence aurait été détecté **avant** le refactor.
|
||||||
|
|
||||||
|
- Contexte technique : Vue 3 / Volar / `vue-tsc` — RL799_V2 29-04-2026
|
||||||
|
|||||||
@@ -149,6 +149,22 @@ source_projects: [app-alexandrie, app-template-resto, RL799_V2]
|
|||||||
|
|
||||||
- Contexte technique : Vue 3 / node:test — RL799_V2 02-04-2026
|
- Contexte technique : Vue 3 / node:test — RL799_V2 02-04-2026
|
||||||
|
|
||||||
|
### Cas additionnel : obsolescence silencieuse après refacto structurel
|
||||||
|
|
||||||
|
Au-delà du faux garde-fou de non-régression, un test en `readFileSync(path) + content.includes(...)` devient obsolète sans alarme dès qu'une réorganisation structurelle déplace le code visé. Trois variantes vécues :
|
||||||
|
|
||||||
|
1. **Fichier déplacé par scoping** (ex: `pages/X.vue` → `pages/<module>/X.vue`) → `ENOENT` au runtime, le test crashe au lieu de signaler une régression métier
|
||||||
|
2. **Logique extraite dans un composable / sous-composant** → la chaîne attendue ne vit plus dans le `.vue` mais dans `composables/use<X>.ts` ; le `.vue` existe encore mais ne contient plus le pattern, donc le test échoue sur une assertion sans rapport avec la vraie cause
|
||||||
|
3. **Variable supprimée du `<script setup>` mais conservée dans le template** → string-match passe (le template contient toujours la string), crash JS au mount du composant
|
||||||
|
|
||||||
|
**Mitigations spécifiques** :
|
||||||
|
|
||||||
|
- Centraliser le `path` du fichier visé dans une constante en tête de fichier de test (pas `resolve(...)` inline) — facilite le rerouting en cas de refacto
|
||||||
|
- Lors d'une extraction de logique dans un composable / sous-composant, grep les tests structurels qui pointaient le fichier d'origine et les rediriger vers le nouveau chemin
|
||||||
|
- Pour les composants interactifs (formulaires, modales, listes avec actions), compléter le string-match par au moins un test de mount via `@vue/test-utils` qui vérifie le render sans crash — c'est le seul moyen de valider la cohérence script ↔ template
|
||||||
|
|
||||||
|
- Contexte technique : Vue 3 / vitest — RL799_V2 30-04-2026 (3 cas observés sur la même session)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<a id="risque-catch-false-test-skip-e2e"></a>
|
<a id="risque-catch-false-test-skip-e2e"></a>
|
||||||
@@ -171,3 +187,129 @@ source_projects: [app-alexandrie, app-template-resto, RL799_V2]
|
|||||||
- **Signal review** : `.catch(() => false)` suivi de `test.skip` dans un test E2E
|
- **Signal review** : `.catch(() => false)` suivi de `test.skip` dans un test E2E
|
||||||
|
|
||||||
- Contexte technique : Playwright / E2E — RL799_V2 08-04-2026
|
- Contexte technique : Playwright / E2E — RL799_V2 08-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-tests-e2e-6-causes-racines"></a>
|
||||||
|
## Tests E2E qui rotent — 6 causes-racines récurrentes
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Sur une suite E2E mature, les fails ne viennent presque jamais d'un bug applicatif : ils viennent d'un désalignement test ↔ code de prod
|
||||||
|
- Conclure à une régression métier alors que c'est du test obsolète fait perdre du temps et masque les vraies régressions
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
Les 6 patterns observés sur RL799_V2 (Playwright + Vue 3 + refactors UI fréquents) :
|
||||||
|
|
||||||
|
1. **Testid changé sans MAJ tests** : `getByTestId('library-entries')` timeout, mais le composant expose `data-testid="document-list"`. Cause : refactor d'un composant qui fusionne plusieurs vues en un composant générique avec un testid neutre.
|
||||||
|
|
||||||
|
2. **Labels métier qui changent** : `await expect(badge).toHaveText('Publiée')` échoue, le badge affiche désormais 'Convoquée'. Cause : refactor lifecycle qui renomme les labels affichés sans toucher aux testids structurels.
|
||||||
|
|
||||||
|
3. **Menus / dropdowns conditionnels** : `getByTestId('odj-insert-menu').click()` timeout aléatoire — parfois le menu s'ouvre, parfois pas. Cause : UX qui adapte le flow selon l'état (1 seul type → bouton direct, plusieurs → menu).
|
||||||
|
|
||||||
|
4. **Features supprimées** : `await page.goto('/secretaire?soireeId=xxx')` charge la page mais ne sélectionne plus la soirée. Cause : query param retiré au profit d'une navigation par onglets + click sur card.
|
||||||
|
|
||||||
|
5. **Refactor visuel** : `await expect(badge).toHaveText('A∴')` échoue, le badge affiche désormais une icône SVG. Cause : refactor de représentation (texte → icône) sans toucher au testid.
|
||||||
|
|
||||||
|
6. **Cleanup post-test** : test métier passe en 2 s, mais le `finally { await restoreEntry() }` timeout à 30 s. Cause : le PATCH de cleanup tape sur une route lente (audit log, notif, validation).
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
À chaque diagnostic E2E, vérifier d'abord ces 6 hypothèses avant de conclure à une régression métier :
|
||||||
|
|
||||||
|
- **Cause 1** : grep `data-testid` dans le composant cible avant de modifier le test. Ne jamais "deviner" le testid à partir du nom de la page.
|
||||||
|
- **Cause 2** : préférer asserter sur des classes CSS modifier (`.badge--published`) ou des testids d'état (`data-testid="status-published"`) plutôt que sur du texte humain (cf. `pattern-asserter-classe-css-modifier-vs-texte` dans `frontend/patterns/tests.md`).
|
||||||
|
- **Cause 3** : guard conditionnel via `isVisible({ timeout: 1_000 }).catch(() => false)` pour gérer les deux branches.
|
||||||
|
- **Cause 4** : quand un test commence par une URL avec query param, vérifier en premier que ce param est encore consommé par la page (grep `useRoute` / `route.query` dans le composant).
|
||||||
|
- **Cause 5** : asserter la classe CSS modifier (plus stable que innerHTML qui contiendrait le SVG).
|
||||||
|
- **Cause 6** : cleanup best-effort avec timeout court (cf. `pattern-cleanup-e2e-best-effort` dans `frontend/patterns/tests.md`).
|
||||||
|
|
||||||
|
### Méta-leçon
|
||||||
|
|
||||||
|
Quand on découvre N fails E2E après une période de refactor intense :
|
||||||
|
|
||||||
|
1. Lancer la suite complète une fois pour avoir la liste exhaustive
|
||||||
|
2. Trier par cause-racine plutôt que par fichier
|
||||||
|
3. Fixer en lots cohérents (1 commit par cause-racine) plutôt qu'1 commit par fail
|
||||||
|
4. Capitaliser les patterns dès qu'ils se répètent (> 2 occurrences)
|
||||||
|
|
||||||
|
- Contexte technique : Playwright / Vue 3 — RL799_V2 25-04-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-tests-string-match-repointer-composant"></a>
|
||||||
|
## Tests `string-match .vue` — limites et compléments après extraction
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Quand on extrait une section/onglet vers un sous-composant, les assertions `readFileSync + content.includes('Ordre du jour')` échouent — la string est maintenant dans le sous-composant, pas dans la page
|
||||||
|
- Mauvaises réactions : supprimer le test (perd la garantie), `.skip()` (dette accumulée), inverser en `toBeFalsy()` (régression masquée), repointer aveuglément (peut camoufler un problème)
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
```
|
||||||
|
AssertionError: expected false to be truthy
|
||||||
|
expect(tenuesPage.includes('Ordre du jour')).toBeTruthy();
|
||||||
|
^
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
**Diagnostic** : lire l'assertion et identifier ce qu'elle garantit (présence d'un comportement métier, d'un data-testid critique, d'un ordre visuel).
|
||||||
|
|
||||||
|
**Repointer correctement** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = resolve(here, '../../../..');
|
||||||
|
|
||||||
|
// Page coquille (ce qui reste : layout, tabs, rendu conditionnel)
|
||||||
|
const tenuesPage = readFileSync(
|
||||||
|
resolve(root, 'src/pages/tenues/TenuesPage.vue'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
// Sous-composant qui incarne désormais le markup d'un onglet
|
||||||
|
const prochaineView = readFileSync(
|
||||||
|
resolve(root, 'src/pages/tenues/components/ProchaineTenueView.vue'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
// Composable qui incarne désormais la logique d'un onglet
|
||||||
|
const useProchaine = readFileSync(
|
||||||
|
resolve(root, 'src/pages/tenues/composables/useProchaineTenue.ts'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('TenuesPage utilise le modèle de vue testable pour tab/titre', () => {
|
||||||
|
expect(tenuesPage.includes('resolveTenuesTab')).toBeTruthy();
|
||||||
|
expect(useProchaine.includes('getProchaineTenueTitle')).toBeTruthy();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Renommer aussi le test si pertinent** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Avant
|
||||||
|
test('TenuesPage redirige vers une page dédiée en cas de 403...', () => { /* … */ });
|
||||||
|
|
||||||
|
// Après — le nom devient un index sémantique
|
||||||
|
test('usePastTenues redirige vers une page dédiée en cas de 403...', () => { /* … */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-pattern : tests structurels qui bougent en cascade
|
||||||
|
|
||||||
|
Si tes tests doivent être systématiquement mis à jour à chaque refactor, c'est que beaucoup de garanties sont vérifiées par string-match plutôt que par comportement. Pour les composants interactifs critiques (formulaires, listes avec actions, modales), **doubler** avec un test de mount `@vue/test-utils` qui survit aux refactors.
|
||||||
|
|
||||||
|
### Trois variantes vécues
|
||||||
|
|
||||||
|
1. **Fichier déplacé par scoping** (`pages/X.vue` → `pages/<module>/X.vue`) → `ENOENT` au runtime, le test crashe au lieu de signaler une régression métier
|
||||||
|
2. **Logique extraite dans un composable / sous-composant** → la chaîne attendue ne vit plus dans le `.vue` ; le test échoue sur une assertion sans rapport avec la vraie cause
|
||||||
|
3. **Variable supprimée du `<script setup>` mais conservée dans le template** → string-match passe (le template contient toujours la string), crash JS au mount du composant
|
||||||
|
|
||||||
|
**Mitigations spécifiques** :
|
||||||
|
|
||||||
|
- Centraliser le `path` du fichier visé dans une constante en tête de fichier de test — facilite le rerouting en cas de refacto
|
||||||
|
- Lors d'une extraction, grep les tests structurels qui pointaient le fichier d'origine et les rediriger vers le nouveau chemin
|
||||||
|
- Pour les composants interactifs, compléter par au moins un test de mount via `@vue/test-utils` qui vérifie le render sans crash
|
||||||
|
|
||||||
|
- Contexte technique : Vue 3 / Vitest — RL799_V2 29-04-2026
|
||||||
|
|||||||
11
knowledge/workflow/patterns/README.md
Normal file
11
knowledge/workflow/patterns/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Workflow — Patterns validés — Index
|
||||||
|
|
||||||
|
Patterns liés au process de développement, aux agents BMAD et au pilotage des chantiers.
|
||||||
|
|
||||||
|
Avant toute proposition workflow, identifie le fichier dont le nom et la description matchent le contexte traité, puis lis-le.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
| Fichier | Domaine | Entrées clés |
|
||||||
|
|---------|---------|--------------|
|
||||||
|
| `general.md` | Review adversarial, isolation de hunks, méthode de chantier | Review adversarial obligatoire sur chemin critique, isolation chirurgicale d'un hunk via `git apply --cached` |
|
||||||
157
knowledge/workflow/patterns/general.md
Normal file
157
knowledge/workflow/patterns/general.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
title: Workflow — Patterns : Général
|
||||||
|
domain: workflow
|
||||||
|
bucket: patterns
|
||||||
|
tags: [review, adversarial, git, chantier, agent]
|
||||||
|
applies_to: [analysis, implementation, review]
|
||||||
|
severity: high
|
||||||
|
validated_on: 2026-04-27
|
||||||
|
source_projects: [RL799_V2]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workflow — Patterns : Général
|
||||||
|
|
||||||
|
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/workflow/patterns/README.md` pour l'index complet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-review-adversarial-chemin-critique"></a>
|
||||||
|
## Pattern : Review adversarial obligatoire sur chemin critique
|
||||||
|
|
||||||
|
- Objectif : détecter avant commit les race conditions, assertions trop molles et dépendances cachées qui ne sont pas visibles à la lecture du spec mais le sont à la lecture du code écrit.
|
||||||
|
- Contexte : chantier qui touche un chemin critique (atomicité, audit, sécurité, intégrité financière, lifecycle d'entité métier) — même petit (< 100 lignes).
|
||||||
|
- Quand l'utiliser : systématique sur les chemins critiques, indépendamment de la taille du chantier.
|
||||||
|
- Quand l'éviter : refactor cosmétique, polish UX, documentation, tests d'un chemin déjà couvert.
|
||||||
|
- Avantage :
|
||||||
|
- capture les défauts post-spec (l'intention est cadrée, l'implémentation peut diverger)
|
||||||
|
- moins coûteuse à corriger avant commit qu'en post-prod (incident, debug, rollback)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- pas un substitut à la code review classique (posture différente : "qu'est-ce qui casse" vs "est-ce conforme au spec")
|
||||||
|
- pas un substitut aux tests qui passent (les tests valident ce qu'on a pensé tester)
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : workflow agent / BMAD — RL799_V2
|
||||||
|
|
||||||
|
### Identifier un chemin critique
|
||||||
|
|
||||||
|
Au moins **une** des conditions :
|
||||||
|
- mutation atomique d'une entité métier (close, cancel, lock, archive)
|
||||||
|
- émission d'audit log (la traçabilité est non-négociable)
|
||||||
|
- émission de notification (mauvais target = bug visible utilisateur)
|
||||||
|
- logique d'autorisation, RBAC, gates de permission
|
||||||
|
- calculs financiers (paiements, soldes, totaux)
|
||||||
|
- intégrité référentielle (FK, soft-delete)
|
||||||
|
- concurrence : plusieurs process peuvent toucher la même entité
|
||||||
|
|
||||||
|
### Format de review efficace
|
||||||
|
|
||||||
|
Posture : œil fâché, on cherche à casser ce qu'on vient de livrer.
|
||||||
|
|
||||||
|
Pour chaque finding :
|
||||||
|
- **Sévérité** : HIGH / MEDIUM / LOW / NIT
|
||||||
|
- **Localisation** : `file:line`
|
||||||
|
- **Problème** : ce qui peut casser, dans quel scénario
|
||||||
|
- **Mitigation possible** : 1-3 options
|
||||||
|
- **Verdict** : fix dans le scope, dette explicite, ignorer
|
||||||
|
|
||||||
|
Sortie : tableau synthétique. Si > 0 HIGH ou > 2 MEDIUM, on fixe avant commit.
|
||||||
|
|
||||||
|
### Anti-règle
|
||||||
|
|
||||||
|
- Confondre review adversarial et code review classique
|
||||||
|
- Confondre "tests verts" et "implémentation correcte"
|
||||||
|
- Lire son propre code en attendant de le valider (biais cognitif fort)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-isolation-hunk-git-apply-cached"></a>
|
||||||
|
## Pattern : Isolation chirurgicale d'un hunk via `git apply --cached <patch>`
|
||||||
|
|
||||||
|
- Objectif : commiter uniquement les hunks d'un chantier A dans un fichier également modifié par un chantier B, sans interaction `git add -p`.
|
||||||
|
- Contexte : plusieurs chantiers parallèles touchent le même fichier central (catalog audit, schema partagé, fichier d'enums).
|
||||||
|
- Quand l'utiliser : agent IA en environnement non-interactif qui prépare un commit avec sélection de hunks.
|
||||||
|
- Quand l'éviter : si on commit la totalité du fichier (`git add <file>` direct suffit), ou si les hunks sont logiquement indissociables.
|
||||||
|
- Avantage :
|
||||||
|
- non-interactif, scriptable
|
||||||
|
- le worktree garde tous les changements (mes hunks A commités + hunks B unstaged pour le chantier B)
|
||||||
|
- Limites / vigilance :
|
||||||
|
- en interactif humain, `git add -p` reste plus rapide
|
||||||
|
- le patch filtré doit rester valide (en-têtes `diff --git`, `index`, `@@` cohérents)
|
||||||
|
- Validé le : 27-04-2026
|
||||||
|
- Contexte technique : git / chantiers parallèles — RL799_V2
|
||||||
|
|
||||||
|
### Recette
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Capturer le diff du fichier mixte
|
||||||
|
git diff <file> > /tmp/mixed.patch
|
||||||
|
|
||||||
|
# 2. Éditer le patch pour ne garder que les hunks à commiter
|
||||||
|
# (un agent peut le faire via Read + Write — c'est un fichier texte)
|
||||||
|
|
||||||
|
# 3. Appliquer le patch filtré uniquement à l'index (pas au worktree)
|
||||||
|
git apply --cached /tmp/mine_only.patch
|
||||||
|
|
||||||
|
# 4. Vérifier le staged
|
||||||
|
git diff --cached <file>
|
||||||
|
|
||||||
|
# 5. Commit
|
||||||
|
git commit ...
|
||||||
|
|
||||||
|
# 6. Le worktree garde TOUS les changements
|
||||||
|
git diff <file> # affiche les hunks de l'autre chantier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Format minimal d'un hunk
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/<file> b/<file>
|
||||||
|
index <hash_old>..<hash_new> 100644
|
||||||
|
--- a/<file>
|
||||||
|
+++ b/<file>
|
||||||
|
@@ -<line>,<count> +<line>,<count> @@ <context>
|
||||||
|
<ligne contexte avant>
|
||||||
|
+<ligne ajoutée>
|
||||||
|
<ligne contexte après>
|
||||||
|
```
|
||||||
|
|
||||||
|
Les `index` et `@@` viennent du `git diff` original — pas besoin de recalculer les hashes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-sub-agents-trianguler-localisations"></a>
|
||||||
|
## Pattern : Trianguler par grep les localisations rapportées par un sub-agent
|
||||||
|
|
||||||
|
- Objectif : ne jamais agir directement sur les localisations précises rapportées par un sub-agent d'analyse — toujours vérifier avant de patcher.
|
||||||
|
- Contexte : workflow multi-agent où un sub-agent (Explore, audit BMAD, lecture de code) retourne un rapport avec findings.
|
||||||
|
- Quand l'utiliser : à la réception de tout rapport de sub-agent qui cite des fichiers, lignes, ou snippets.
|
||||||
|
- Quand l'éviter : pour les comptages globaux et tendances — fiables même sans triangulation.
|
||||||
|
- Avantage :
|
||||||
|
- évite de "corriger" du code déjà correct (pollution de commit, perte de confiance)
|
||||||
|
- distingue les findings actionables des extrapolations
|
||||||
|
- Limites / vigilance :
|
||||||
|
- ajoute ~15-20 % de temps à la phase d'action — largement compensé par les commits évités
|
||||||
|
- Validé le : 24-04-2026
|
||||||
|
- Contexte technique : workflow multi-agent / BMAD — RL799_V2
|
||||||
|
|
||||||
|
### Règle
|
||||||
|
|
||||||
|
> Ne jamais agir sur les **localisations précises** d'un sub-agent sans vérification.
|
||||||
|
> - Sub-agent dit "fichier X ligne Y a pattern Z" → `grep Z fichier X` doit confirmer
|
||||||
|
> - Sub-agent dit "fichiers A, B, C, D ont pattern Z" → grep chaque fichier
|
||||||
|
> - Si un finding s'effondre à la vérif, relire critiquement les autres findings du même sub-agent
|
||||||
|
|
||||||
|
### Ce qui reste fiable chez un sub-agent
|
||||||
|
|
||||||
|
- Comptages globaux (X fichiers sur Y ont le pattern Z — l'ordre de grandeur est généralement juste)
|
||||||
|
- Tendances (ex: "le projet a un problème de cleanup" — juste, même si les fichiers cités sont faux)
|
||||||
|
- Patterns de risque identifiés (`vi.stubEnv` sans restauration est un vrai pattern à risque)
|
||||||
|
|
||||||
|
### Ce qui est peu fiable
|
||||||
|
|
||||||
|
- Numéros de ligne précis (peuvent être hallucinés)
|
||||||
|
- Listes de fichiers exhaustives pour un pattern rare
|
||||||
|
- Snippets de code cités (peuvent être reconstruits de mémoire plutôt que lus)
|
||||||
|
|
||||||
|
### Communication au user
|
||||||
|
|
||||||
|
> *"Sub-agent X signale 4 fichiers, j'ai validé 1/4 et invalidé 3/4. Voici le plan d'action corrigé."*
|
||||||
@@ -6,4 +6,4 @@ Risques liés au process de développement, aux agents BMAD, et au tracking des
|
|||||||
|
|
||||||
| Fichier | Domaine | Entrées clés |
|
| Fichier | Domaine | Entrées clés |
|
||||||
|---------|---------|--------------|
|
|---------|---------|--------------|
|
||||||
| `story-tracking.md` | BMAD, agents, story completion | Story "completed" avec tâches ❌, story "done" sans fichiers source dans File List |
|
| `story-tracking.md` | BMAD, agents, story completion | Story "completed" avec tâches ❌, story "done" sans fichiers source dans File List, stratégie de fix d'une suite E2E qui rote en masse |
|
||||||
|
|||||||
@@ -270,3 +270,47 @@ source_projects: [app-alexandrie, app-template-resto, RL799_V2]
|
|||||||
- Contexte technique : BMAD / traçabilité adaptation story — RL799_V2 15-04-2026
|
- Contexte technique : BMAD / traçabilité adaptation story — RL799_V2 15-04-2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<a id="risque-strategie-fix-suite-e2e-en-masse"></a>
|
||||||
|
## Stratégie de fix d'une suite E2E qui rote en masse (post-refactor)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Quand un projet enchaîne plusieurs refactors UI/lifecycle sans run E2E entre chaque, on découvre souvent N fails (10-20) d'un coup
|
||||||
|
- Tentation de fixer fail-by-fail dans l'ordre où ils apparaissent — erreur : on se disperse sur N causes-racines mélangées et chaque commit n'est pas reviewable
|
||||||
|
- Sans capitalisation immédiate des patterns, on perd le bénéfice de la session pour les futurs refactors
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- 13 fails d'un coup après une période de refactor intense
|
||||||
|
- Tentative de fix fail-par-fail qui produit un commit géant difficile à reviewer
|
||||||
|
- Capitalisation reportée → patterns oubliés à la session suivante
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
**Process recommandé** :
|
||||||
|
|
||||||
|
1. **Run complet en mode list** d'abord : `playwright test --reporter=list`. On veut **la liste exhaustive** des fails, pas un comptage one-shot par fichier (qui peut masquer des fails qui apparaissent uniquement quand on lance la suite entière à cause d'effets de bord seed).
|
||||||
|
|
||||||
|
2. **Catégoriser par cause-racine** plutôt que par fichier (cf. `knowledge/frontend/risques/tests.md` — Tests E2E qui rotent — 6 causes-racines récurrentes) :
|
||||||
|
- testid changé sans MAJ tests
|
||||||
|
- label métier changé (lifecycle, statut)
|
||||||
|
- menu/dropdown conditionnel
|
||||||
|
- feature supprimée (query param, route)
|
||||||
|
- refactor visuel (texte → icône)
|
||||||
|
- cleanup post-test à rendre best-effort
|
||||||
|
|
||||||
|
3. **1 commit = 1 cause-racine**. Pas "fix 19 fails" en un commit géant. Permet :
|
||||||
|
- Review plus simple (chaque commit a un thème clair)
|
||||||
|
- Revert chirurgical si une cause-racine s'avère mal diagnostiquée
|
||||||
|
- Capitalisation : chaque commit message documente le pattern
|
||||||
|
|
||||||
|
4. **Validation entre commits** : run la suite complète après chaque commit pour savoir avant le push que le commit n'a pas régressé d'autres tests.
|
||||||
|
|
||||||
|
5. **Capitaliser les patterns à > 2 occurrences** : si on voit le même type de fail 3 fois sur 3 fichiers différents, c'est un pattern. Le poser dans `knowledge/frontend/risques/tests.md` immédiatement, pas plus tard.
|
||||||
|
|
||||||
|
**Anti-pattern à éviter** : "je fixe les 5 fails P1 d'abord, je verrai les P2 après". Si la cause-racine est la même (ex : labels lifecycle v3), on perd 30 min à re-comprendre quand on retourne sur les P2 plus tard. Mieux : grouper par cause-racine, pas par priorité.
|
||||||
|
|
||||||
|
**Métrique de référence** : 19 fails fixés en 4 commits / ~3 h dont 1 h de capitalisation. Effort par fail : ~6 min de fix + 4 min de validation.
|
||||||
|
|
||||||
|
- Contexte technique : Playwright / refactor UI — RL799_V2 25-04-2026
|
||||||
|
|||||||
Reference in New Issue
Block a user