Files
benevoles/backend/tests/test_forms_api.py
T
AdamThomasandClaude Sonnet 5 0bd38a6465
ci-cd / build-backend (push) Failing after 4m12s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
Remplace le simple lien Yapla statique par une vraie intégration API SumUp :
checkout hébergé créé à la soumission du formulaire, statut de paiement
(PENDING/PAID/FAILED/EXPIRED) tenu à jour par un webhook SumUp et visible sur
la fiche du bénévole. La cotisation n'est plus un montant unique : la
coordination configure autant de types de paiement qu'elle veut (libellé +
montant, ex. « Cotisation classique », « Mécénat »), et le bénévole en
choisit un à l'inscription — le libellé et le montant choisis sont figés sur
le bénévole, indépendamment d'une modification ultérieure de la config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 19:56:04 +02:00

601 lines
23 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
from app.auth.rate_limit import form_submission_limiter
from app.models import Role
from app.sumup import SumUpCheckout
from tests.factories import make_artist, make_position
_MERCHANT = "MXXXXX"
_API_KEY = "sup_sk_secret"
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"]
# 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 _submit(client, edition, **fields):
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_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()
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(_form_url(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
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(_form_url(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,
)
volunteer_id = resp.json()["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(_form_url(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(_form_url(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(_form_url(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(_form_url(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(_form_url(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"]},
)
resp = _submit(
raw_client, edition,
first_name="alex", last_name="martin", hcaptcha_token="x",
email="alex@example.org", phone="0600000000",
available="true", willing_to_pay_fee="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
# 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_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_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"},
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"},
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_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_submitting_creates_a_sumup_checkout_when_a_payment_option_is_chosen(
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://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["sumup_checkout_url"] == "https://checkout.sumup.com/pay/co_123"
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
assert fiche["fee_payment_status"] == "PENDING"
assert fiche["fee_payment_label"] == "Cotisation classique"
assert fiche["fee_amount"] == 12.5
assert fiche["willing_to_pay_fee"] is True
def test_submitting_without_choosing_an_option_does_not_create_a_checkout(
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
assert resp.json()["sumup_checkout_url"] is None
assert called == []
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_does_not_block_registration(
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
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
assert fiche["fee_payment_status"] is None
def test_sumup_webhook_updates_the_payment_status(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,
)
volunteer_id = resp.json()["volunteer_id"]
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(id="co_123", status="PAID", hosted_checkout_url=None),
)
webhook_resp = raw_client.post(
_form_url(edition, "/public/sumup-webhook"),
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_123"},
)
assert webhook_resp.status_code == 200
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["fee_payment_status"] == "PAID"
assert fiche["fee_paid_at"] is not None
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_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