Files
benevoles/backend/tests/test_forms_api.py
T
AdamThomasandClaude Opus 5.5 ccd769d04e Numéro d'ordre unique sur chaque reçu fiscal mécénat
Volunteer.mecenat_receipt_number (migration 0020, unique) : année du
paiement + rang sur 5 chiffres (202600001…), une seule numérotation pour
toutes les éditions, repartant à 1 chaque année. Attribué à la
confirmation du paiement, ou au premier export pour les bénévoles payés
avant (dans l'ordre des paiements), puis jamais réattribué — un reçu
regénéré garde son numéro. Attribution sérialisée par un verrou
pg_advisory_xact_lock (webhook et sondage peuvent confirmer deux
paiements à la fois). Imprimé centré dans la case « Numéro d'ordre du
reçu » en page 1 du cerfa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:48:05 +02:00

1092 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Formulaire public d'inscription : configuration (COORDINATION) et
soumission publique (aucune authentification)."""
import base64
import datetime as dt
import io
import uuid
import pytest
from pypdf import PdfReader
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter
from app.models import FormTemplate, PendingFormSubmission, Role, Volunteer, 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"):
"""Active le champ, renseigne les identifiants et crée un type de
paiement — renvoie son id, à passer en `payment_option_id` à `_submit`."""
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
client.patch(
_form_url(edition),
json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY},
)
resp = client.post(
_form_url(edition, "/payment-options"), json={"label": label, "amount": amount}
)
return resp.json()["id"]
def _pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="PAID"):
"""Monte le checkout ensuite renvoyé par `get_checkout` sur `status_after`
(`PAID`/`FAILED`/`EXPIRED`) — les tests posent d'abord `create_checkout`
et soumettent eux-mêmes, cette aide ne fait que la partie webhook."""
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(id=checkout_id, status=status_after, hosted_checkout_url=None),
)
return raw_client.post(
_form_url(edition, "/public/sumup-webhook"),
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": checkout_id},
)
def _pending_status(raw_client, edition, token):
return raw_client.get(_form_url(edition, f"/public/pending/{token}")).json()
# PNG 1×1 valide (transparent), le plus petit possible.
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
"42YAAAAASUVORK5CYII="
)
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)
def test_config_starts_open_with_no_fields(client, edition):
resp = client.get(_form_url(edition))
assert resp.status_code == 200
body = resp.json()
assert body["is_open"] is True
assert body["fields"] == []
def test_config_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
lead_client = as_account(lead)
assert lead_client.get(_form_url(edition)).status_code == 403
resp = lead_client.put(_form_url(edition, "/fields"), json={"fields": []})
assert resp.status_code == 403
def test_setting_fields_replaces_them_entirely(client, edition):
resp = client.put(
_form_url(edition, "/fields"),
json={"fields": ["EMAIL", "PHONE", "PHOTO"]},
)
assert resp.status_code == 200
assert sorted(resp.json()["fields"]) == ["EMAIL", "PHONE", "PHOTO"]
# Un second PUT remplace, ne cumule pas.
resp = client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
assert resp.json()["fields"] == ["EMAIL"]
def test_closing_the_form(client, edition):
resp = client.patch(_form_url(edition), json={"is_open": False})
assert resp.status_code == 200
assert resp.json()["is_open"] is False
def test_setting_description_does_not_touch_is_open(client, edition):
client.patch(_form_url(edition), json={"is_open": False})
resp = client.patch(_form_url(edition), json={"description": "Bienvenue !"})
assert resp.status_code == 200
assert resp.json()["description"] == "Bienvenue !"
assert resp.json()["is_open"] is False # inchangé, absent du corps
def test_clearing_description_with_an_explicit_null(client, edition):
client.patch(_form_url(edition), json={"description": "Bienvenue !"})
resp = client.patch(_form_url(edition), json={"description": None})
assert resp.status_code == 200
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(_public(edition, "/public")).json()
assert public["sumup_enabled"] is False
assert public["payment_options"] == []
def test_setting_sumup_credentials_never_echoes_the_api_key(client, raw_client, edition):
resp = client.patch(
_form_url(edition),
json={"sumup_merchant_code": "MXXXXX", "sumup_api_key": "sup_sk_secret"},
)
assert resp.status_code == 200
body = resp.json()
assert body["sumup_merchant_code"] == "MXXXXX"
assert body["sumup_api_key_set"] is True
assert "sumup_api_key" not in body
# 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(_public(edition, "/public")).json()["sumup_enabled"] is False
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is True
def test_payment_options_start_empty(client, edition):
assert client.get(_form_url(edition, "/payment-options")).json() == []
def test_creating_and_listing_payment_options(client, raw_client, edition):
client.post(
_form_url(edition, "/payment-options"),
json={"label": "Mécénat", "amount": 50, "order": 1},
)
resp = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Cotisation classique", "amount": 20, "order": 0},
)
assert resp.status_code == 201
body = resp.json()
assert body["label"] == "Cotisation classique"
assert body["amount"] == 20
listed = client.get(_form_url(edition, "/payment-options")).json()
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(_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]
def test_updating_and_deleting_a_payment_option(client, edition):
option_id = client.post(
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}
).json()["id"]
resp = client.patch(
_form_url(edition, f"/payment-options/{option_id}"), json={"amount": 25}
)
assert resp.status_code == 200
assert resp.json()["amount"] == 25
resp = client.delete(_form_url(edition, f"/payment-options/{option_id}"))
assert resp.status_code == 204
assert client.get(_form_url(edition, "/payment-options")).json() == []
def test_payment_options_are_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
lead_client = as_account(lead)
resp = lead_client.post(
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}
)
assert resp.status_code == 403
def test_rejects_a_non_positive_payment_option_amount(client, edition):
resp = client.post(
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 0}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.invalid_sumup_amount"
def test_rejects_a_duplicate_payment_option_label(client, edition):
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
resp = client.post(
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 30}
)
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "form.duplicate_payment_option_label"
def test_deleting_a_payment_option_keeps_the_volunteers_snapshot(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
client.delete(_form_url(edition, f"/payment-options/{option_id}"))
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["fee_payment_label"] == "Cotisation classique"
assert fiche["fee_amount"] == 12.5
def test_clearing_the_sumup_api_key(client, edition):
client.patch(_form_url(edition), json={"sumup_api_key": "sup_sk_secret"})
assert client.get(_form_url(edition)).json()["sumup_api_key_set"] is True
resp = client.patch(_form_url(edition), json={"sumup_api_key": ""})
assert resp.json()["sumup_api_key_set"] is False
def test_logo_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
lead_client = as_account(lead)
resp = lead_client.put(
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
)
assert resp.status_code == 403
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(_public(edition, "/logo"))
assert resp.status_code == 404
def test_uploading_a_logo_and_reading_it_back_publicly(client, raw_client, edition):
resp = client.put(
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
)
assert resp.status_code == 200
assert resp.json()["has_logo"] is True
assert client.get(_form_url(edition)).json()["has_logo"] is True
logo = raw_client.get(_public(edition, "/logo"))
assert logo.status_code == 200
assert logo.headers["content-type"] == "image/png"
assert logo.content == _PNG
def test_uploading_an_unsupported_logo_type_is_rejected(client, edition):
resp = client.put(
_form_url(edition, "/logo"),
files={"file": ("logo.gif", b"not-a-real-gif", "image/gif")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.logo_type_not_supported"
def test_public_config_reports_the_logo_and_description(client, raw_client, edition):
client.patch(_form_url(edition), json={"description": "Bienvenue au festival !"})
client.put(
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
)
body = raw_client.get(_public(edition, "/public")).json()
assert body["description"] == "Bienvenue au festival !"
assert body["has_logo"] is True
def test_public_form_hides_positions_and_artists_unless_enabled(
client, raw_client, edition, session
):
make_position(session, edition, name="Bar central")
make_artist(session, edition, name="Chilla")
resp = raw_client.get(_public(edition, "/public"))
body = resp.json()
assert body["positions"] == []
assert body["artists"] == []
client.put(
_form_url(edition, "/fields"),
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]},
)
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"]
def test_submitting_creates_a_volunteer(raw_client, client, edition, session):
client.put(
_form_url(edition, "/fields"),
json={"fields": ["EMAIL", "PHONE", "AVAILABLE", "WILLING_TO_PAY_FEE"]},
)
# Pas de payment_option_id : « je paierai sur place » — pas de paiement
# en ligne, le bénévole est créé tout de suite.
resp = _submit(
raw_client, edition,
first_name="alex", last_name="martin", hcaptcha_token="x",
email="alex@example.org", phone="0600000000", available="true",
)
assert resp.status_code == 201
volunteer_id = resp.json()["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["first_name"] == "Alex"
assert fiche["last_name"] == "Martin"
assert fiche["email"] == "alex@example.org"
assert fiche["self_declared_available"] is True
assert fiche["willing_to_pay_fee"] is True
assert fiche["fee_payment_status"] is None
# Le bénévole créé apparaît dans la liste de la coordination.
volunteers = client.get(f"/editions/{edition.id}/volunteers").json()
assert any(v["id"] == volunteer_id for v in volunteers)
def test_submitting_sends_a_registration_confirmation_email(raw_client, client, edition, outbox):
client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
email="alex@example.org",
)
assert resp.status_code == 201
assert len(outbox) == 1
assert outbox[0]["To"] == "alex@example.org"
assert "Alex" in plain_text(outbox[0])
def test_submitting_without_an_email_sends_no_confirmation(raw_client, edition, outbox):
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
assert resp.status_code == 201
assert outbox == []
def test_submitting_ignores_fields_not_enabled(raw_client, client, edition):
# Aucun champ activé : email/téléphone envoyés quand même, ignorés.
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
email="alex@example.org", phone="0600000000",
)
assert resp.status_code == 201
volunteer_id = resp.json()["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["email"] is None
assert fiche["phone"] is None
def test_submitting_without_an_email_is_rejected_when_email_enabled(raw_client, client, edition):
client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
def test_submitting_without_a_phone_is_rejected_when_phone_enabled(raw_client, client, edition):
client.put(_form_url(edition, "/fields"), json={"fields": ["PHONE"]})
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
def test_submitting_without_checking_available_is_rejected_when_enabled(
raw_client, client, edition
):
client.put(_form_url(edition, "/fields"), json={"fields": ["AVAILABLE"]})
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x", available="true",
)
assert resp.status_code == 201
def test_submitting_resolves_position_and_artist_wishes(
raw_client, client, edition, session
):
position = make_position(session, edition, name="Bar central")
artist = make_artist(session, edition, name="Chilla")
client.put(
_form_url(edition, "/fields"),
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES", "COMPANION_WISH"]},
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
position_wish_ids=f'["{position.id}"]',
artist_wish_ids=f'["{artist.id}"]',
companion_wish_note="Sam, il/elle s'inscrira bientôt",
)
assert resp.status_code == 201
volunteer_id = resp.json()["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["wished_position_ids"] == [str(position.id)]
assert fiche["artist_ids"] == [str(artist.id)]
assert fiche["companion_wish_note"] == "Sam, il/elle s'inscrira bientôt"
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",
"access_token": _ACCESS_TOKEN,
},
files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")},
)
assert resp.status_code == 201
volunteer_id = resp.json()["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["has_photo"] is True
photo = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}/photo")
assert photo.status_code == 200
assert photo.headers["content-type"] == "image/png"
assert photo.content == _PNG
def test_submitting_an_unsupported_photo_type_is_rejected(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",
"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
):
positions = [
make_position(session, edition, name=f"Poste {i}") for i in range(3)
]
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
position_wish_ids=ids,
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.too_many_position_wishes"
def test_submitting_two_position_wishes_is_accepted(raw_client, client, edition, session):
positions = [make_position(session, edition, name=f"Poste {i}") for i in range(2)]
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
position_wish_ids=ids,
)
assert resp.status_code == 201
def test_submitting_two_artist_wishes_on_the_same_day_is_rejected(
raw_client, client, edition, session
):
a1 = make_artist(session, edition, name="Chilla")
a2 = make_artist(session, edition, name="Kntongo") # même jour par défaut
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.too_many_artists_same_day"
def test_submitting_artist_wishes_on_different_days_is_accepted(
raw_client, client, edition, session
):
a1 = make_artist(session, edition, name="Chilla", performance_date=dt.date(2026, 7, 9))
a2 = make_artist(session, edition, name="Kntongo", performance_date=dt.date(2026, 7, 10))
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
)
assert resp.status_code == 201
def test_submitting_a_comment(raw_client, client, edition):
client.put(_form_url(edition, "/fields"), json={"fields": ["COMMENT"]})
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
comment="Dispo seulement le samedi",
)
assert resp.status_code == 201
volunteer_id = resp.json()["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["comment"] == "Dispo seulement le samedi"
def test_submitting_with_an_unknown_position_id_is_rejected(raw_client, client, edition):
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
position_wish_ids='["00000000-0000-0000-0000-000000000000"]',
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.unknown"
def test_submitting_when_the_form_is_closed_is_rejected(raw_client, client, edition):
client.patch(_form_url(edition), json={"is_open": False})
resp = _submit(
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.closed"
def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch):
monkeypatch.setattr("app.api.forms.verify_hcaptcha", lambda token: False)
resp = _submit(
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.captcha_failed"
def test_choosing_an_online_option_does_not_create_the_volunteer_yet(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
before = len(client.get(f"/editions/{edition.id}/volunteers").json())
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(
id="co_123", status="PENDING", hosted_checkout_url="https://checkout.sumup.com/pay/co_123"
),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
assert resp.status_code == 201
body = resp.json()
assert body["volunteer_id"] is None
assert body["pending_token"] is not None
assert body["sumup_checkout_url"] == "https://checkout.sumup.com/pay/co_123"
# Toujours rien en base tant que le paiement n'est pas confirmé.
after = len(client.get(f"/editions/{edition.id}/volunteers").json())
assert after == before
status = _pending_status(raw_client, edition, body["pending_token"])
assert status["status"] == "PENDING"
assert status["label"] == "Cotisation classique"
assert status["amount"] == 12.5
def test_paying_promotes_the_pending_submission_to_a_volunteer(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
webhook_resp = _pay(raw_client, edition, monkeypatch, checkout_id="co_123")
assert webhook_resp.status_code == 200
status = _pending_status(raw_client, edition, token)
assert status["status"] == "PAID"
volunteer_id = status["volunteer_id"]
assert volunteer_id is not None
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["first_name"] == "Alex"
assert fiche["fee_payment_status"] == "PAID"
assert fiche["fee_payment_label"] == "Cotisation classique"
assert fiche["fee_amount"] == 12.5
assert fiche["fee_paid_at"] is not None
assert fiche["willing_to_pay_fee"] is True
def test_payment_confirmation_sends_an_email_on_promotion(
raw_client, client, edition, monkeypatch, outbox
):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Cotisation classique", "amount": 12.5},
).json()["id"]
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
_submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
email="alex@example.org",
payment_option_id=option_id,
)
assert outbox == [] # pas encore payé : pas d'email à ce stade
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
assert len(outbox) == 1
assert outbox[0]["To"] == "alex@example.org"
body = plain_text(outbox[0])
assert "Alex" in body
assert "Cotisation classique" in body
assert "12.5" in body
def test_choosing_to_pay_on_site_creates_the_volunteer_immediately(
raw_client, client, edition, monkeypatch
):
_configure_sumup(client, edition)
called = []
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: called.append(kw))
resp = _submit(
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
)
assert resp.status_code == 201
body = resp.json()
assert body["volunteer_id"] is not None
assert body["pending_token"] is None
assert body["sumup_checkout_url"] is None
assert called == []
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
assert fiche["willing_to_pay_fee"] is True
assert fiche["fee_payment_status"] is None
def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, client, edition):
_configure_sumup(client, edition)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id="00000000-0000-0000-0000-000000000000",
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.unknown_payment_option"
def test_a_failed_checkout_call_can_be_retried_without_resubmitting(
raw_client, client, edition, monkeypatch
):
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,
)
assert resp.status_code == 201
body = resp.json()
assert body["sumup_checkout_url"] is None
token = body["pending_token"]
assert _pending_status(raw_client, edition, token)["status"] == "PENDING"
# SumUp remarche : la relance réutilise les infos déjà saisies.
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_retry", status="PENDING", hosted_checkout_url="https://x"),
)
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
assert retry_resp.status_code == 200
assert retry_resp.json()["sumup_checkout_url"] == "https://x"
_pay(raw_client, edition, monkeypatch, checkout_id="co_retry")
assert _pending_status(raw_client, edition, token)["status"] == "PAID"
def test_retrying_after_an_expired_checkout(raw_client, client, edition, monkeypatch):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
_pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="EXPIRED")
assert _pending_status(raw_client, edition, token)["status"] == "EXPIRED"
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_2", status="PENDING", hosted_checkout_url="https://y"),
)
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
assert retry_resp.status_code == 200
def test_retrying_an_already_paid_pending_submission_is_rejected(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
assert retry_resp.status_code == 422
assert retry_resp.json()["detail"]["code"] == "form.pending_already_paid"
def test_an_unknown_pending_token_is_a_404(raw_client, edition):
resp = raw_client.get(
_form_url(edition, "/public/pending/00000000-0000-0000-0000-000000000000")
)
assert resp.status_code == 404
def test_choosing_a_mecenat_option_requires_the_address_step_first(
raw_client, client, edition, monkeypatch
):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
).json()["id"]
called = []
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: called.append(kw))
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
assert resp.status_code == 201
body = resp.json()
assert body["requires_mecenat_info"] is True
assert body["sumup_checkout_url"] is None
assert called == [] # pas de checkout avant d'avoir l'adresse
token = body["pending_token"]
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_don", status="PENDING", hosted_checkout_url="https://don"),
)
info_resp = raw_client.post(
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
json={
"address": "1 rue du Festival",
"city": "Rennes",
"postal_code": "35000",
"country": "France",
},
)
assert info_resp.status_code == 200
assert info_resp.json()["sumup_checkout_url"] == "https://don"
_pay(raw_client, edition, monkeypatch, checkout_id="co_don")
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["mecenat_address"] == "1 rue du Festival"
assert fiche["mecenat_city"] == "Rennes"
assert fiche["mecenat_postal_code"] == "35000"
assert fiche["mecenat_country"] == "France"
def _attachments(message) -> list:
return [
part
for part in message.iter_attachments()
if part.get_content_disposition() == "attachment"
]
def test_mecenat_payment_emails_the_filled_cerfa(
raw_client, client, edition, session, monkeypatch, outbox
):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
).json()["id"]
token = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
email="alex@example.org",
payment_option_id=option_id,
).json()["pending_token"]
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_don", status="PENDING", hosted_checkout_url="https://don"),
)
raw_client.post(
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
json={
"address": "1 rue du Festival",
"city": "Rennes",
"postal_code": "35000",
"country": "France",
},
)
assert outbox == [] # pas encore payé : ni email ni reçu
_pay(raw_client, edition, monkeypatch, checkout_id="co_don")
assert len(outbox) == 1
attachments = _attachments(outbox[0])
assert len(attachments) == 1
assert attachments[0].get_content_type() == "application/pdf"
assert attachments[0].get_filename().endswith(".pdf")
assert attachments[0].get_content().startswith(b"%PDF")
assert "reçu fiscal" in plain_text(outbox[0])
# Numéro d'ordre attribué à la promotion, et imprimé sur le cerfa joint.
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
volunteer = session.get(Volunteer, uuid.UUID(volunteer_id))
expected = f"{volunteer.fee_paid_at.year}00001"
assert volunteer.mecenat_receipt_number == expected
reader = PdfReader(io.BytesIO(attachments[0].get_content()))
assert expected in reader.pages[0].extract_text()
def test_classic_payment_email_has_no_cerfa(raw_client, client, edition, monkeypatch, outbox):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Cotisation classique", "amount": 12.5},
).json()["id"]
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
_submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
email="alex@example.org",
payment_option_id=option_id,
)
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
assert len(outbox) == 1
assert _attachments(outbox[0]) == []
assert "reçu fiscal" not in plain_text(outbox[0])
def test_mecenat_info_requires_every_field(raw_client, client, edition, monkeypatch):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
).json()["id"]
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
info_resp = raw_client.post(
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
json={
"address": "1 rue du Festival",
"city": "Rennes",
"postal_code": "",
"country": "France",
},
)
assert info_resp.status_code == 422
def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, monkeypatch):
_configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(id="co_unknown", status="PAID", hosted_checkout_url=None),
)
resp = raw_client.post(
_form_url(edition, "/public/sumup-webhook"),
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_unknown"},
)
assert resp.status_code == 200
def test_stale_pending_submissions_are_pruned(raw_client, client, edition, session, monkeypatch):
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"]
pending = session.get(PendingFormSubmission, uuid.UUID(token))
pending.created_at = local_now() - dt.timedelta(hours=49)
session.add(pending)
session.commit()
# N'importe quelle nouvelle soumission déclenche le nettoyage paresseux.
_submit(raw_client, edition, first_name="Sam", last_name="Dupuis", hcaptcha_token="x")
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 404
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
for _ in range(form_submission_limiter.max_attempts):
form_submission_limiter.hit(str(edition.id))
resp = _submit(
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