From e30a41a58b199b68eca687a159736b2025e9f22d Mon Sep 17 00:00:00 2001 From: Luc SORIGNET Date: Sun, 15 Mar 2026 12:09:02 +0100 Subject: [PATCH 1/3] feat(frontend): fusion liste des frais et message compte existant [#NEWTS-9] --- Back-End/N3wtSchool/error.py | 2 +- Back-End/School/tests.py | 67 ++++ Back-End/School/views.py | 9 +- .../src/components/PaymentModeSelector.js | 53 ++- .../src/components/PaymentPlanSelector.js | 79 +++-- .../Tarification/DiscountsSection.js | 63 ++-- .../Structure/Tarification/FeeTypeSection.js | 145 ++++++++ .../Structure/Tarification/FeesManagement.js | 332 +++++++----------- .../Structure/Tarification/FeesSection.js | 88 +++-- Front-End/src/test/FeesSection.test.js | 211 +++++++++++ 10 files changed, 755 insertions(+), 294 deletions(-) create mode 100644 Front-End/src/components/Structure/Tarification/FeeTypeSection.js create mode 100644 Front-End/src/test/FeesSection.test.js 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/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 (
diff --git a/Front-End/src/components/Structure/Tarification/DiscountsSection.js b/Front-End/src/components/Structure/Tarification/DiscountsSection.js index 1053f53..da69f53 100644 --- a/Front-End/src/components/Structure/Tarification/DiscountsSection.js +++ b/Front-End/src/components/Structure/Tarification/DiscountsSection.js @@ -9,6 +9,8 @@ import SectionHeader from '@/components/SectionHeader'; import { useEstablishment } from '@/context/EstablishmentContext'; import AlertMessage from '@/components/AlertMessage'; +const DISCOUNT_TYPE_LABELS = { 0: 'Inscription', 1: 'Scolarité' }; + const DiscountsSection = ({ discounts, setDiscounts, @@ -16,6 +18,7 @@ const DiscountsSection = ({ handleEdit, handleDelete, type, + unified = false, subscriptionMode = false, selectedDiscounts, handleDiscountSelection, @@ -39,7 +42,7 @@ const DiscountsSection = ({ amount: '', description: '', discount_type: 0, - type: type, + type: unified ? 0 : type, establishment: selectedEstablishmentId, }); }; @@ -219,6 +222,21 @@ const DiscountsSection = ({ handleChange, 'Description' ); + case 'TYPE': + return ( + + ); case 'ACTIONS': return (
@@ -259,6 +277,18 @@ const DiscountsSection = ({ return discount.description; case 'MISE A JOUR': return discount.updated_at_formatted; + case 'TYPE': + return ( + + {DISCOUNT_TYPE_LABELS[discount.type]} + + ); case 'ACTIONS': return (
@@ -335,34 +365,25 @@ const DiscountsSection = ({ { name: 'LIBELLE', label: 'Libellé' }, { name: 'DESCRIPTION', label: 'Description' }, { name: 'REMISE', label: 'Remise' }, + ...(unified ? [{ name: 'TYPE', label: 'Type' }] : []), { name: '', label: 'Sélection' }, ] : [ { name: 'LIBELLE', label: 'Libellé' }, { name: 'REMISE', label: 'Remise' }, { name: 'DESCRIPTION', label: 'Description' }, + ...(unified ? [{ name: 'TYPE', label: 'Type' }] : []), { name: 'MISE A JOUR', label: 'Date mise à jour' }, { name: 'ACTIONS', label: 'Actions' }, ]; - let emptyMessage; - if (type === 0) { - emptyMessage = ( - - ); - } else { - emptyMessage = ( - - ); - } + const emptyMessage = ( + + ); return (
@@ -370,8 +391,8 @@ const DiscountsSection = ({ diff --git a/Front-End/src/components/Structure/Tarification/FeeTypeSection.js b/Front-End/src/components/Structure/Tarification/FeeTypeSection.js new file mode 100644 index 0000000..7253b80 --- /dev/null +++ b/Front-End/src/components/Structure/Tarification/FeeTypeSection.js @@ -0,0 +1,145 @@ +import React from 'react'; +import FeesSection from '@/components/Structure/Tarification/FeesSection'; +import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection'; +import PaymentPlanSelector from '@/components/PaymentPlanSelector'; +import PaymentModeSelector from '@/components/PaymentModeSelector'; +import { + BE_SCHOOL_FEES_URL, + BE_SCHOOL_DISCOUNTS_URL, + BE_SCHOOL_PAYMENT_PLANS_URL, + BE_SCHOOL_PAYMENT_MODES_URL, +} from '@/utils/Url'; + +/** + * Bloc complet de gestion des frais pour un type donné (inscription ou scolarité). + * Regroupe : liste des frais, réductions, plans et modes de paiement. + * + * @param {string} title - Titre affiché dans le séparateur de section + * @param {Array} fees - Liste des frais du type + * @param {Function} setFees - Setter des frais + * @param {Array} discounts - Liste des réductions du type + * @param {Function} setDiscounts - Setter des réductions + * @param {Array} paymentPlans - Plans de paiement du type + * @param {Function} setPaymentPlans - Setter des plans de paiement + * @param {Array} paymentModes - Modes de paiement du type + * @param {Function} setPaymentModes - Setter des modes de paiement + * @param {number} type - 0 = inscription, 1 = scolarité + * @param {Function} handleCreate - (url, newData, setter) => Promise + * @param {Function} handleEdit - (url, id, updatedData, setter) => Promise + * @param {Function} handleDelete - (url, id, setter) => Promise + * @param {Function} onDiscountDelete - Callback invoqué après suppression d'une réduction + */ +const FeeTypeSection = ({ + title, + fees, + setFees, + discounts, + setDiscounts, + paymentPlans, + setPaymentPlans, + paymentModes, + setPaymentModes, + type, + handleCreate, + handleEdit, + handleDelete, + onDiscountDelete, +}) => { + return ( + <> +
+
+ {title} +
+
+ +
+ + handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setFees) + } + handleEdit={(id, updatedData) => + handleEdit(`${BE_SCHOOL_FEES_URL}`, id, updatedData, setFees) + } + handleDelete={(id) => + handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setFees) + } + type={type} + /> +
+ +
+ + handleCreate(`${BE_SCHOOL_DISCOUNTS_URL}`, newData, setDiscounts) + } + handleEdit={(id, updatedData) => + handleEdit( + `${BE_SCHOOL_DISCOUNTS_URL}`, + id, + updatedData, + setDiscounts + ) + } + handleDelete={(id) => + handleDelete(`${BE_SCHOOL_DISCOUNTS_URL}`, id, setDiscounts) + } + onDiscountDelete={onDiscountDelete} + type={type} + /> +
+ +
+
+ + handleCreate( + `${BE_SCHOOL_PAYMENT_PLANS_URL}`, + newData, + setPaymentPlans + ) + } + handleDelete={(id) => + handleDelete( + `${BE_SCHOOL_PAYMENT_PLANS_URL}`, + id, + setPaymentPlans + ) + } + type={type} + /> +
+
+ + handleCreate( + `${BE_SCHOOL_PAYMENT_MODES_URL}`, + newData, + setPaymentModes + ) + } + handleDelete={(id) => + handleDelete( + `${BE_SCHOOL_PAYMENT_MODES_URL}`, + id, + setPaymentModes + ) + } + type={type} + /> +
+
+ + ); +}; + +export default FeeTypeSection; diff --git a/Front-End/src/components/Structure/Tarification/FeesManagement.js b/Front-End/src/components/Structure/Tarification/FeesManagement.js index 40225f9..5dd14dc 100644 --- a/Front-End/src/components/Structure/Tarification/FeesManagement.js +++ b/Front-End/src/components/Structure/Tarification/FeesManagement.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React from 'react'; import FeesSection from '@/components/Structure/Tarification/FeesSection'; import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection'; import PaymentPlanSelector from '@/components/PaymentPlanSelector'; @@ -31,223 +31,141 @@ const FeesManagement = ({ handleEdit, handleDelete, }) => { - const handleDiscountDelete = (id, type) => { - if (type === 0) { - setRegistrationFees((prevFees) => - prevFees.map((fee) => ({ - ...fee, - discounts: fee.discounts.filter((discountId) => discountId !== id), - })) - ); - } else { - setTuitionFees((prevFees) => - prevFees.map((fee) => ({ - ...fee, - discounts: fee.discounts.filter((discountId) => discountId !== id), - })) - ); - } + // Liste unique triée par type puis par nom + const allFees = [...(registrationFees ?? []), ...(tuitionFees ?? [])].sort( + (a, b) => a.type - b.type || (a.name ?? '').localeCompare(b.name ?? '') + ); + + const setAllFees = (updater) => { + const next = typeof updater === 'function' ? updater(allFees) : updater; + setRegistrationFees(next.filter((f) => f.type === 0)); + setTuitionFees(next.filter((f) => f.type === 1)); }; + const allDiscounts = [ + ...(registrationDiscounts ?? []), + ...(tuitionDiscounts ?? []), + ].sort( + (a, b) => a.type - b.type || (a.name ?? '').localeCompare(b.name ?? '') + ); + + const setAllDiscounts = (updater) => { + const next = + typeof updater === 'function' ? updater(allDiscounts) : updater; + setRegistrationDiscounts(next.filter((d) => d.type === 0)); + setTuitionDiscounts(next.filter((d) => d.type === 1)); + }; + + const allPaymentPlans = [ + ...(registrationPaymentPlans ?? []), + ...(tuitionPaymentPlans ?? []), + ]; + const allPaymentModes = [ + ...(registrationPaymentModes ?? []), + ...(tuitionPaymentModes ?? []), + ]; + return ( -
-
-
- - Frais d'inscription - -
-
+
+ {/* Tableau unique des frais */} + { + const setter = + feeData.type === 0 ? setRegistrationFees : setTuitionFees; + return handleCreate(BE_SCHOOL_FEES_URL, feeData, setter); + }} + handleEdit={(id, data) => { + const fee = allFees.find((f) => f.id === id); + const feeType = data.type ?? fee?.type; + const setter = feeType === 0 ? setRegistrationFees : setTuitionFees; + return handleEdit(BE_SCHOOL_FEES_URL, id, data, setter); + }} + handleDelete={(id) => { + const fee = allFees.find((f) => f.id === id); + const setter = fee?.type === 0 ? setRegistrationFees : setTuitionFees; + return handleDelete(BE_SCHOOL_FEES_URL, id, setter); + }} + /> -
- - handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setRegistrationFees) - } - handleEdit={(id, updatedData) => - handleEdit( - `${BE_SCHOOL_FEES_URL}`, - id, - updatedData, - setRegistrationFees - ) - } - handleDelete={(id) => - handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setRegistrationFees) - } - type={0} - /> -
-
- - handleCreate( - `${BE_SCHOOL_DISCOUNTS_URL}`, - newData, - setRegistrationDiscounts - ) - } - handleEdit={(id, updatedData) => - handleEdit( - `${BE_SCHOOL_DISCOUNTS_URL}`, - id, - updatedData, - setRegistrationDiscounts - ) - } - handleDelete={(id) => - handleDelete( - `${BE_SCHOOL_DISCOUNTS_URL}`, - id, - setRegistrationDiscounts - ) - } - onDiscountDelete={(id) => handleDiscountDelete(id, 0)} - type={0} - /> -
+ {/* Tableau unique des réductions */} + { + const setter = + data.type === 0 ? setRegistrationDiscounts : setTuitionDiscounts; + return handleCreate(BE_SCHOOL_DISCOUNTS_URL, data, setter); + }} + handleEdit={(id, data) => { + const discount = allDiscounts.find((d) => d.id === id); + const discountType = data.type ?? discount?.type; + const setter = + discountType === 0 ? setRegistrationDiscounts : setTuitionDiscounts; + return handleEdit(BE_SCHOOL_DISCOUNTS_URL, id, data, setter); + }} + handleDelete={(id) => { + const discount = allDiscounts.find((d) => d.id === id); + const setter = + discount?.type === 0 + ? setRegistrationDiscounts + : setTuitionDiscounts; + return handleDelete(BE_SCHOOL_DISCOUNTS_URL, id, setter); + }} + onDiscountDelete={(id) => { + // Retire la réduction des frais concernés + setAllFees((prevFees) => + prevFees.map((fee) => ({ + ...fee, + discounts: fee.discounts.filter((dId) => dId !== id), + })) + ); + }} + /> + + {/* Plans et modes de paiement communs */}
- handleCreate( - `${BE_SCHOOL_PAYMENT_PLANS_URL}`, - newData, - setRegistrationPaymentPlans - ) - } - handleDelete={(id) => - handleDelete( - `${BE_SCHOOL_PAYMENT_PLANS_URL}`, - id, - setRegistrationPaymentPlans - ) - } - type={0} + allPaymentPlans={allPaymentPlans} + handleCreate={(data) => { + const setter = + data.type === 0 + ? setRegistrationPaymentPlans + : setTuitionPaymentPlans; + return handleCreate(BE_SCHOOL_PAYMENT_PLANS_URL, data, setter); + }} + handleDelete={(id) => { + const plan = allPaymentPlans.find((p) => p.id === id); + const setter = + plan?.type === 0 + ? setRegistrationPaymentPlans + : setTuitionPaymentPlans; + return handleDelete(BE_SCHOOL_PAYMENT_PLANS_URL, id, setter); + }} />
- handleCreate( - `${BE_SCHOOL_PAYMENT_MODES_URL}`, - newData, - setRegistrationPaymentModes - ) - } - handleDelete={(id) => - handleDelete( - `${BE_SCHOOL_PAYMENT_MODES_URL}`, - id, - setRegistrationPaymentModes - ) - } - type={0} - /> -
-
- -
-
- - Frais de scolarité - -
-
- -
- - handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setTuitionFees) - } - handleEdit={(id, updatedData) => - handleEdit(`${BE_SCHOOL_FEES_URL}`, id, updatedData, setTuitionFees) - } - handleDelete={(id) => - handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setTuitionFees) - } - type={1} - /> -
-
- - handleCreate( - `${BE_SCHOOL_DISCOUNTS_URL}`, - newData, - setTuitionDiscounts - ) - } - handleEdit={(id, updatedData) => - handleEdit( - `${BE_SCHOOL_DISCOUNTS_URL}`, - id, - updatedData, - setTuitionDiscounts - ) - } - handleDelete={(id) => - handleDelete(`${BE_SCHOOL_DISCOUNTS_URL}`, id, setTuitionDiscounts) - } - onDiscountDelete={(id) => handleDiscountDelete(id, 1)} - type={1} - /> -
-
-
- - handleCreate( - `${BE_SCHOOL_PAYMENT_PLANS_URL}`, - newData, - setTuitionPaymentPlans - ) - } - handleDelete={(id) => - handleDelete( - `${BE_SCHOOL_PAYMENT_PLANS_URL}`, - id, - setTuitionPaymentPlans - ) - } - type={1} - /> -
-
- - handleCreate( - `${BE_SCHOOL_PAYMENT_MODES_URL}`, - newData, - setTuitionPaymentModes - ) - } - handleDelete={(id) => - handleDelete( - `${BE_SCHOOL_PAYMENT_MODES_URL}`, - id, - setTuitionPaymentModes - ) - } - type={1} + allPaymentModes={allPaymentModes} + handleCreate={(data) => { + const setter = + data.type === 0 + ? setRegistrationPaymentModes + : setTuitionPaymentModes; + return handleCreate(BE_SCHOOL_PAYMENT_MODES_URL, data, setter); + }} + handleDelete={(id) => { + const mode = allPaymentModes.find((m) => m.id === id); + const setter = + mode?.type === 0 + ? setRegistrationPaymentModes + : setTuitionPaymentModes; + return handleDelete(BE_SCHOOL_PAYMENT_MODES_URL, id, setter); + }} />
diff --git a/Front-End/src/components/Structure/Tarification/FeesSection.js b/Front-End/src/components/Structure/Tarification/FeesSection.js index 310a590..c2ef70c 100644 --- a/Front-End/src/components/Structure/Tarification/FeesSection.js +++ b/Front-End/src/components/Structure/Tarification/FeesSection.js @@ -9,6 +9,13 @@ import SectionHeader from '@/components/SectionHeader'; import { useEstablishment } from '@/context/EstablishmentContext'; import AlertMessage from '@/components/AlertMessage'; +const FEE_TYPE_LABELS = { 0: 'Inscription', 1: 'Scolarité' }; + +/** + * @param {boolean} [unified=false] - true : tableau mixte inscription+scolarité avec colonne TYPE. + * Dans ce cas, `fees` contient les frais des deux types et `handleCreate`/`handleEdit`/`handleDelete` + * sont des fonctions (url, data, setter) déjà partiellement appliquées par le parent. + */ const FeesSection = ({ fees, setFees, @@ -16,6 +23,7 @@ const FeesSection = ({ handleEdit, handleDelete, type, + unified = false, subscriptionMode = false, selectedFees, handleFeeSelection, @@ -29,8 +37,9 @@ const FeesSection = ({ const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); - const labelTypeFrais = - type === 0 ? "Frais d'inscription" : 'Frais de scolarité'; + // En mode unifié, le type effectif est celui du frais ou celui du formulaire de création + const labelTypeFrais = (feeType) => + feeType === 0 ? "Frais d'inscription" : 'Frais de scolarité'; const { selectedEstablishmentId } = useEstablishment(); // Récupération des messages d'erreur @@ -44,10 +53,8 @@ const FeesSection = ({ name: '', base_amount: '', description: '', - validity_start_date: '', - validity_end_date: '', discounts: [], - type: type, + type: unified ? 0 : type, establishment: selectedEstablishmentId, }); }; @@ -91,8 +98,8 @@ const FeesSection = ({ const handleUpdateFee = (id, updatedFee) => { if (updatedFee.name && updatedFee.base_amount) { handleEdit(id, updatedFee) - .then((updatedFee) => { - setFees(fees.map((fee) => (fee.id === id ? updatedFee : fee))); + .then((updated) => { + setFees(fees.map((fee) => (fee.id === id ? updated : fee))); setEditingFee(null); setLocalErrors({}); }) @@ -193,6 +200,21 @@ const FeesSection = ({ handleChange, 'Description' ); + case 'TYPE': + return ( + + ); case 'ACTIONS': return (
@@ -222,6 +244,7 @@ const FeesSection = ({ return null; } } else { + const feeLabel = labelTypeFrais(fee.type); switch (column) { case 'NOM': return fee.name; @@ -231,6 +254,18 @@ const FeesSection = ({ return fee.updated_at_formatted; case 'DESCRIPTION': return fee.description; + case 'TYPE': + return ( + + {FEE_TYPE_LABELS[fee.type]} + + ); case 'ACTIONS': return (
@@ -257,22 +292,20 @@ const FeesSection = ({ onClick={() => { setRemovePopupVisible(true); setRemovePopupMessage( - `Attentions ! \nVous êtes sur le point de supprimer un ${labelTypeFrais} .\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?` + `Attentions ! \nVous êtes sur le point de supprimer un ${feeLabel}.\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?` ); setRemovePopupOnConfirm(() => () => { handleRemoveFee(fee.id) .then((data) => { logger.debug('Success:', data); - setPopupMessage( - labelTypeFrais + ' correctement supprimé' - ); + setPopupMessage(feeLabel + ' correctement supprimé'); setPopupVisible(true); setRemovePopupVisible(false); }) .catch((error) => { logger.error('Error archiving data:', error); setPopupMessage( - 'Erreur lors de la suppression du ' + labelTypeFrais + 'Erreur lors de la suppression du ' + feeLabel ); setPopupVisible(true); setRemovePopupVisible(false); @@ -307,42 +340,33 @@ const FeesSection = ({ { name: 'NOM', label: 'Nom' }, { name: 'DESCRIPTION', label: 'Description' }, { name: 'MONTANT', label: 'Montant de base' }, + ...(unified ? [{ name: 'TYPE', label: 'Type' }] : []), { name: '', label: 'Sélection' }, ] : [ { name: 'NOM', label: 'Nom' }, { name: 'MONTANT', label: 'Montant de base' }, { name: 'DESCRIPTION', label: 'Description' }, + ...(unified ? [{ name: 'TYPE', label: 'Type' }] : []), { name: 'MISE A JOUR', label: 'Date mise à jour' }, { name: 'ACTIONS', label: 'Actions' }, ]; - let emptyMessage; - if (type === 0) { - emptyMessage = ( - - ); - } else { - emptyMessage = ( - - ); - } + const emptyMessage = ( + + ); return (
{!subscriptionMode && ( diff --git a/Front-End/src/test/FeesSection.test.js b/Front-End/src/test/FeesSection.test.js new file mode 100644 index 0000000..ca8a754 --- /dev/null +++ b/Front-End/src/test/FeesSection.test.js @@ -0,0 +1,211 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import FeesSection from '@/components/Structure/Tarification/FeesSection'; + +/* eslint-disable react/display-name */ + +// Mock du contexte établissement +jest.mock('@/context/EstablishmentContext', () => ({ + useEstablishment: () => ({ selectedEstablishmentId: 1 }), +})); + +// Mock des composants UI pour isoler les tests unitaires +jest.mock( + '@/components/Table', + () => + ({ data, columns, renderCell, emptyMessage }) => { + if (!data || data.length === 0) + return
{emptyMessage}
; + return ( + + + {data.map((row) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
{renderCell(row, col.name)}
+ ); + } +); + +jest.mock( + '@/components/Popup', + () => + ({ isOpen, message, onConfirm, onCancel }) => + isOpen ? ( +
+

{message}

+ + +
+ ) : null +); + +jest.mock('@/components/SectionHeader', () => ({ title, button, onClick }) => ( +
+

{title}

+ {button && } +
+)); + +jest.mock('@/components/AlertMessage', () => ({ title, message }) => ( +
+ {title} +

{message}

+
+)); + +jest.mock( + '@/components/Form/InputText', + () => + ({ name, value, onChange, placeholder }) => ( + + ) +); + +jest.mock('@/components/Form/CheckBox', () => ({ item, handleChange }) => ( + +)); + +jest.mock('@/utils/logger', () => ({ + error: jest.fn(), + debug: jest.fn(), +})); + +const mockFee = { + id: 1, + name: 'Frais test', + base_amount: '200.00', + description: 'Description test', + updated_at_formatted: '01-01-2026 10:00', + is_active: true, + discounts: [], + type: 0, +}; + +describe('FeesSection - type inscription (type=0)', () => { + const defaultProps = { + fees: [mockFee], + setFees: jest.fn(), + handleCreate: jest.fn(), + handleEdit: jest.fn(), + handleDelete: jest.fn(), + type: 0, + }; + + it('affiche le titre "Liste des frais"', () => { + render(); + expect(screen.getByText('Liste des frais')).toBeInTheDocument(); + }); + + it('affiche les données du frais dans le tableau', () => { + render(); + expect(screen.getByText('Frais test')).toBeInTheDocument(); + expect(screen.getByText('200.00 €')).toBeInTheDocument(); + }); + + it('affiche le bouton Ajouter en mode gestion', () => { + render(); + expect(screen.getByText('Ajouter')).toBeInTheDocument(); + }); + + it('affiche le message vide quand la liste est vide', () => { + render(); + expect(screen.getByTestId('empty-message')).toBeInTheDocument(); + expect(screen.getByText('Aucun frais enregistré')).toBeInTheDocument(); + }); +}); + +describe('FeesSection - type scolarité (type=1)', () => { + const defaultProps = { + fees: [{ ...mockFee, type: 1 }], + setFees: jest.fn(), + handleCreate: jest.fn(), + handleEdit: jest.fn(), + handleDelete: jest.fn(), + type: 1, + }; + + it('affiche le titre "Liste des frais" aussi pour type=1', () => { + render(); + expect(screen.getByText('Liste des frais')).toBeInTheDocument(); + }); + + it('affiche le message vide générique quand la liste est vide', () => { + render(); + expect(screen.getByText('Aucun frais enregistré')).toBeInTheDocument(); + }); +}); + +describe('FeesSection - mode sélection (subscriptionMode)', () => { + const defaultProps = { + fees: [mockFee], + setFees: jest.fn(), + handleCreate: jest.fn(), + handleEdit: jest.fn(), + handleDelete: jest.fn(), + type: 0, + subscriptionMode: true, + selectedFees: [], + handleFeeSelection: jest.fn(), + }; + + it('cache le header section en mode subscription', () => { + render(); + expect(screen.queryByText('Liste des frais')).not.toBeInTheDocument(); + }); + + it("n'affiche pas le bouton Ajouter en mode subscription", () => { + render(); + expect(screen.queryByText('Ajouter')).not.toBeInTheDocument(); + }); +}); + +describe("FeesSection - création d'un nouveau frais", () => { + it('initialise le nouveau frais avec le bon type', () => { + const setFees = jest.fn(); + const handleCreate = jest.fn(() => + Promise.resolve({ id: 2, name: 'Nouveau', base_amount: '100' }) + ); + + render( + + ); + + fireEvent.click(screen.getByText('Ajouter')); + // Le nouveau frais doit apparaître dans le tableau + expect(screen.queryByPlaceholderText('Nom des frais')).toBeInTheDocument(); + }); + + it('initialise le nouveau frais avec type=1 pour les frais de scolarité', () => { + render( + + ); + + fireEvent.click(screen.getByText('Ajouter')); + expect(screen.queryByPlaceholderText('Nom des frais')).toBeInTheDocument(); + }); +}); From 7576b5a68caba9b4b0331a2b26ddfa573c3953a6 Mon Sep 17 00:00:00 2001 From: Luc SORIGNET Date: Sun, 15 Mar 2026 12:09:18 +0100 Subject: [PATCH 2/3] test(frontend): ajout tests unitaires Jest composants frais [#NEWTS-9] --- Front-End/src/test/FeeTypeSection.test.js | 149 ++++++++++++++++++++++ Front-End/src/test/FeesManagement.test.js | 145 +++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 Front-End/src/test/FeeTypeSection.test.js create mode 100644 Front-End/src/test/FeesManagement.test.js diff --git a/Front-End/src/test/FeeTypeSection.test.js b/Front-End/src/test/FeeTypeSection.test.js new file mode 100644 index 0000000..0fde419 --- /dev/null +++ b/Front-End/src/test/FeeTypeSection.test.js @@ -0,0 +1,149 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import FeeTypeSection from '@/components/Structure/Tarification/FeeTypeSection'; + +// Mock du contexte établissement +jest.mock('@/context/EstablishmentContext', () => ({ + useEstablishment: () => ({ selectedEstablishmentId: 1 }), +})); + +// Mock des sous-composants pour isoler FeeTypeSection +jest.mock( + '@/components/Structure/Tarification/FeesSection', + () => + function MockFeesSection({ type }) { + return ( +
+ FeesSection type={type} +
+ ); + } +); + +jest.mock( + '@/components/Structure/Tarification/DiscountsSection', + () => + function MockDiscountsSection({ type }) { + return ( +
+ DiscountsSection type={type} +
+ ); + } +); + +jest.mock( + '@/components/PaymentPlanSelector', + () => + function MockPaymentPlanSelector({ type }) { + return ( +
+ PaymentPlanSelector type={type} +
+ ); + } +); + +jest.mock( + '@/components/PaymentModeSelector', + () => + function MockPaymentModeSelector({ type }) { + return ( +
+ PaymentModeSelector type={type} +
+ ); + } +); + +jest.mock('@/utils/Url', () => ({ + BE_SCHOOL_FEES_URL: '/api/fees', + BE_SCHOOL_DISCOUNTS_URL: '/api/discounts', + BE_SCHOOL_PAYMENT_PLANS_URL: '/api/payment-plans', + BE_SCHOOL_PAYMENT_MODES_URL: '/api/payment-modes', +})); + +const defaultProps = { + title: "Frais d'inscription", + fees: [], + setFees: jest.fn(), + discounts: [], + setDiscounts: jest.fn(), + paymentPlans: [], + setPaymentPlans: jest.fn(), + paymentModes: [], + setPaymentModes: jest.fn(), + type: 0, + handleCreate: jest.fn(), + handleEdit: jest.fn(), + handleDelete: jest.fn(), + onDiscountDelete: jest.fn(), +}; + +describe('FeeTypeSection - type inscription (type=0)', () => { + it('affiche le titre passé en props', () => { + render(); + expect(screen.getByText("Frais d'inscription")).toBeInTheDocument(); + }); + + it('rend le composant FeesSection avec le bon type', () => { + render(); + expect(screen.getByTestId('fees-section-type-0')).toBeInTheDocument(); + }); + + it('rend le composant DiscountsSection avec le bon type', () => { + render(); + expect(screen.getByTestId('discounts-section-type-0')).toBeInTheDocument(); + }); + + it('rend le composant PaymentPlanSelector avec le bon type', () => { + render(); + expect(screen.getByTestId('payment-plan-type-0')).toBeInTheDocument(); + }); + + it('rend le composant PaymentModeSelector avec le bon type', () => { + render(); + expect(screen.getByTestId('payment-mode-type-0')).toBeInTheDocument(); + }); +}); + +describe('FeeTypeSection - type scolarité (type=1)', () => { + const tuitionProps = { + ...defaultProps, + title: 'Frais de scolarité', + type: 1, + }; + + it('affiche le titre "Frais de scolarité"', () => { + render(); + expect(screen.getByText('Frais de scolarité')).toBeInTheDocument(); + }); + + it('rend tous les sous-composants avec type=1', () => { + render(); + expect(screen.getByTestId('fees-section-type-1')).toBeInTheDocument(); + expect(screen.getByTestId('discounts-section-type-1')).toBeInTheDocument(); + expect(screen.getByTestId('payment-plan-type-1')).toBeInTheDocument(); + expect(screen.getByTestId('payment-mode-type-1')).toBeInTheDocument(); + }); +}); + +describe('FeeTypeSection - transmission des handlers', () => { + it('passe les fonctions handleCreate, handleEdit, handleDelete aux sous-composants', () => { + const handleCreate = jest.fn(); + const handleEdit = jest.fn(); + const handleDelete = jest.fn(); + + // On vérifie que le composant se rend sans erreur avec les handlers + expect(() => + render( + + ) + ).not.toThrow(); + }); +}); diff --git a/Front-End/src/test/FeesManagement.test.js b/Front-End/src/test/FeesManagement.test.js new file mode 100644 index 0000000..9054d05 --- /dev/null +++ b/Front-End/src/test/FeesManagement.test.js @@ -0,0 +1,145 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import FeesManagement from '@/components/Structure/Tarification/FeesManagement'; + +jest.mock('@/context/EstablishmentContext', () => ({ + useEstablishment: () => ({ selectedEstablishmentId: 1 }), +})); + +jest.mock('@/utils/Url', () => ({ + BE_SCHOOL_FEES_URL: '/api/fees', + BE_SCHOOL_DISCOUNTS_URL: '/api/discounts', + BE_SCHOOL_PAYMENT_PLANS_URL: '/api/payment-plans', + BE_SCHOOL_PAYMENT_MODES_URL: '/api/payment-modes', +})); + +jest.mock('@/utils/logger', () => ({ error: jest.fn() })); + +jest.mock( + '@/components/Structure/Tarification/FeesSection', + () => + function MockFeesSection({ fees, unified }) { + return ( +
+ {fees.map((f) => ( + {f.name} + ))} +
+ ); + } +); + +jest.mock( + '@/components/Structure/Tarification/DiscountsSection', + () => + function MockDiscountsSection({ discounts, unified }) { + return ( +
+ {discounts.map((d) => ( + {d.name} + ))} +
+ ); + } +); + +jest.mock( + '@/components/PaymentPlanSelector', + () => + function MockPaymentPlanSelector({ allPaymentPlans }) { + return ( +
+ {(allPaymentPlans ?? []).length} plans +
+ ); + } +); + +jest.mock( + '@/components/PaymentModeSelector', + () => + function MockPaymentModeSelector({ allPaymentModes }) { + return ( +
+ {(allPaymentModes ?? []).length} modes +
+ ); + } +); + +const defaultProps = { + registrationFees: [], + setRegistrationFees: jest.fn(), + tuitionFees: [], + setTuitionFees: jest.fn(), + registrationDiscounts: [], + setRegistrationDiscounts: jest.fn(), + tuitionDiscounts: [], + setTuitionDiscounts: jest.fn(), + registrationPaymentPlans: [], + setRegistrationPaymentPlans: jest.fn(), + tuitionPaymentPlans: [], + setTuitionPaymentPlans: jest.fn(), + registrationPaymentModes: [], + setRegistrationPaymentModes: jest.fn(), + tuitionPaymentModes: [], + setTuitionPaymentModes: jest.fn(), + handleCreate: jest.fn(), + handleEdit: jest.fn(), + handleDelete: jest.fn(), +}; + +describe('FeesManagement - vue unifiée', () => { + it('affiche la section des frais en mode unifié', () => { + render(); + const section = screen.getByTestId('fees-section'); + expect(section).toBeInTheDocument(); + expect(section).toHaveAttribute('data-unified', 'true'); + }); + + it('affiche la section des réductions en mode unifié', () => { + render(); + const section = screen.getByTestId('discounts-section'); + expect(section).toBeInTheDocument(); + expect(section).toHaveAttribute('data-unified', 'true'); + }); + + it('affiche le sélecteur de plans de paiement', () => { + render(); + expect(screen.getByTestId('payment-plan-selector')).toBeInTheDocument(); + }); + + it('affiche le sélecteur de modes de paiement', () => { + render(); + expect(screen.getByTestId('payment-mode-selector')).toBeInTheDocument(); + }); + + it('fusionne les frais inscription et scolarité en une seule liste', () => { + render( + + ); + expect(screen.getByText('Inscription A')).toBeInTheDocument(); + expect(screen.getByText('Scolarité B')).toBeInTheDocument(); + }); + + it('fusionne les plans de paiement inscription et scolarité', () => { + render( + + ); + expect(screen.getByText('2 plans')).toBeInTheDocument(); + }); +}); From c96b9562a2b4e31353947e887ad0d85ed2164a15 Mon Sep 17 00:00:00 2001 From: Luc SORIGNET Date: Sun, 15 Mar 2026 12:09:31 +0100 Subject: [PATCH 3/3] chore(ci): ajout test_settings.py et SKILL run-tests --- .github/copilot-instructions.md | 1 + .github/instructions/run-tests.instruction.md | 53 +++++++++++++++ Back-End/N3wtSchool/test_settings.py | 66 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 .github/instructions/run-tests.instruction.md create mode 100644 Back-End/N3wtSchool/test_settings.py 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/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