Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
- Lien d'inscription protégé par un jeton (FormTemplate.access_token, /inscription/<token>), avec régénération depuis l'écran de configuration. - Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel. - Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée. - Infobulles (HelpTip et détail de cellule de la grille des besoins) repositionnées en position: fixed via JS, bornées à l'écran. - Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le jeton d'inscription), sujet retiré du log de remise des emails (pouvait porter un nom de bénévole), photo du formulaire public revalidée par décodage réel avant stockage, export Excel protégé contre l'injection de formule, sondage du statut de paiement limité en fréquence, pyjwt mis à jour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a2b748566b
commit
096b7cd26a
@@ -11,6 +11,7 @@ from app.auth.rate_limit import (
|
||||
form_submission_limiter,
|
||||
login_limiter,
|
||||
magic_link_limiter,
|
||||
pending_status_limiter,
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
@@ -36,6 +37,7 @@ def _reset_rate_limiters():
|
||||
login_limiter.reset()
|
||||
magic_link_limiter.reset()
|
||||
form_submission_limiter.reset()
|
||||
pending_status_limiter.reset()
|
||||
yield
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import io
|
||||
import uuid
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from pypdf import PdfReader
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Assignment, Availability, Role, Volunteer
|
||||
from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
@@ -426,7 +427,102 @@ def test_export_volunteers_as_excel(client, edition, session):
|
||||
assert row[6] == "Chilla"
|
||||
|
||||
|
||||
def test_export_neutralizes_formula_injection(client, edition, session):
|
||||
# CWE-1236 : un bénévole inscrit via le formulaire public (sans
|
||||
# authentification) contrôle son nom, son commentaire, etc. — ouverts
|
||||
# tels quels dans Excel par la coordination, une valeur comme
|
||||
# `=cmd|'/c calc'!A0` y serait exécutée comme une formule. Un préfixe
|
||||
# `'` force l'interprétation en texte (voir `app/exporting/xlsx.py`).
|
||||
volunteer = make_volunteer(
|
||||
session, edition, first_name="=cmd|'/c calc'!A0", last_name="Martin"
|
||||
)
|
||||
client.patch(
|
||||
_url(edition, f"/{volunteer.id}"),
|
||||
json={"comment": "@SUM(1+1)*cmd|' /C calc'!A0"},
|
||||
)
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
workbook = load_workbook(io.BytesIO(resp.content))
|
||||
row = list(workbook.active.iter_rows(values_only=True))[1]
|
||||
assert row[0] == "'=cmd|'/c calc'!A0"
|
||||
assert row[17] == "'@SUM(1+1)*cmd|' /C calc'!A0"
|
||||
|
||||
|
||||
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).get(_url(edition, "/export"))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def _mark_mecenat_paid(session, volunteer, *, amount=50.0):
|
||||
"""Simule l'état d'un bénévole ayant payé un type de paiement « mécénat »
|
||||
(voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
|
||||
marqueur qui distingue ce chemin d'un simple paiement classique."""
|
||||
volunteer.fee_payment_status = FeePaymentStatus.PAID
|
||||
volunteer.fee_amount = amount
|
||||
volunteer.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
|
||||
volunteer.mecenat_address = "12 rue des Lilas"
|
||||
volunteer.mecenat_city = "Paris"
|
||||
volunteer.mecenat_postal_code = "75020"
|
||||
volunteer.mecenat_country = "France"
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_mecenat_receipts_pdf_includes_only_paid_mecenat_donors(
|
||||
client, edition, session
|
||||
):
|
||||
paid_mecenat = make_volunteer(
|
||||
session, edition, first_name="Alex", last_name="Martin"
|
||||
)
|
||||
_mark_mecenat_paid(session, paid_mecenat)
|
||||
|
||||
# Cotisation classique payée (pas de mécénat) : ne doit pas apparaître.
|
||||
paid_classique = make_volunteer(
|
||||
session, edition, first_name="Sam", last_name="Petit"
|
||||
)
|
||||
paid_classique.fee_payment_status = FeePaymentStatus.PAID
|
||||
paid_classique.fee_amount = 15.0
|
||||
paid_classique.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
|
||||
session.add(paid_classique)
|
||||
session.commit()
|
||||
|
||||
# Mécénat choisi mais paiement non abouti : ne doit pas apparaître.
|
||||
failed_mecenat = make_volunteer(
|
||||
session, edition, first_name="Jo", last_name="Dupuis"
|
||||
)
|
||||
failed_mecenat.fee_payment_status = FeePaymentStatus.FAILED
|
||||
failed_mecenat.mecenat_address = "1 rue X"
|
||||
session.add(failed_mecenat)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
assert f"recus-mecenat-{edition.year}.pdf" in resp.headers["content-disposition"]
|
||||
|
||||
reader = PdfReader(io.BytesIO(resp.content))
|
||||
# Un reçu = 2 pages (le cerfa officiel) — un seul bénévole qualifie ici.
|
||||
assert len(reader.pages) == 2
|
||||
donor_page_text = reader.pages[1].extract_text()
|
||||
assert "Martin" in donor_page_text
|
||||
assert "Alex" in donor_page_text
|
||||
assert "50,00" in donor_page_text
|
||||
assert "05/03/2026" in donor_page_text
|
||||
assert "Petit" not in donor_page_text
|
||||
|
||||
|
||||
def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
|
||||
resp = client.get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "mecenat.no_receipts"
|
||||
|
||||
|
||||
def test_mecenat_receipts_is_coordinator_only(
|
||||
edition, session, make_account, as_account
|
||||
):
|
||||
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
_mark_mecenat_paid(session, volunteer)
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -106,3 +106,32 @@ def test_render_magic_link_uses_translations():
|
||||
assert subject
|
||||
assert "https://x/connexion?token=abc" in body
|
||||
assert "30" in body
|
||||
|
||||
|
||||
def test_message_without_logo_has_no_inline_image():
|
||||
message = sender.build_message("a@b.fr", "Sujet", "Corps")
|
||||
html_part = message.get_body(preferencelist=("html",))
|
||||
assert "cid:" not in html_part.get_content()
|
||||
assert not any(part.get_content_type() == "image/png" for part in message.walk())
|
||||
|
||||
|
||||
def test_logo_is_embedded_inline_never_as_an_external_url():
|
||||
# Le logo est une pièce jointe « inline » (Content-ID), jamais une URL :
|
||||
# `GET .../form/logo` exige le `access_token` du formulaire public
|
||||
# depuis l'étape 27 — une URL externe dans l'email l'aurait exposé à
|
||||
# quiconque lit la source du message (voir `app/email/branding.py`).
|
||||
logo_bytes = b"\x89PNG\r\n\x1a\n" + b"fake-png-bytes"
|
||||
message = sender.build_message(
|
||||
"a@b.fr", "Sujet", "Corps", logo=(logo_bytes, "image/png")
|
||||
)
|
||||
|
||||
html_part = message.get_body(preferencelist=("html",))
|
||||
assert "cid:edition-logo" in html_part.get_content()
|
||||
assert "access_token" not in html_part.get_content()
|
||||
assert "http://" not in html_part.get_content()
|
||||
assert "https://" not in html_part.get_content()
|
||||
|
||||
image_parts = [p for p in message.walk() if p.get_content_type() == "image/png"]
|
||||
assert len(image_parts) == 1
|
||||
assert image_parts[0].get_content() == logo_bytes
|
||||
assert image_parts[0]["Content-ID"] == "<edition-logo>"
|
||||
|
||||
+131
-13
@@ -6,14 +6,27 @@ import datetime as dt
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from app.auth.rate_limit import form_submission_limiter
|
||||
from app.models import PendingFormSubmission, Role, local_now
|
||||
import pytest
|
||||
|
||||
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter
|
||||
from app.models import FormTemplate, PendingFormSubmission, Role, local_now
|
||||
from app.sumup import SumUpCheckout
|
||||
from tests.factories import make_artist, make_position
|
||||
from tests.mail import plain_text
|
||||
|
||||
_MERCHANT = "MXXXXX"
|
||||
_API_KEY = "sup_sk_secret"
|
||||
# `FormTemplate.access_token` est aléatoire par défaut (voir
|
||||
# `app/models/domain.py`) — fixé pour toute inscription en attente créée
|
||||
# par ce module, pour ne pas avoir à le relire dynamiquement dans chaque
|
||||
# test (voir `_fixed_access_token` plus bas).
|
||||
_ACCESS_TOKEN = "test-access-token"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fixed_access_token(session, edition):
|
||||
session.add(FormTemplate(edition_id=edition.id, access_token=_ACCESS_TOKEN))
|
||||
session.commit()
|
||||
|
||||
|
||||
def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique"):
|
||||
@@ -58,7 +71,14 @@ def _form_url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/form{suffix}"
|
||||
|
||||
|
||||
def _public(edition, suffix):
|
||||
"""`GET .../form/public` et `GET .../form/logo` exigent `?token=...` en
|
||||
plus de l'édition (voir `app/api/forms.py:_require_access_token`)."""
|
||||
return f"{_form_url(edition, suffix)}?token={_ACCESS_TOKEN}"
|
||||
|
||||
|
||||
def _submit(client, edition, **fields):
|
||||
fields.setdefault("access_token", _ACCESS_TOKEN)
|
||||
return client.post(_form_url(edition, "/public/submit"), data=fields)
|
||||
|
||||
|
||||
@@ -112,11 +132,55 @@ def test_clearing_description_with_an_explicit_null(client, edition):
|
||||
assert resp.json()["description"] is None
|
||||
|
||||
|
||||
def test_public_form_requires_the_access_token(raw_client, edition):
|
||||
resp = raw_client.get(_form_url(edition, "/public"))
|
||||
assert resp.status_code == 422 # `token` absent
|
||||
|
||||
resp = raw_client.get(_form_url(edition, "/public?token=wrong"))
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"]["code"] == "error.not_found"
|
||||
|
||||
|
||||
def test_submitting_without_the_right_access_token_is_rejected(raw_client, edition):
|
||||
resp = raw_client.post(
|
||||
_form_url(edition, "/public/submit"),
|
||||
data={
|
||||
"first_name": "Alex",
|
||||
"last_name": "Martin",
|
||||
"hcaptcha_token": "x",
|
||||
"access_token": "wrong",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"]["code"] == "error.not_found"
|
||||
|
||||
|
||||
def test_regenerating_the_access_token_invalidates_the_old_link(
|
||||
client, raw_client, edition
|
||||
):
|
||||
old_token = client.get(_form_url(edition)).json()["access_token"]
|
||||
assert old_token == _ACCESS_TOKEN
|
||||
|
||||
resp = client.post(_form_url(edition, "/regenerate-token"))
|
||||
assert resp.status_code == 200
|
||||
new_token = resp.json()["access_token"]
|
||||
assert new_token != old_token
|
||||
|
||||
assert raw_client.get(_form_url(edition, f"/public?token={old_token}")).status_code == 404
|
||||
assert raw_client.get(_form_url(edition, f"/public?token={new_token}")).status_code == 200
|
||||
|
||||
|
||||
def test_regenerate_token_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).post(_form_url(edition, "/regenerate-token"))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_sumup_not_configured_by_default(client, raw_client, edition):
|
||||
body = client.get(_form_url(edition)).json()
|
||||
assert body["sumup_merchant_code"] is None
|
||||
assert body["sumup_api_key_set"] is False
|
||||
public = raw_client.get(_form_url(edition, "/public")).json()
|
||||
public = raw_client.get(_public(edition, "/public")).json()
|
||||
assert public["sumup_enabled"] is False
|
||||
assert public["payment_options"] == []
|
||||
|
||||
@@ -134,10 +198,10 @@ def test_setting_sumup_credentials_never_echoes_the_api_key(client, raw_client,
|
||||
|
||||
# Identifiants seuls, sans aucun type de paiement : pas encore activable.
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||||
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is False
|
||||
assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is False
|
||||
|
||||
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
|
||||
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is True
|
||||
assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is True
|
||||
|
||||
|
||||
def test_payment_options_start_empty(client, edition):
|
||||
@@ -162,7 +226,7 @@ def test_creating_and_listing_payment_options(client, raw_client, edition):
|
||||
assert [o["label"] for o in listed] == ["Cotisation classique", "Mécénat"]
|
||||
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||||
public_options = raw_client.get(_form_url(edition, "/public")).json()["payment_options"]
|
||||
public_options = raw_client.get(_public(edition, "/public")).json()["payment_options"]
|
||||
assert [o["label"] for o in public_options] == ["Cotisation classique", "Mécénat"]
|
||||
assert [o["amount"] for o in public_options] == [20, 50]
|
||||
|
||||
@@ -251,7 +315,7 @@ def test_logo_is_coordinator_only(edition, make_account, as_account):
|
||||
|
||||
def test_no_logo_by_default(client, raw_client, edition):
|
||||
assert client.get(_form_url(edition)).json()["has_logo"] is False
|
||||
resp = raw_client.get(_form_url(edition, "/logo"))
|
||||
resp = raw_client.get(_public(edition, "/logo"))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@@ -264,7 +328,7 @@ def test_uploading_a_logo_and_reading_it_back_publicly(client, raw_client, editi
|
||||
|
||||
assert client.get(_form_url(edition)).json()["has_logo"] is True
|
||||
|
||||
logo = raw_client.get(_form_url(edition, "/logo"))
|
||||
logo = raw_client.get(_public(edition, "/logo"))
|
||||
assert logo.status_code == 200
|
||||
assert logo.headers["content-type"] == "image/png"
|
||||
assert logo.content == _PNG
|
||||
@@ -285,7 +349,7 @@ def test_public_config_reports_the_logo_and_description(client, raw_client, edit
|
||||
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||||
)
|
||||
|
||||
body = raw_client.get(_form_url(edition, "/public")).json()
|
||||
body = raw_client.get(_public(edition, "/public")).json()
|
||||
assert body["description"] == "Bienvenue au festival !"
|
||||
assert body["has_logo"] is True
|
||||
|
||||
@@ -296,7 +360,7 @@ def test_public_form_hides_positions_and_artists_unless_enabled(
|
||||
make_position(session, edition, name="Bar central")
|
||||
make_artist(session, edition, name="Chilla")
|
||||
|
||||
resp = raw_client.get(_form_url(edition, "/public"))
|
||||
resp = raw_client.get(_public(edition, "/public"))
|
||||
body = resp.json()
|
||||
assert body["positions"] == []
|
||||
assert body["artists"] == []
|
||||
@@ -305,7 +369,7 @@ def test_public_form_hides_positions_and_artists_unless_enabled(
|
||||
_form_url(edition, "/fields"),
|
||||
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]},
|
||||
)
|
||||
resp = raw_client.get(_form_url(edition, "/public"))
|
||||
resp = raw_client.get(_public(edition, "/public"))
|
||||
body = resp.json()
|
||||
assert [p["name"] for p in body["positions"]] == ["Bar central"]
|
||||
assert [a["name"] for a in body["artists"]] == ["Chilla"]
|
||||
@@ -429,7 +493,12 @@ def test_submitting_with_a_photo(raw_client, client, edition):
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
|
||||
resp = raw_client.post(
|
||||
_form_url(edition, "/public/submit"),
|
||||
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
|
||||
data={
|
||||
"first_name": "Alex",
|
||||
"last_name": "Martin",
|
||||
"hcaptcha_token": "x",
|
||||
"access_token": _ACCESS_TOKEN,
|
||||
},
|
||||
files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -447,13 +516,41 @@ def test_submitting_an_unsupported_photo_type_is_rejected(raw_client, client, ed
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
|
||||
resp = raw_client.post(
|
||||
_form_url(edition, "/public/submit"),
|
||||
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
|
||||
data={
|
||||
"first_name": "Alex",
|
||||
"last_name": "Martin",
|
||||
"hcaptcha_token": "x",
|
||||
"access_token": _ACCESS_TOKEN,
|
||||
},
|
||||
files={"photo": ("selfie.gif", b"not-a-real-gif", "image/gif")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
|
||||
|
||||
|
||||
def test_submitting_a_fake_photo_with_a_spoofed_content_type_is_rejected(
|
||||
raw_client, client, edition
|
||||
):
|
||||
# Endpoint public, sans authentification : le `Content-Type` du fichier
|
||||
# n'est qu'un en-tête fourni par l'appelant, jamais vérifié par
|
||||
# lui-même — un fichier qui prétend être une image sans en être une
|
||||
# planterait sinon la génération des badges/feuilles de poste plus tard
|
||||
# (voir `app/api/forms.py:_read_photo`).
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
|
||||
resp = raw_client.post(
|
||||
_form_url(edition, "/public/submit"),
|
||||
data={
|
||||
"first_name": "Alex",
|
||||
"last_name": "Martin",
|
||||
"hcaptcha_token": "x",
|
||||
"access_token": _ACCESS_TOKEN,
|
||||
},
|
||||
files={"photo": ("selfie.png", b"this is not actually a png file", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
|
||||
|
||||
|
||||
def test_submitting_more_than_two_position_wishes_is_rejected(
|
||||
raw_client, client, edition, session
|
||||
):
|
||||
@@ -884,3 +981,24 @@ def test_submitting_is_rate_limited_per_edition(raw_client, edition):
|
||||
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
def test_pending_status_polling_is_rate_limited_per_token(
|
||||
raw_client, client, edition, monkeypatch
|
||||
):
|
||||
# Chaque appel relit l'état réel auprès de SumUp (voir
|
||||
# `get_pending_status`) — sans limite, un jeton connu (ex. une
|
||||
# inscription abandonnée par son propre auteur) permettrait de
|
||||
# solliciter l'API SumUp de l'édition indéfiniment.
|
||||
option_id = _configure_sumup(client, edition)
|
||||
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: None)
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
payment_option_id=option_id,
|
||||
)
|
||||
token = resp.json()["pending_token"]
|
||||
|
||||
for _ in range(pending_status_limiter.max_attempts):
|
||||
pending_status_limiter.hit(token)
|
||||
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 429
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from app.mecenat.number_words import amount_in_words
|
||||
|
||||
|
||||
def test_zero_centimes_only_euros():
|
||||
assert amount_in_words(15.0) == "Quinze euros"
|
||||
|
||||
|
||||
def test_singular_euro_has_no_s():
|
||||
assert amount_in_words(1.0) == "Un euro"
|
||||
|
||||
|
||||
def test_et_un_forms():
|
||||
assert amount_in_words(21.0) == "Vingt et un euros"
|
||||
assert amount_in_words(31.0) == "Trente et un euros"
|
||||
assert amount_in_words(61.0) == "Soixante et un euros"
|
||||
|
||||
|
||||
def test_seventies_irregular_et_onze():
|
||||
assert amount_in_words(70.0) == "Soixante-dix euros"
|
||||
assert amount_in_words(71.0) == "Soixante et onze euros"
|
||||
assert amount_in_words(72.0) == "Soixante-douze euros"
|
||||
assert amount_in_words(79.0) == "Soixante-dix-neuf euros"
|
||||
|
||||
|
||||
def test_eighties_no_et_ever():
|
||||
assert amount_in_words(80.0) == "Quatre-vingts euros"
|
||||
assert amount_in_words(81.0) == "Quatre-vingt-un euros"
|
||||
assert amount_in_words(91.0) == "Quatre-vingt-onze euros"
|
||||
assert amount_in_words(99.0) == "Quatre-vingt-dix-neuf euros"
|
||||
|
||||
|
||||
def test_hundreds_agreement():
|
||||
assert amount_in_words(100.0) == "Cent euros"
|
||||
assert amount_in_words(101.0) == "Cent un euros"
|
||||
assert amount_in_words(200.0) == "Deux cents euros"
|
||||
assert amount_in_words(201.0) == "Deux cent un euros"
|
||||
|
||||
|
||||
def test_thousands_are_invariable():
|
||||
assert amount_in_words(1000.0) == "Mille euros"
|
||||
assert amount_in_words(2000.0) == "Deux mille euros"
|
||||
assert amount_in_words(1234.0) == "Mille deux cent trente-quatre euros"
|
||||
|
||||
|
||||
def test_cents_only_when_no_euros():
|
||||
assert amount_in_words(0.5) == "Cinquante centimes"
|
||||
assert amount_in_words(0.01) == "Un centime"
|
||||
|
||||
|
||||
def test_euros_and_cents_joined_with_et():
|
||||
assert amount_in_words(1234.56) == (
|
||||
"Mille deux cent trente-quatre euros et cinquante-six centimes"
|
||||
)
|
||||
assert amount_in_words(1.01) == "Un euro et un centime"
|
||||
|
||||
|
||||
def test_rounds_to_the_nearest_cent():
|
||||
# Erreurs de flottant (ex. 0.1 + 0.2) ne doivent jamais se voir.
|
||||
assert amount_in_words(19.999999) == "Vingt euros"
|
||||
@@ -1,6 +1,6 @@
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Edition, Volunteer
|
||||
from app.models import Edition, FeePaymentStatus, Volunteer
|
||||
from app.seed import DEMO_EDITION_NAME, build
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ def test_build_creates_a_realistic_dataset(session):
|
||||
assert summary.position_wishes > 0
|
||||
assert summary.artist_wishes > 0
|
||||
assert summary.companion_wishes > 0
|
||||
# Paiement de la cotisation : au moins un exemple de chaque chemin.
|
||||
assert summary.fee_paid_classique > 0
|
||||
assert summary.fee_paid_mecenat > 0
|
||||
|
||||
edition = session.exec(
|
||||
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
|
||||
@@ -30,6 +33,19 @@ def test_build_creates_a_realistic_dataset(session):
|
||||
).all()
|
||||
assert len(volunteers) == 120
|
||||
|
||||
paid = [v for v in volunteers if v.fee_payment_status == FeePaymentStatus.PAID]
|
||||
assert len(paid) == summary.fee_paid_classique + summary.fee_paid_mecenat
|
||||
mecenat = [v for v in paid if v.mecenat_address is not None]
|
||||
assert len(mecenat) == summary.fee_paid_mecenat
|
||||
for volunteer in mecenat:
|
||||
assert volunteer.mecenat_city and volunteer.mecenat_postal_code
|
||||
assert volunteer.mecenat_country == "France"
|
||||
assert volunteer.fee_payment_label == "Mécénat"
|
||||
classique = [v for v in paid if v.mecenat_address is None]
|
||||
for volunteer in classique:
|
||||
assert volunteer.fee_payment_label == "Cotisation classique"
|
||||
assert volunteer.fee_amount == 15.0
|
||||
|
||||
|
||||
def test_build_is_deterministic_and_idempotent(session):
|
||||
first = build(session)
|
||||
@@ -47,6 +63,8 @@ def test_build_is_deterministic_and_idempotent(session):
|
||||
assert second.position_wishes == first.position_wishes
|
||||
assert second.artist_wishes == first.artist_wishes
|
||||
assert second.companion_wishes == first.companion_wishes
|
||||
assert second.fee_paid_classique == first.fee_paid_classique
|
||||
assert second.fee_paid_mecenat == first.fee_paid_mecenat
|
||||
|
||||
editions = session.exec(
|
||||
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
|
||||
|
||||
Reference in New Issue
Block a user