diff --git a/.gitignore b/.gitignore index fb5bcbe..eee2ddb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules/ dist/ __pycache__/ +.tmp/ +frontend/test-results/ +frontend/playwright-report/ *.py[cod] *.retry ansible/group_vars/vault.yml diff --git a/AGENTS.md b/AGENTS.md index f0cd05f..c88d542 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ Consignes pour les agents IA qui modifient ce dépôt. Ce fichier est une bousso - Exploitation: `docs/90_EXPLOITATION.md` - Déploiement Ansible: `docs/91_DEPLOIEMENT_ANSIBLE.md` - Multi-groupe: `docs/93_MULTI_GROUPE.md` +- Plan de validation: `docs/96_PLAN_VALIDATION.md` +- Validation automatisée: `docs/97_VALIDATION_AUTOMATISEE.md` - Garde-fous sociaux: `docs/94_GARDE_FOUS_SOCIAUX.md` Lire les documents pertinents avant un changement large. Ne pas recopier leur contenu ici. @@ -79,9 +81,11 @@ Chaque groupe a un `tenant_schema` PostgreSQL. La voie applicative principale ut Choisir les validations selon le changement: ```bash +scripts/validate.sh python3 -m py_compile -.venv/bin/python -m pytest backend/tests/test_business_rules.py +.venv/bin/python -m pytest backend/tests npm run build +(cd frontend && npm run e2e) ansible-playbook ansible/site.yml --syntax-check ansible-playbook ansible/verify.yml --syntax-check ansible-playbook ansible/backup-now.yml --syntax-check diff --git a/backend/tests/test_validation_contracts.py b/backend/tests/test_validation_contracts.py new file mode 100644 index 0000000..5182a64 --- /dev/null +++ b/backend/tests/test_validation_contracts.py @@ -0,0 +1,108 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +class ValidationPlanTests(unittest.TestCase): + def test_validation_plan_is_indexed_and_structured(self): + index = read("docs/00_INDEX.md") + plan = read("docs/96_PLAN_VALIDATION.md") + + self.assertIn("96_PLAN_VALIDATION.md", index) + self.assertGreaterEqual(plan.count("Scénario:"), 40) + self.assertEqual(plan.count("Scénario:"), plan.count("Résultats attendus:")) + + def test_validation_plan_covers_all_critical_domains(self): + plan = read("docs/96_PLAN_VALIDATION.md") + for section in [ + "Accès, groupes et identité", + "Navigation et ergonomie", + "Membres", + "Postes, mandats et permissions", + "Rencontres", + "Assemblées", + "Gouvernance", + "Contributions", + "Dépenses", + "Trésorerie", + "Inventaires, littérature et jetons", + "Événements", + "Rapports PDF et mode papier", + "Journal, historique et notifications", + "Sauvegarde, restauration et exploitation", + "Concurrence et changements simultanés", + ]: + with self.subTest(section=section): + self.assertIn(f"## {section}", plan) + + +class PublicLandingContractTests(unittest.TestCase): + def test_public_landing_contains_only_public_links_and_sysadmin_access(self): + login = read("frontend/src/pages/Login.jsx") + + self.assertIn("Choisir un groupe", login) + self.assertIn("Sysadmin", login) + self.assertIn("Charte", login) + self.assertIn("Confidentialité", login) + self.assertIn("Découvrir", login) + self.assertIn("setPage('G14')", login) + self.assertIn("localStorage.removeItem('gm_groupe_id')", login) + self.assertIn('type="password"', login) + + def test_group_home_does_not_expose_instance_admin_or_public_policy_links(self): + accueil = read("frontend/src/pages/Accueil.jsx") + + for forbidden in ["setPage('G14')", "Charte", "Confidentialité", "Découvrir"]: + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, accueil) + + +class SafetyContractTests(unittest.TestCase): + def test_api_client_sends_resource_version_for_concurrency_protection(self): + api = read("frontend/src/stores/api.js") + + self.assertIn("options.version", api) + self.assertIn("X-Resource-Version", api) + + def test_crystallized_reports_guard_meeting_inputs(self): + guarded_files = [ + "backend/app/routers/reunions.py", + "backend/app/routers/presences.py", + "backend/app/routers/pv.py", + "backend/app/routers/ventes_litterature.py", + "backend/app/routers/jetons.py", + ] + for rel in guarded_files: + source = read(rel) + with self.subTest(router=rel): + self.assertIn("verifier_reunion_non_cristallisee", source) + + def test_group_backup_and_restore_are_group_scoped_operations(self): + admin_router = read("backend/app/routers/admin.py") + gestion_groupes = read("frontend/src/pages/GestionGroupes.jsx") + + self.assertIn("/groupes/{groupe_id}/backup", admin_router) + self.assertIn("/groupes/{groupe_id}/restore", admin_router) + self.assertIn("creer_sauvegarde_groupe", admin_router) + self.assertIn("restaurer_sauvegarde_groupe", admin_router) + self.assertIn("Réinit", gestion_groupes) + self.assertIn("Restaurer", gestion_groupes) + + def test_instance_admin_token_is_kept_out_of_group_context(self): + admin_router = read("backend/app/routers/admin.py") + security = read("backend/app/core/security.py") + + self.assertIn("create_token(str(admin.id), None", admin_router) + self.assertIn('subject_type="instance_admin"', admin_router) + self.assertIn('payload.get("typ") == "instance_admin"', security) + self.assertIn("return admin", security) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/00_INDEX.md b/docs/00_INDEX.md index 24c7b9e..02e2025 100644 --- a/docs/00_INDEX.md +++ b/docs/00_INDEX.md @@ -23,6 +23,8 @@ Pour administrer ou exploiter l'application: - [Déploiement](92_DEPLOIEMENT.md) - [Sécurité et confidentialité](80_SECURITE.md) - [Multi-groupe](93_MULTI_GROUPE.md) +- [Plan de validation fonctionnelle](96_PLAN_VALIDATION.md) +- [Validation automatisée](97_VALIDATION_AUTOMATISEE.md) Pour maintenir ou faire évoluer le code: @@ -31,6 +33,8 @@ Pour maintenir ou faire évoluer le code: - [API applicative](70_API.md) - [Règles métier](30_REGLES_METIER.md) - [Liens de causalité](40_LIENS_CAUSALITE.md) +- [Plan de validation fonctionnelle](96_PLAN_VALIDATION.md) +- [Validation automatisée](97_VALIDATION_AUTOMATISEE.md) ## Organisation fonctionnelle diff --git a/docs/96_PLAN_VALIDATION.md b/docs/96_PLAN_VALIDATION.md new file mode 100644 index 0000000..9a84a8b --- /dev/null +++ b/docs/96_PLAN_VALIDATION.md @@ -0,0 +1,1008 @@ +# Plan de validation fonctionnelle + +Statut: plan de tests manuel +Public: testeurs, exécutifs, mainteneurs +Dernière révision: 2026-06-02 + +Ce document décrit les scénarios à exécuter pour vérifier que tous les processus importants fonctionnent comme prévu. Il complète les [processus applicatifs](20_PROCESSUS.md), les [règles métier](30_REGLES_METIER.md) et les [liens de causalité](40_LIENS_CAUSALITE.md). + +## Principes de validation + +- Tester au moins une fois depuis une installation vanille. +- Tester avec le groupe de démonstration et avec un groupe créé manuellement. +- Tester avec trois profils: membre ordinaire, exécutif, sysadmin. +- Vérifier les effets visibles à l'écran, les effets financiers, les rapports PDF, le journal et l'isolation entre groupes. +- Après chaque action modifiante, vérifier que le journal ou l'historique conserve une trace exploitable. +- Après un redéploiement, vérifier que les données existantes sont conservées. + +## Préparation + +### Installation vanille + +Scénario: + +1. Déployer l'application sur une VM Debian vanille. +2. Ouvrir l'application depuis `https://app.87-16.org`. +3. Lancer le playbook de vérification. + +Résultats attendus: + +- L'application répond en HTTP derrière le proxy TLS. +- La page publique affiche les groupes sous forme de tuiles fermées. +- `Groupe Démonstration` existe. +- Le bouton discret `Sysadmin` est visible en bas de la page publique. +- Le playbook `ansible/verify.yml` réussit. + +### Données de démonstration + +Scénario: + +1. Ouvrir `Sysadmin`. +2. Saisir le PIN sysadmin. +3. Ouvrir la gestion des groupes. +4. Cliquer `Réinit` sur `Groupe Démonstration`. + +Résultats attendus: + +- Les données du groupe de démonstration sont réinitialisées. +- Les autres groupes ne sont pas modifiés. +- Le groupe de démonstration contient un historique crédible: rencontres, assemblées, membres, postes, décisions, trésorerie, inventaires et journal. + +## Accès, groupes et identité + +### Sélection d'un groupe + +Scénario: + +1. Ouvrir la page publique. +2. Ouvrir la tuile d'un groupe. +3. Se connecter avec un membre de ce groupe. + +Résultats attendus: + +- La tuile fermée affiche le jour, l'heure et le nom du groupe. +- La tuile ouverte affiche les coordonnées et les options de connexion. +- Après connexion, l'entête affiche le nom du groupe courant. +- Le membre voit seulement les données de son groupe. + +### Isolation entre groupes + +Scénario: + +1. Créer deux groupes. +2. Créer un membre différent dans chaque groupe. +3. Créer une rencontre, une dépense et une décision dans le premier groupe. +4. Se connecter dans le deuxième groupe. + +Résultats attendus: + +- Le deuxième groupe ne voit pas les membres, rencontres, dépenses, décisions, finances, rapports ou journaux du premier groupe. +- Les permissions d'un poste du premier groupe n'ont aucun effet dans le deuxième groupe. + +### Connexion d'un membre + +Scénario: + +1. Se connecter avec un identifiant valide et un PIN valide. +2. Tenter une connexion avec un mauvais PIN. +3. Désactiver le membre. +4. Tenter une nouvelle connexion. + +Résultats attendus: + +- La connexion valide ouvre l'application. +- Le mauvais PIN est refusé. +- Un membre désactivé ne peut pas se connecter. +- La désactivation n'efface pas l'historique du membre. + +### PIN + +Scénario: + +1. Changer le PIN d'un membre. +2. Saisir un PIN non conforme. +3. Saisir deux confirmations différentes. + +Résultats attendus: + +- Le PIN n'est pas lisible à l'écran pendant la saisie. +- Un PIN invalide est refusé. +- Deux confirmations différentes sont refusées. +- Le nouveau PIN fonctionne seulement après confirmation correcte. + +### Sysadmin d'instance + +Scénario: + +1. Cliquer `Sysadmin` sur la page publique. +2. Saisir le PIN sysadmin. +3. Ouvrir la gestion des groupes. +4. Changer le PIN sysadmin. + +Résultats attendus: + +- Le sysadmin d'instance n'est pas connecté dans un groupe. +- Il accède seulement aux fonctions d'administration d'instance. +- Le changement de PIN exige deux saisies identiques. +- L'ancien PIN ne fonctionne plus. + +### Sysadmin de groupe + +Scénario: + +1. Se connecter comme `Sysadmin` dans un groupe. +2. Tenter de supprimer le compte `Sysadmin`. +3. Impersonifier un membre. + +Résultats attendus: + +- Le compte `Sysadmin` du groupe a tous les accès du groupe. +- La suppression de `Sysadmin` est refusée. +- L'impersonification permet d'agir comme le membre ciblé. +- Le journal conserve l'identité du sysadmin responsable. + +## Navigation et ergonomie + +### Page publique + +Scénario: + +1. Ouvrir la page publique sur téléphone. +2. Vérifier les tuiles de groupes. +3. Vérifier les liens `Sysadmin`, `Charte`, `Confidentialité` et `Découvrir`. + +Résultats attendus: + +- Les tuiles sont fermées par défaut. +- Le gros bouton `Découvrir l'application` n'est plus présent. +- Les liens sont discrets et placés au bas de la page publique. +- Ces liens ne sont pas affichés dans l'accueil interne d'un groupe. + +### Accueil interne + +Scénario: + +1. Se connecter comme membre ordinaire. +2. Se connecter comme exécutif. +3. Comparer les modules visibles. + +Résultats attendus: + +- Les modules visibles correspondent aux permissions. +- La gestion des groupes n'est jamais accessible depuis l'accueil interne d'un groupe. +- Le bouton `Déconnexion` est présent dans l'entête. +- Le nom du groupe est visible dans l'entête. + +### Pages principales + +Scénario: + +1. Naviguer entre `Accueil`, `Rencontres`, `Assemblées` et `Gestion`. +2. Ouvrir un module. + +Résultats attendus: + +- Les pages sont cohérentes visuellement. +- Le menu inférieur n'est pas affiché dans un module pour récupérer l'espace. +- Les actions principales restent accessibles au pouce sur téléphone. + +## Membres + +### Invitation + +Scénario: + +1. Comme exécutif, générer une invitation. +2. Utiliser le code pour créer un nouveau membre. +3. Réutiliser le même code. + +Résultats attendus: + +- Le membre est créé dans le groupe de l'invitation. +- Le code ne peut être utilisé qu'une seule fois. +- Un code expiré est refusé. + +### Création par un exécutif + +Scénario: + +1. Comme exécutif, créer un compte pour un autre membre. +2. Se connecter avec ce compte. + +Résultats attendus: + +- Le compte est créé dans le groupe courant. +- Le membre peut se connecter avec son PIN. +- L'action est journalisée. + +### Profil et notifications + +Scénario: + +1. Ouvrir le profil. +2. Modifier des informations. +3. Choisir des catégories de notifications avec cases à cocher. +4. Enregistrer. + +Résultats attendus: + +- Les préférences sont conservées après rafraîchissement. +- Les notifications restent optionnelles. +- Le changement est visible au journal ou à l'historique. + +### Droit à l'oubli + +Scénario: + +1. Appliquer le droit à l'oubli à un membre. +2. Consulter les décisions, mandats, dépenses et journal liés à ce membre. + +Résultats attendus: + +- L'enregistrement du membre n'est pas supprimé. +- L'ID et le prénom demeurent. +- Les coordonnées, nom, abstinence, préférences et abonnements push sont effacés. +- L'historique reste intelligible. + +## Postes, mandats et permissions + +### Postes à pourvoir + +Scénario: + +1. Ouvrir `Postes à pourvoir`. +2. Cliquer une tuile de poste. +3. Postuler. +4. Comme exécutif, proposer un autre membre. + +Résultats attendus: + +- La liste affiche une tuile par poste disponible. +- La tuile ouverte affiche la description et les candidatures. +- `Postuler` crée une candidature pour soi. +- `Proposer` crée une candidature pour le membre choisi. + +### Gestion des postes + +Scénario: + +1. Comme exécutif, ouvrir `Gestion des postes`. +2. Créer un poste. +3. Modifier ses attributs. +4. Ouvrir `Permissions`. +5. Associer et dissocier des permissions. + +Résultats attendus: + +- Tous les postes sont présentés en tuiles. +- Le bouton `Permissions` ne montre pas de nombre. +- Les permissions changent les modules visibles pour les membres occupant ce poste. +- Le backend refuse une action non autorisée même si l'interface est contournée. + +### Mandat et nomination + +Scénario: + +1. Affecter un membre à un poste. +2. Fournir proposeur et secondeur. +3. Vérifier les décisions. +4. Terminer le mandat. + +Résultats attendus: + +- L'affectation crée une décision de type `Nomination`. +- La décision porte le préfixe `NOM`. +- Le poste n'est plus à pourvoir pendant le mandat actif. +- Terminer le mandat libère le poste sans effacer l'historique. + +## Rencontres + +### Planification + +Scénario: + +1. Ouvrir la page `Rencontres`. +2. Cliquer `Planifier une rencontre`. +3. Vérifier les dates proposées. +4. Choisir une date, une heure, un sujet et un animateur. + +Résultats attendus: + +- Les rencontres sont affichées en tuiles fermées par défaut. +- Les dates proposées respectent le jour de réunion configuré. +- Les dates sont présentées en ordre chronologique inverse lorsque l'utilisateur doit choisir une date. +- L'animateur par défaut est le membre occupant le poste d'animateur, si défini. +- La tuile fermée affiche seulement date, heure, sujet et animé par. + +### Modification + +Scénario: + +1. Comme exécutif, ouvrir une rencontre. +2. Modifier le sujet, l'heure ou l'animateur. + +Résultats attendus: + +- Les détails sont mis à jour. +- Le rapport futur reflète les nouvelles informations. +- La modification est journalisée. + +### Collecte + +Scénario: + +1. Ouvrir une rencontre. +2. Saisir une collecte. +3. Tenter de saisir une deuxième collecte pour la même rencontre. +4. Comme trésorier, accuser réception. + +Résultats attendus: + +- Une seule collecte est permise par rencontre. +- La collecte devient un montant à recevoir. +- La réception augmente l'encaisse. +- La date comptable est la date de la collecte, même si la réception est saisie plus tard. + +### Ventes + +Scénario: + +1. Saisir une vente de littérature. +2. Saisir une vente de jetons. +3. Saisir une rencontre sans vente. +4. Comme trésorier, recevoir les montants. + +Résultats attendus: + +- Plusieurs ventes sont permises par rencontre. +- Une vente de littérature diminue l'inventaire de littérature. +- Une vente de jetons diminue l'inventaire de jetons. +- L'argent devient un montant à recevoir, puis augmente l'encaisse à la réception. +- Une rencontre sans vente reste valide. + +### Jetons et gâteaux statistiques + +Scénario: + +1. Saisir une remise de jeton. +2. Saisir une remise de gâteau. +3. Vérifier l'inventaire. + +Résultats attendus: + +- Les remises sont comptabilisées aux statistiques. +- L'inventaire ne change pas. +- Seules les ventes affectent l'inventaire. + +### Rapport de rencontre + +Scénario: + +1. Ouvrir une tuile de rencontre. +2. Cliquer l'icône de rapport. +3. Vérifier le PDF. +4. Cliquer `Clôturer`. +5. Tenter de modifier les intrants de la rencontre. + +Résultats attendus: + +- L'icône est discrète et placée sur la tuile fermée. +- Le rapport contient l'entête du groupe, les coordonnées, la rencontre, la collecte, les ventes, les remises et les notes. +- Le PDF est conçu pour impression lettre. +- Après clôture, le rapport est cristallisé. +- Les intrants qui composent le rapport ne peuvent plus être modifiés directement. + +## Assemblées + +### Planification + +Scénario: + +1. Ouvrir la page `Assemblées`. +2. Cliquer `Planifier une assemblée`. +3. Vérifier les dates proposées. +4. Créer l'assemblée. + +Résultats attendus: + +- La page est dédiée aux assemblées. +- Les assemblées sont affichées comme les rencontres: une tuile par assemblée, fermée par défaut. +- Les dates respectent le rang mensuel configuré dans les paramètres du groupe. +- Les dates sont présentées en ordre chronologique inverse. +- Chaque tuile offre une icône de rapport. + +### Présences + +Scénario: + +1. Ouvrir une assemblée. +2. Ouvrir la section `Présences`. +3. Saisir les membres présents. + +Résultats attendus: + +- La section s'ouvre et se ferme sans quitter la page. +- Les présences sont conservées. +- Le rapport PDF les reprend. + +### Invitation et accès invité + +Scénario: + +1. Ouvrir une assemblée. +2. Générer le texte d'invitation. +3. Ouvrir le lien invité dans un autre navigateur. +4. Attendre ou simuler l'expiration du lien. + +Résultats attendus: + +- Le texte est prêt à copier/coller. +- Le lien ouvre directement la page de l'assemblée en lecture seule. +- L'URL BBB réelle n'est pas exposée comme lien permanent. +- Un invité ne peut rien modifier. +- Un lien expiré refuse l'accès. + +### Ordre du jour + +Scénario: + +1. Ouvrir une assemblée. +2. Vérifier les sections disponibles. + +Résultats attendus: + +- La page d'assemblée tient lieu d'ordre du jour. +- Les sections s'ouvrent et se ferment comme dans les rencontres. +- Les actions disponibles comprennent présences, décisions, contributions, postes à pourvoir et rapport. + +### Adoption du PV précédent + +Scénario: + +1. Ouvrir l'assemblée courante. +2. Consulter le PV précédent. +3. Apporter une correction avant adoption. +4. Cliquer `Adopter`. +5. Choisir proposeur et secondeur. +6. Tenter une modification après adoption. + +Résultats attendus: + +- Les corrections sont possibles avant adoption. +- L'adoption exige proposeur et secondeur. +- Une fois adopté, le PV est cristallisé, historisé, consultable et imprimable. +- Une correction postérieure ne modifie pas le PV adopté; elle doit être portée à une assemblée subséquente. + +### Adoption du rapport du trésorier + +Scénario: + +1. Ouvrir l'assemblée. +2. Ouvrir le rapport du trésorier. +3. Tenter de décider une contribution avant adoption. +4. Adopter le rapport avec proposeur et secondeur. +5. Retenter la contribution. + +Résultats attendus: + +- Une contribution est bloquée tant que le rapport du trésorier requis n'est pas adopté. +- L'adoption exige proposeur et secondeur. +- Après adoption, le rapport est cristallisé. +- Les contributions peuvent ensuite être décidées. + +### Rapport d'assemblée + +Scénario: + +1. Cliquer l'icône de rapport d'une assemblée. +2. Vérifier le PDF. +3. Adopter l'assemblée avec proposeur et secondeur. + +Résultats attendus: + +- Le rapport sert de procès-verbal. +- Le PDF contient l'ordre du jour, les présences, les propositions, les décisions, les contributions, les nominations, les postes à pourvoir et les rapports adoptés. +- Après adoption, le rapport est cristallisé et ne peut plus être altéré directement. + +## Gouvernance + +### Proposition + +Scénario: + +1. Soumettre une proposition avec un objet et un contenu. +2. Soumettre une proposition sans objet. + +Résultats attendus: + +- La proposition valide apparaît dans `Gouvernance`. +- Une proposition sans objet est refusée. +- Une proposition n'a pas de type au départ. + +### Seconder + +Scénario: + +1. Comme membre, cliquer `Je seconde`. +2. Comme exécutif, seconder pour un autre membre. +3. Comme sysadmin, seconder sa propre proposition. + +Résultats attendus: + +- Le libellé affiché est `Je seconde`. +- Un exécutif peut choisir le membre secondeur. +- Sysadmin peut seconder ses propres propositions et nominations. +- Les autres membres ordinaires ne peuvent pas seconder pour autrui. + +### Décision + +Scénario: + +1. Transformer une proposition secondée en décision. +2. Renseigner `ÉTANT DONNÉ QUE`. +3. Renseigner `LE GROUPE A DÉCIDÉ DE`. +4. Vérifier l'historique des décisions. + +Résultats attendus: + +- Seul un exécutif peut transformer une proposition en décision. +- La décision exige proposeur et secondeur. +- Le type est renseigné au moment de la décision. +- Le préfixe est correct: `RES`, `NOM` ou `DON`. +- La décision est visible dans l'historique et les rapports. + +### Rejet + +Scénario: + +1. Rejeter une proposition. +2. Omettre `ÉTANT DONNÉ QUE`. +3. Consulter l'historique. + +Résultats attendus: + +- Le rejet exige une raison. +- La proposition rejetée est conservée. +- Elle ne disparaît pas comme si elle n'avait jamais existé. +- Le journal indique qui a causé le rejet. + +### Rapport des décisions + +Scénario: + +1. Produire le registre PDF des décisions. +2. Vérifier une décision ayant `LE GROUPE A DÉCIDÉ DE`. + +Résultats attendus: + +- La description du rapport est `Décisions prises en Groupe`. +- La colonne principale est `Résolution`. +- Le texte de `LE GROUPE A DÉCIDÉ DE` apparaît comme résolution lorsque renseigné. +- Le rapport ne contient pas de sommaire final inutile. + +## Contributions + +### Donner au suivant + +Scénario: + +1. Ouvrir `Donner au suivant`. +2. Choisir un destinataire. +3. Choisir proposeur et secondeur. +4. Soumettre. + +Résultats attendus: + +- Le module porte le nom `Donner au suivant`. +- `District 87-16` est présent et sélectionné par défaut au déploiement initial. +- La contribution crée une décision de type `Contribution` avec préfixe `DON`. +- La contribution apparaît dans `Trésorerie > À traiter`. +- Les soldes ne changent pas avant traitement par la trésorerie. + +### Destinataires + +Scénario: + +1. Ajouter un destinataire à la volée. +2. Gérer les destinataires depuis `Trésorerie`. +3. Tenter de supprimer `District 87-16`. + +Résultats attendus: + +- Le nouveau destinataire devient sélectionnable. +- La gestion depuis la trésorerie met à jour la liste. +- `District 87-16` est protégé contre la suppression. + +## Dépenses + +### Soumission par un membre + +Scénario: + +1. Comme membre ordinaire, soumettre une dépense. +2. Vérifier le nom du demandeur. +3. Vérifier la date. + +Résultats attendus: + +- Le membre soumet seulement en son nom. +- La date est la date de soumission. +- La demande apparaît dans `Trésorerie > À traiter`. + +### Soumission par un exécutif + +Scénario: + +1. Comme exécutif, soumettre une dépense pour un autre membre. +2. Choisir le membre dans une liste déroulante. + +Résultats attendus: + +- L'exécutif peut choisir le demandeur. +- Le membre choisi est conservé comme demandeur. +- L'action est journalisée. + +### Traitement + +Scénario: + +1. Traiter une dépense cash. +2. Traiter une dépense par chèque. +3. Traiter une dépense par virement. +4. Tenter de traiter une dépense bancaire supérieure au solde disponible. +5. Rejeter une dépense. + +Résultats attendus: + +- Une dépense cash diminue immédiatement l'encaisse. +- Une dépense par chèque ou virement crée un engagement. +- Le débit bancaire diminue la banque lors de confirmation. +- Une dépense bancaire impossible est refusée. +- Le bouton `Rejeter` efface la demande soumise. +- Le module `Dépense` ne duplique pas le traitement de trésorerie. + +## Trésorerie + +### Page d'accueil de trésorerie + +Scénario: + +1. Ouvrir `Trésorerie`. +2. Vérifier l'ordre des sections. +3. Comparer membre ordinaire et exécutif. + +Résultats attendus: + +- La page d'accueil montre les positions de trésorerie, les réserves et les éléments à traiter. +- Tous peuvent voir les éléments à traiter. +- Seuls les exécutifs peuvent confirmer, traiter, rejeter ou modifier. +- L'état des résultats est sur une page `Résultats` séparée. + +### Soldes + +Scénario: + +1. Consulter banque, encaisse, réserves et solde disponible. +2. Faire une collecte reçue. +3. Faire un dépôt d'encaisse. +4. Faire un don direct. + +Résultats attendus: + +- L'encaisse est une position disponible séparée. +- La collecte reçue augmente l'encaisse. +- Le dépôt depuis l'encaisse diminue l'encaisse et augmente la banque. +- Le don direct augmente la banque sans diminuer l'encaisse. +- Le solde disponible bancaire est la banque moins engagements et réserves. + +### Réserves + +Scénario: + +1. Créer une réserve. +2. Tenter de modifier directement son montant. +3. Faire un virement depuis le disponible vers la réserve. +4. Faire un virement entre deux réserves. +5. Faire un virement depuis une réserve vers le disponible. +6. Supprimer une réserve à solde non nul. +7. Ramener la réserve à zéro et la supprimer. + +Résultats attendus: + +- La réserve est créée à zéro. +- Le montant ne se modifie pas directement. +- Les virements modifient les soldes. +- Les virements apparaissent au registre. +- Une réserve non vide ne peut pas être supprimée. +- Une réserve à zéro peut être supprimée. + +### Registre + +Scénario: + +1. Ouvrir le registre. +2. Filtrer par mois. +3. Filtrer par type. +4. Vérifier l'ordre. + +Résultats attendus: + +- Les transactions les plus récentes sont affichées d'abord. +- Les filtres mois et type fonctionnent ensemble. +- Le registre reflète collectes, ventes, dépenses, dépôts, retraits, dons, contributions et mouvements de réserves. + +### État des résultats + +Scénario: + +1. Ouvrir `Résultats`. +2. Choisir un mois et une année. +3. Produire le rapport PDF mensuel. + +Résultats attendus: + +- L'état des résultats montre revenus, charges et résultat net. +- Le rapport PDF utilise toujours le mois précédent. +- Les positions de trésorerie du rapport sont celles du dernier jour du mois précédent. + +## Inventaires, littérature et jetons + +### Consultation + +Scénario: + +1. Ouvrir l'inventaire littérature et jetons depuis la page `Gestion`. +2. Ouvrir les modules de consultation depuis `Accueil`. + +Résultats attendus: + +- La gestion littérature et jetons est unifiée dans une même tuile de gestion. +- Les traitements sont cohérents entre littérature et jetons. +- Les modules de consultation restent accessibles depuis `Accueil` selon les permissions. + +### Ajustement + +Scénario: + +1. Comme responsable autorisé, ajuster un stock. +2. Comme membre non autorisé, tenter le même ajustement. + +Résultats attendus: + +- L'ajustement autorisé est conservé et journalisé. +- L'action non autorisée est refusée. + +### Import initial littérature + +Scénario: + +1. Déployer sur une base vide. +2. Consulter l'inventaire de littérature. + +Résultats attendus: + +- L'inventaire initial est renseigné à partir du fichier de prix prévu. +- Les articles sont disponibles pour les ventes. + +## Événements + +### Consultation + +Scénario: + +1. Ouvrir `Événements`. +2. Vérifier événements passés et futurs. +3. Sélectionner plusieurs filtres. + +Résultats attendus: + +- Les événements passés sont visibles. +- Le filtre `Tous` n'est pas présenté. +- La sélection multiple fonctionne. +- Les résolutions ne sont pas affichées dans le calendrier. + +### Gestion + +Scénario: + +1. Comme exécutif, créer un événement. +2. Comme membre ordinaire, tenter de créer un événement. + +Résultats attendus: + +- L'exécutif peut créer et supprimer un événement. +- Le membre ordinaire ne peut pas créer d'événement si son poste ne l'autorise pas. + +## Rapports PDF et mode papier + +### Formulaires vierges + +Scénario: + +1. Ouvrir le mode papier. +2. Produire un formulaire vierge de rencontre. +3. Produire un formulaire vierge d'assemblée. + +Résultats attendus: + +- Les formulaires contiennent une entête propre avec le nom et les coordonnées du groupe. +- Les cadres de saisie sont proportionnés pour impression lettre. +- Le formulaire permet une reprise manuelle si l'application devient indisponible. + +### Rapports cristallisés + +Scénario: + +1. Clôturer une rencontre. +2. Adopter une assemblée. +3. Télécharger les rapports plus tard. + +Résultats attendus: + +- Les PDF cristallisés restent accessibles. +- Le contenu ne change plus après clôture ou adoption. +- Les rapports sont consultables et imprimables. + +## Journal, historique et notifications + +### Journal + +Scénario: + +1. Modifier un membre. +2. Créer une proposition. +3. Traiter une dépense. +4. Ouvrir le journal. +5. Filtrer par domaine. + +Résultats attendus: + +- Chaque action modifiante réussie crée une entrée. +- La tuile condensée montre seulement date, heure et domaine. +- La tuile ouverte montre les détails sans redondance. +- Le journal indique le prénom du membre responsable lorsque disponible. +- L'ancienne valeur et la nouvelle valeur sont visibles lorsque disponibles. +- Le filtre du journal fonctionne. + +### Historique métier + +Scénario: + +1. Modifier une donnée sensible ou importante. +2. Consulter les détails historiques. + +Résultats attendus: + +- L'historique indique l'objet modifié. +- L'ancienne valeur est conservée lorsque pertinente. +- La nouvelle valeur est conservée lorsque pertinente. +- La raison de modification est générée automatiquement par l'application. +- L'utilisateur n'a pas à saisir la raison. + +### Notifications + +Scénario: + +1. Activer les notifications pour une catégorie. +2. Désactiver une autre catégorie. +3. Créer une action qui devrait notifier. + +Résultats attendus: + +- Le membre reçoit seulement les notifications des catégories choisies, si son navigateur les supporte. +- Un membre non abonné ou non intéressé ne reçoit pas la notification. +- L'échec d'une notification push ne bloque pas l'action métier. + +## Sauvegarde, restauration et exploitation + +### Sauvegarde par groupe + +Scénario: + +1. Créer des données dans deux groupes. +2. Sauvegarder le premier groupe. +3. Modifier les deux groupes. + +Résultats attendus: + +- La sauvegarde concerne seulement le groupe choisi. +- Les données personnelles contenues dans la sauvegarde sont traitées comme sensibles. + +### Restauration par groupe + +Scénario: + +1. Restaurer le premier groupe. +2. Vérifier le deuxième groupe. + +Résultats attendus: + +- Le premier groupe revient à l'état sauvegardé. +- Le deuxième groupe n'est pas modifié. +- Une sauvegarde de sécurité est créée avant restauration. + +### Redéploiement + +Scénario: + +1. Créer des données. +2. Redéployer avec Ansible. +3. Vérifier les données. + +Résultats attendus: + +- Le redéploiement ne détruit pas les données. +- Les services redémarrent correctement. +- La vérification Ansible réussit. + +### Cache navigateur + +Scénario: + +1. Installer l'application comme PWA. +2. Déployer une nouvelle version. +3. Ouvrir l'application sur un appareil qui avait l'ancienne version. + +Résultats attendus: + +- L'application se met à jour sans incohérence durable. +- En cas de problème, la navigation privée ou l'effacement des données du site corrige le symptôme. + +## Concurrence et changements simultanés + +### Modification concurrente + +Scénario: + +1. Ouvrir la même fiche dans deux navigateurs. +2. Modifier et enregistrer dans le premier navigateur. +3. Modifier et enregistrer dans le deuxième navigateur sans rafraîchir. + +Résultats attendus: + +- L'application détecte que la donnée a changé. +- Le deuxième enregistrement n'écrase pas silencieusement le premier. +- L'utilisateur est informé qu'un rafraîchissement ou une reprise est nécessaire. +- L'historique conserve le changement réellement appliqué. + +### Données cristallisées + +Scénario: + +1. Ouvrir un rapport non cristallisé dans deux navigateurs. +2. Cristalliser le rapport dans le premier. +3. Tenter une modification dans le deuxième. + +Résultats attendus: + +- La modification est refusée. +- Le message explique que le rapport est clôturé ou adopté. +- Le PDF cristallisé demeure la source consultable. + +## Contrôles transversaux après chaque scénario + +Après chaque scénario modifiant des données, vérifier: + +- le résultat à l'écran; +- le rafraîchissement de la page; +- la déconnexion et reconnexion; +- le journal; +- l'historique détaillé si applicable; +- les permissions d'un membre non autorisé; +- l'absence de fuite vers un autre groupe; +- les rapports PDF concernés; +- les soldes ou inventaires si l'action a un effet comptable. + +## Critères d'acceptation globaux + +L'application est considérée prête pour une ronde de test avec des membres lorsque: + +- tous les scénarios critiques d'accès, rencontres, assemblées, gouvernance, trésorerie et rapports passent; +- aucun membre ordinaire ne peut exécuter une action réservée; +- aucun groupe ne voit les données d'un autre groupe; +- les soldes financiers sont explicables par le registre; +- les rapports adoptés ou clôturés ne changent plus; +- le journal permet de comprendre qui a causé quoi, quand, et quelle valeur a changé; +- une installation vanille donne un groupe de démonstration utilisable; +- un redéploiement ne détruit pas les données; +- les sauvegardes et restaurations par groupe ont été testées au moins une fois. diff --git a/docs/97_VALIDATION_AUTOMATISEE.md b/docs/97_VALIDATION_AUTOMATISEE.md new file mode 100644 index 0000000..add3196 --- /dev/null +++ b/docs/97_VALIDATION_AUTOMATISEE.md @@ -0,0 +1,96 @@ +# Validation automatisée + +Statut: runbook de validation +Public: testeurs, mainteneurs, sysadmin +Dernière révision: 2026-06-02 + +Ce document explique comment automatiser la validation sans remplacer le jugement humain nécessaire pour l'acceptabilité, la lisibilité et l'ergonomie. + +## Commande locale + +Depuis la racine du dépôt: + +```bash +scripts/validate.sh +``` + +Cette commande exécute: + +- compilation Python du backend et des scripts de déploiement; +- tests backend; +- build frontend; +- tests navigateur Playwright; +- syntax-check Ansible lorsque `ansible-playbook` est disponible. + +Pour ignorer temporairement les tests navigateur: + +```bash +SKIP_E2E=1 scripts/validate.sh +``` + +## Ce qui est automatisé + +- règles métier testables par code; +- contrats de sécurité et de multi-groupe; +- présence des protections de concurrence; +- cristallisation des rapports; +- exposition correcte de l'accès sysadmin; +- page publique de sélection des groupes; +- accès sysadmin d'instance; +- changement du PIN sysadmin; +- liens publics Charte, Confidentialité et Découvrir; +- build frontend; +- validité syntaxique Ansible. + +## Ce qui doit rester validé par un humain + +- lisibilité des rapports PDF; +- confort de navigation sur petit téléphone; +- ton des textes destinés aux membres; +- acceptabilité sociale des processus; +- clarté des formulaires papier; +- compréhension par des membres non techniques. + +## Ronde de validation recommandée + +1. Exécuter `scripts/validate.sh`. +2. Déployer sur une VM vanille. +3. Exécuter `ansible-playbook ansible/verify.yml`. +4. Réinitialiser `Groupe Démonstration`. +5. Parcourir les scénarios du [plan de validation fonctionnelle](96_PLAN_VALIDATION.md). +6. Noter les écarts avec la page, l'action, le résultat obtenu et le résultat attendu. + +## Tests Playwright + +Les tests Playwright sont dans `frontend/e2e`. + +Ils démarrent automatiquement Vite, simulent les réponses API nécessaires et exécutent les scénarios dans un navigateur mobile et desktop. + +Commande directe: + +```bash +cd frontend +npm run e2e +``` + +Pour installer le navigateur Chromium si nécessaire: + +```bash +cd frontend +npx playwright install chromium +``` + +## Évolution prévue + +La prochaine étape pertinente est d'élargir Playwright vers les parcours authentifiés complets: + +- sélection de groupe; +- connexion membre et sysadmin; +- navigation mobile; +- rencontre complète; +- assemblée complète; +- trésorerie; +- journal; +- captures de rapports PDF. + +Ces tests devront être ajoutés progressivement pour éviter une suite fragile. diff --git a/frontend/e2e/public.spec.js b/frontend/e2e/public.spec.js new file mode 100644 index 0000000..2a398ed --- /dev/null +++ b/frontend/e2e/public.spec.js @@ -0,0 +1,155 @@ +import { expect, test } from '@playwright/test'; + +const groupes = [ + { + id: 'groupe-alpha', + nom: 'Groupe Alpha', + district: '87-16', + adresse_local: '123 rue Principale, Saint-Bruno', + jour_reunion: 'mardi', + heure_reunion: '19:30', + assemblee_affaires_ordre: 'premiere', + format_reunion: 'Discussion ouverte', + }, + { + id: 'groupe-demo', + nom: 'Groupe Démonstration', + district: '87-16', + adresse_local: 'Salle de démonstration', + jour_reunion: 'samedi', + heure_reunion: '20:00', + assemblee_affaires_ordre: 'derniere', + format_reunion: 'Formation', + }, +]; + +async function mockPublicApi(page) { + await page.route('**/api/auth/groupes', async (route) => { + await route.fulfill({ json: groupes }); + }); +} + +async function mockSysadminApi(page) { + await page.route('**/api/admin/sysadmin-login', async (route) => { + const body = route.request().postDataJSON(); + if (body.pin !== '0000') { + await route.fulfill({ status: 401, json: { detail: 'PIN Sysadmin invalide' } }); + return; + } + await route.fulfill({ + json: { + access_token: 'instance-admin-token', + token_type: 'bearer', + sysadmin_instance: true, + membre: { + id: 'admin-id', + prenom: 'Sysadmin', + courriel: 'sysadmin', + groupe_id: null, + date_abstinence: null, + is_sysadmin: true, + }, + }, + }); + }); + await page.route('**/api/admin/groupes', async (route) => { + await route.fulfill({ + json: groupes.map((groupe, index) => ({ + ...groupe, + lien_aa87: 'https://aa87.org', + tenant_schema: `grp_${index}`, + cree_le: '2026-01-01T00:00:00Z', + membres: index === 0 ? 12 : 14, + reunions: index === 0 ? 52 : 52, + traces: index === 0 ? 120 : 180, + demo: groupe.nom === 'Groupe Démonstration', + protege: groupe.nom === 'Groupe Démonstration', + })), + }); + }); + await page.route('**/api/admin/sysadmin-pin', async (route) => { + const body = route.request().postDataJSON(); + if (body.pin_actuel !== '0000') { + await route.fulfill({ status: 401, json: { detail: 'PIN actuel invalide' } }); + return; + } + await route.fulfill({ json: { message: 'PIN sysadmin mis à jour' } }); + }); +} + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + await mockPublicApi(page); +}); + +test('la page publique liste les groupes en tuiles fermées et expose les liens publics', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'Choisir un groupe' })).toBeVisible(); + await expect(page.getByRole('button', { name: /Groupe Alpha/ })).toBeVisible(); + await expect(page.getByText('123 rue Principale')).toBeHidden(); + await expect(page.getByRole('button', { name: 'Sysadmin' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Charte' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Confidentialité' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Découvrir' })).toBeVisible(); + + await page.getByRole('button', { name: /Groupe Alpha/ }).click(); + + await expect(page.getByText('123 rue Principale')).toBeVisible(); + await expect(page.getByPlaceholder('Adresse courriel ou identifiant')).toBeVisible(); + await expect(page.getByPlaceholder('PIN')).toHaveAttribute('type', 'password'); +}); + +test('les pages publiques Charte et Confidentialité restent accessibles sans connexion', async ({ page }) => { + await page.goto('/'); + + await page.getByRole('button', { name: 'Charte' }).click(); + await expect(page.getByRole('heading', { name: /Charte|Principes/ })).toBeVisible(); + + await page.goto('/'); + await page.getByRole('button', { name: 'Confidentialité' }).click(); + await expect(page.getByRole('heading', { name: /Confidentialité/ })).toBeVisible(); +}); + +test('le sysadmin d’instance ouvre la gestion des groupes sans contexte de groupe', async ({ page }) => { + await mockSysadminApi(page); + await page.goto('/'); + + await page.getByRole('button', { name: 'Sysadmin' }).click(); + await page.getByPlaceholder('PIN sysadmin').fill('0000'); + await page.getByRole('button', { name: 'Ouvrir' }).click(); + + await expect(page.getByRole('heading', { name: 'Gestion des groupes' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Créer un groupe' })).toBeVisible(); + await expect(page.getByRole('button', { name: /Groupe Alpha/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /Groupe Démonstration/ })).toBeVisible(); + await expect(page.getByText('Instance · Bonjour Sysadmin')).toBeVisible(); + await expect(page.getByText(/Groupe Alpha · Bonjour Sysadmin/)).not.toBeVisible(); +}); + +test('le changement de PIN sysadmin masque les saisies et exige une confirmation', async ({ page }) => { + await mockSysadminApi(page); + await page.goto('/'); + + await page.getByRole('button', { name: 'Sysadmin' }).click(); + await page.getByPlaceholder('PIN sysadmin').fill('0000'); + await page.getByRole('button', { name: 'Ouvrir' }).click(); + await page.getByRole('button', { name: 'Changer le PIN sysadmin' }).click(); + + await expect(page.getByPlaceholder('PIN actuel')).toHaveAttribute('type', 'password'); + await expect(page.getByPlaceholder('Nouveau PIN', { exact: true })).toHaveAttribute('type', 'password'); + await expect(page.getByPlaceholder('Confirmer le nouveau PIN')).toHaveAttribute('type', 'password'); + + await page.getByPlaceholder('PIN actuel').fill('0000'); + await page.getByPlaceholder('Nouveau PIN', { exact: true }).fill('1234'); + await page.getByPlaceholder('Confirmer le nouveau PIN').fill('4321'); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + await expect(page.getByText('La confirmation ne correspond pas')).toBeVisible(); + + await page.getByPlaceholder('Confirmer le nouveau PIN').fill('1234'); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + await expect(page.getByText('PIN sysadmin mis à jour')).toBeVisible(); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7628329..7673fc7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", @@ -1999,6 +2000,21 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -4753,6 +4769,50 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0020044..3c26993 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "e2e": "playwright test" }, "dependencies": { "react": "^18.3.1", @@ -15,12 +16,13 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", - "tailwindcss": "^3.4.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", "vite": "^5.4.0", "vite-plugin-pwa": "^0.20.0" } diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js new file mode 100644 index 0000000..a96747b --- /dev/null +++ b/frontend/playwright.config.js @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + reporter: [['list']], + use: { + baseURL: 'http://127.0.0.1:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + webServer: { + command: 'npm run dev -- --host 127.0.0.1', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'mobile-chromium', + use: { + browserName: 'chromium', + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 3, + userAgent: devices['iPhone 13'].userAgent, + }, + }, + { + name: 'desktop-chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 0000000..73ea863 --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +mkdir -p .tmp/ansible-local .tmp/ansible-remote +export ANSIBLE_LOCAL_TEMP="$ROOT/.tmp/ansible-local" +export ANSIBLE_REMOTE_TEMP="$ROOT/.tmp/ansible-remote" + +echo "== Python syntax ==" +python3 -m py_compile $(find backend/app deploy -name '*.py' -print) + +echo "== Backend tests ==" +if [ -x ".venv/bin/python" ]; then + .venv/bin/python -m pytest backend/tests +else + python3 -m pytest backend/tests +fi + +echo "== Frontend build ==" +(cd frontend && npm run build) + +if [ "${SKIP_E2E:-0}" = "1" ]; then + echo "== Playwright E2E ==" + echo "SKIP_E2E=1; tests navigateur ignorés." +else + echo "== Playwright E2E ==" + (cd frontend && npm run e2e) +fi + +if command -v ansible-playbook >/dev/null 2>&1; then + echo "== Ansible syntax ==" + ansible-playbook ansible/site.yml --syntax-check + ansible-playbook ansible/verify.yml --syntax-check + ansible-playbook ansible/backup-now.yml --syntax-check +else + echo "== Ansible syntax ==" + echo "ansible-playbook introuvable; syntax-check ignoré." +fi + +echo "Validation locale terminée."