Files
benevoles/backend/tests/test_forms_api.py
T
AdamThomasandClaude Sonnet 5 82088f3b0c Étape 21 : formulaire public d'inscription des bénévoles
Retire « constructeur de formulaire » des exclusions du CLAUDE.md (§ 5) —
décision explicite prise en conversation.

Un formulaire par édition (COORDINATION choisit les champs actifs parmi
neuf : email, téléphone, vœux de poste — max 2, vœux d'artiste — un par
soir de festival, vœu de compagnon en texte libre, disponibilité/
cotisation déclaratives, photo, commentaire ; nom/prénom toujours
collectés) ouvert/fermé à volonté, avec un lien à partager
(/inscription, aucun compte requis).

Backend : FormTemplate/FormField (migration 0013), quatre nouveaux champs
sur Volunteer (photo, companion_wish_note, self_declared_available,
willing_to_pay_fee), app/api/forms.py — les deux seuls endpoints publics
de toute l'API (GET .../form/public, POST .../form/public/submit),
protégés par hCaptcha (app/forms/captcha.py, désactivé sans
HCAPTCHA_SECRET_KEY) et un filet de sécurité global par édition
(form_submission_limiter). resolve_owned_ids/set_artists/
set_wished_positions déplacés vers app/api/_common.py, partagés avec
volunteers.py.

Frontend : écran de configuration (/inscriptions) avec lien copiable et
bascule ouvert/fermé, formulaire public (/inscription, hors RequireAuth)
avec widget hCaptcha chargé dynamiquement, capture photo native
(<input capture>), vœux de poste/artiste avec les mêmes règles que le
back-end. Réponses du formulaire visibles et éditables depuis la fiche
bénévole existante (ConfigVolunteers.tsx).

Vérifié : pytest (273 passés), ruff, alembic check, tsc --noEmit, eslint,
vite build, smoke (rendu SSR de chaque écran + flux de bout en bout du
formulaire en mode démonstration) — tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 20:24:32 +02:00

283 lines
11 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 tests.factories import make_artist, make_position
# 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_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_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