diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 031bc7d..01d4331 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -56,3 +56,4 @@ Pour le front-end, les exigences de qualité sont les suivantes : - **Tickets** : [issues guidelines](./instructions/issues.instruction.md) - **Commits** : [commit guidelines](./instructions/general-commit.instruction.md) +- **Tests** : [run tests](./instructions/run-tests.instruction.md) diff --git a/.github/instructions/run-tests.instruction.md b/.github/instructions/run-tests.instruction.md new file mode 100644 index 0000000..571ff41 --- /dev/null +++ b/.github/instructions/run-tests.instruction.md @@ -0,0 +1,53 @@ +--- +applyTo: "**" +--- + +# Lancer les tests – N3WT-SCHOOL + +## Tests backend (Django) + +Les tests backend tournent dans le conteneur Docker. Toujours utiliser `--settings=N3wtSchool.test_settings`. + +```powershell +# Tous les tests +docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings --verbosity=2 + +# Un module spécifique +docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests --verbosity=2 +``` + +### Points importants + +- Le fichier `Back-End/N3wtSchool/test_settings.py` configure l'environnement de test : + - Base PostgreSQL dédiée `school_test` (SQLite incompatible avec `ArrayField`) + - Cache en mémoire locale (pas de Redis) + - Channels en mémoire (`InMemoryChannelLayer`) + - Throttling désactivé + - Hashage MD5 (plus rapide) + - Email en mode `locmem` +- Si le conteneur n'est pas démarré : `docker compose up -d` depuis la racine du projet +- Les logs `WARNING` dans la sortie des tests sont normaux (endpoints qui retournent 400/401 intentionnellement) + +## Tests frontend (Jest) + +```powershell +# Depuis le dossier Front-End +cd Front-End +npm test -- --watchAll=false + +# Avec couverture +npm test -- --watchAll=false --coverage +``` + +### Points importants + +- Les tests sont dans `Front-End/src/test/` +- Les warnings `ReactDOMTestUtils.act is deprecated` sont non bloquants (dépendance `@testing-library/react`) +- Config Jest : `Front-End/jest.config.js` + +## Résultats attendus + +| Périmètre | Nb tests | Statut | +| -------------- | -------- | ------ | +| Backend Django | 121 | ✅ OK | +| Frontend Jest | 24 | ✅ OK | diff --git a/Back-End/N3wtSchool/error.py b/Back-End/N3wtSchool/error.py index a357692..4f30b1f 100644 --- a/Back-End/N3wtSchool/error.py +++ b/Back-End/N3wtSchool/error.py @@ -31,5 +31,5 @@ returnMessage = { WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée', PROFIL_INACTIVE: 'Le profil n\'est pas actif', MESSAGE_ACTIVATION_PROFILE: 'Votre profil a été activé avec succès', - PROFIL_ACTIVE: 'Le profil est déjà actif', + PROFIL_ACTIVE: 'Un compte a été détecté et existe déjà pour cet établissement', } \ No newline at end of file diff --git a/Back-End/N3wtSchool/test_settings.py b/Back-End/N3wtSchool/test_settings.py new file mode 100644 index 0000000..de2d288 --- /dev/null +++ b/Back-End/N3wtSchool/test_settings.py @@ -0,0 +1,66 @@ +""" +Settings de test pour l'exécution des tests unitaires Django. +Utilise la base PostgreSQL du docker-compose (ArrayField non supporté par SQLite). +Redis et Celery sont désactivés. +""" +import os +os.environ.setdefault('SECRET_KEY', 'django-insecure-test-secret-key-for-unit-tests-only') +os.environ.setdefault('WEBHOOK_API_KEY', 'test-webhook-api-key-for-unit-tests-only') +os.environ.setdefault('DJANGO_DEBUG', 'True') + +from N3wtSchool.settings import * # noqa: F401, F403 + +# Base de données PostgreSQL dédiée aux tests (isolée de la base de prod) +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'school_test', + 'USER': os.environ.get('DB_USER', 'postgres'), + 'PASSWORD': os.environ.get('DB_PASSWORD', 'postgres'), + 'HOST': os.environ.get('DB_HOST', 'database'), + 'PORT': os.environ.get('DB_PORT', '5432'), + 'TEST': { + 'NAME': 'school_test', + }, + } +} + +# Cache en mémoire locale (pas de Redis) +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + } +} + +# Sessions en base de données (plus simple que le cache pour les tests) +SESSION_ENGINE = 'django.contrib.sessions.backends.db' + +# Django Channels en mémoire (pas de Redis) +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer', + } +} + +# Désactiver Celery pendant les tests +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True + +# Email en mode console (pas d'envoi réel) +EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' + +# Clé secrète fixe pour les tests +SECRET_KEY = 'django-insecure-test-secret-key-for-unit-tests-only' +SIMPLE_JWT['SIGNING_KEY'] = SECRET_KEY # noqa: F405 + +# Désactiver le throttling pendant les tests +REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [] # noqa: F405 +REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = {} # noqa: F405 + +# Accélérer le hashage des mots de passe pour les tests +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] + +# Désactiver les logs verbeux pendant les tests +LOGGING['root']['level'] = 'CRITICAL' # noqa: F405 diff --git a/Back-End/School/tests.py b/Back-End/School/tests.py index 632b98d..7a802f5 100644 --- a/Back-End/School/tests.py +++ b/Back-End/School/tests.py @@ -284,3 +284,70 @@ class EstablishmentCompetencyEndpointAuthTest(TestCase): _assert_endpoint_requires_auth( self, "post", self.list_url, payload={"competency": 1} ) + + +# --------------------------------------------------------------------------- +# Fee - validation du paramètre filter +# --------------------------------------------------------------------------- + +@override_settings(**OVERRIDE_SETTINGS) +class FeeFilterValidationTest(TestCase): + """Tests de validation du paramètre 'filter' sur l'endpoint Fee list.""" + + def setUp(self): + self.client = APIClient() + self.list_url = reverse("School:fee_list_create") + self.user = create_user("fee_filter_test@example.com") + token = get_jwt_token(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + + def test_get_fees_sans_filter_retourne_400(self): + """GET sans paramètre 'filter' doit retourner 400.""" + response = self.client.get(self.list_url, {"establishment_id": 1}) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + msg="GET sans filter devrait retourner 400", + ) + + def test_get_fees_filter_invalide_retourne_400(self): + """GET avec un filtre inconnu doit retourner 400.""" + response = self.client.get( + self.list_url, {"establishment_id": 1, "filter": "unknown"} + ) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + msg="GET avec filter='unknown' devrait retourner 400", + ) + + def test_get_fees_filter_registration_accepte(self): + """GET avec filter='registration' doit être accepté (200 ou 400 si establishment manquant).""" + response = self.client.get( + self.list_url, {"establishment_id": 99999, "filter": "registration"} + ) + self.assertNotEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + msg="GET avec filter='registration' ne doit pas retourner 400 pour une raison de filtre invalide", + ) + + def test_get_fees_filter_tuition_accepte(self): + """GET avec filter='tuition' doit être accepté (200 ou autre selon l'establishment).""" + response = self.client.get( + self.list_url, {"establishment_id": 99999, "filter": "tuition"} + ) + self.assertNotEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + msg="GET avec filter='tuition' ne doit pas retourner 400 pour une raison de filtre invalide", + ) + + def test_get_fees_sans_establishment_id_retourne_400(self): + """GET sans establishment_id doit retourner 400.""" + response = self.client.get(self.list_url, {"filter": "registration"}) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + msg="GET sans establishment_id devrait retourner 400", + ) diff --git a/Back-End/School/views.py b/Back-End/School/views.py index 2f7b818..5353133 100644 --- a/Back-End/School/views.py +++ b/Back-End/School/views.py @@ -12,6 +12,7 @@ from .models import ( Planning, Discount, Fee, + FeeType, PaymentPlan, PaymentMode, EstablishmentCompetency, @@ -288,7 +289,13 @@ class FeeListCreateView(APIView): return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) filter = request.GET.get('filter', '').strip() - fee_type_value = 0 if filter == 'registration' else 1 + if filter not in ('registration', 'tuition'): + return JsonResponse( + {'error': "Le paramètre 'filter' doit être 'registration' ou 'tuition'"}, + safe=False, + status=status.HTTP_400_BAD_REQUEST, + ) + fee_type_value = FeeType.REGISTRATION_FEE if filter == 'registration' else FeeType.TUITION_FEE fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct() fee_serializer = FeeSerializer(fees, many=True) diff --git a/Front-End/src/components/PaymentModeSelector.js b/Front-End/src/components/PaymentModeSelector.js index 1bfb343..7e8f1b9 100644 --- a/Front-End/src/components/PaymentModeSelector.js +++ b/Front-End/src/components/PaymentModeSelector.js @@ -1,6 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { DollarSign } from 'lucide-react'; import { useEstablishment } from '@/context/EstablishmentContext'; +import logger from '@/utils/logger'; const paymentModesOptions = [ { id: 1, name: 'Prélèvement SEPA' }, @@ -9,8 +10,14 @@ const paymentModesOptions = [ { id: 4, name: 'Espèce' }, ]; +/** + * Affiche les modes de paiement communs aux deux types de frais. + * Quand `allPaymentModes` est fourni (mode unifié), un mode activé est créé + * pour les deux types (inscription 0 ET scolarité 1). + */ const PaymentModeSelector = ({ paymentModes, + allPaymentModes, setPaymentModes, handleCreate, handleDelete, @@ -19,23 +26,45 @@ const PaymentModeSelector = ({ const [activePaymentModes, setActivePaymentModes] = useState([]); const { selectedEstablishmentId } = useEstablishment(); + const modes = useMemo( + () => + Array.isArray(allPaymentModes) + ? allPaymentModes + : Array.isArray(paymentModes) + ? paymentModes + : [], + [allPaymentModes, paymentModes] + ); + const unified = !!allPaymentModes; + useEffect(() => { - const activeModes = paymentModes.map((mode) => mode.mode); + const activeModes = [...new Set(modes.map((mode) => mode.mode))]; setActivePaymentModes(activeModes); - }, [paymentModes]); + }, [modes]); const handleModeToggle = (modeId) => { - const updatedMode = paymentModes.find((mode) => mode.mode === modeId); - const isActive = !!updatedMode; - + const isActive = activePaymentModes.includes(modeId); if (!isActive) { - handleCreate({ - mode: modeId, - type, - establishment: selectedEstablishmentId, - }); + if (unified) { + [0, 1].forEach((t) => + handleCreate({ + mode: modeId, + type: t, + establishment: selectedEstablishmentId, + }).catch((e) => logger.error(e)) + ); + } else { + handleCreate({ + mode: modeId, + type, + establishment: selectedEstablishmentId, + }).catch((e) => logger.error(e)); + } } else { - handleDelete(updatedMode.id, null); + const toDelete = modes.filter((m) => m.mode === modeId); + toDelete.forEach((m) => + handleDelete(m.id, null).catch((e) => logger.error(e)) + ); } }; diff --git a/Front-End/src/components/PaymentPlanSelector.js b/Front-End/src/components/PaymentPlanSelector.js index f2f12e7..eeac07a 100644 --- a/Front-End/src/components/PaymentPlanSelector.js +++ b/Front-End/src/components/PaymentPlanSelector.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Calendar } from 'lucide-react'; import Table from '@/components/Table'; import Popup from '@/components/Popup'; @@ -13,8 +13,22 @@ const paymentPlansOptions = [ { id: 4, name: '12 fois', frequency: 12 }, ]; +/** + * Affiche les plans de paiement communs aux deux types de frais. + * Quand `allPaymentPlans` est fourni (mode unifié), un plan coché est créé pour + * les deux types (inscription 0 ET scolarité 1) en même temps. + * + * Props (mode unifié) : + * allPaymentPlans : [{plan_type, type, ...}, ...] - liste combinée des deux types + * handleCreate : (data) => Promise - avec type et establishment déjà présent dans data + * handleDelete : (id) => Promise + * + * Props (mode legacy) : + * paymentPlans, handleCreate, handleDelete, type + */ const PaymentPlanSelector = ({ paymentPlans, + allPaymentPlans, handleCreate, handleDelete, type, @@ -24,38 +38,63 @@ const PaymentPlanSelector = ({ const { selectedEstablishmentId } = useEstablishment(); const [checkedPlans, setCheckedPlans] = useState([]); - // Vérifie si un plan existe pour ce type (par id) + const plans = useMemo( + () => + Array.isArray(allPaymentPlans) + ? allPaymentPlans + : Array.isArray(paymentPlans) + ? paymentPlans + : [], + [allPaymentPlans, paymentPlans] + ); + const unified = !!allPaymentPlans; + + // Un plan est coché si au moins un enregistrement existe pour cette option const isChecked = (planOption) => checkedPlans.includes(planOption.id); - // Création ou suppression du plan const handlePlanToggle = (planOption) => { - const updatedPlan = paymentPlans.find( - (plan) => plan.plan_type === planOption.id - ); if (isChecked(planOption)) { + // Supprimer tous les enregistrements correspondant à cette option (les deux types en mode unifié) + const toDelete = plans.filter( + (p) => + (typeof p.plan_type === 'object' ? p.plan_type.id : p.plan_type) === + planOption.id + ); setCheckedPlans((prev) => prev.filter((id) => id !== planOption.id)); - handleDelete(updatedPlan.id, null); + toDelete.forEach((p) => + handleDelete(p.id, null).catch((e) => logger.error(e)) + ); } else { setCheckedPlans((prev) => [...prev, planOption.id]); - handleCreate({ - plan_type: planOption.id, - type, - establishment: selectedEstablishmentId, - }); + if (unified) { + // Créer pour inscription (0) et scolarité (1) + [0, 1].forEach((t) => + handleCreate({ + plan_type: planOption.id, + type: t, + establishment: selectedEstablishmentId, + }).catch((e) => logger.error(e)) + ); + } else { + handleCreate({ + plan_type: planOption.id, + type, + establishment: selectedEstablishmentId, + }).catch((e) => logger.error(e)); + } } }; useEffect(() => { - if (paymentPlans && paymentPlans.length > 0) { - setCheckedPlans( - paymentPlans.map((plan) => - typeof plan.plan_type === 'object' - ? plan.plan_type.id - : plan.plan_type - ) + if (plans.length > 0) { + const ids = plans.map((plan) => + typeof plan.plan_type === 'object' ? plan.plan_type.id : plan.plan_type ); + setCheckedPlans([...new Set(ids)]); + } else { + setCheckedPlans([]); } - }, [paymentPlans]); + }, [plans]); return (
| {renderCell(row, col.name)} | + ))} +
{message}
+ + +{message}
+