Logs de diagnostic et rapprochement SumUp ↔ bénévole
ci-cd / build-backend (push) Successful in 38s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 13s

- Une ligne de log par requête (route modèle, statut, durée, code d'erreur,
  compte), identifiant X-Request-ID repris dans chaque log et affiché à
  l'utilisateur sous une erreur, exceptions non gérées tracées (500 JSON)
- Journal d'accès d'uvicorn coupé : il exposait le jeton du formulaire public
- Logs métier : paiement SumUp, connexion, import, plannings, solveur
- Nom du bénévole dans la description du checkout SumUp
- Code et identifiant de transaction SumUp enregistrés au paiement
  (migration 0023), affichés sur la fiche et dans l'export Excel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-12 18:54:35 +02:00
co-authored by Claude Opus 5.5
parent d722a81a87
commit d0fa1e109c
27 changed files with 798 additions and 16 deletions
+21
View File
@@ -588,3 +588,24 @@ def test_mecenat_receipts_is_coordinator_only(
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 403
def test_export_gives_the_sumup_transaction(client, edition, session):
volunteer = make_volunteer(session, edition, first_name="Sam", last_name="B")
_mark_mecenat_paid(session, volunteer)
volunteer.fee_transaction_code = "TEENSK4W2K"
volunteer.fee_transaction_id = "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
session.add(volunteer)
session.commit()
resp = client.get(_url(edition, "/export"))
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
code = rows[0].index("Transaction SumUp (code)")
ident = rows[0].index("Transaction SumUp (identifiant)")
assert rows[1][code] == "TEENSK4W2K"
assert rows[1][ident] == "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
fiche = client.get(_url(edition, f"/{volunteer.id}")).json()
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
# L'identifiant du checkout, lui, reste interne.
assert "fee_checkout_id" not in fiche
+50
View File
@@ -716,6 +716,56 @@ def test_paying_promotes_the_pending_submission_to_a_volunteer(
assert fiche["willing_to_pay_fee"] is True
def test_checkout_description_names_the_volunteer(raw_client, client, edition, monkeypatch):
# Pas de champ de métadonnées libre à la création d'un checkout SumUp :
# c'est la description (visible dans le tableau de bord SumUp) qui porte
# le nom, pour rapprocher la transaction du bénévole.
option_id = _configure_sumup(client, edition)
calls = []
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: calls.append(kw)
or SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
_submit(
raw_client, edition,
first_name="alex", last_name="martin", hcaptcha_token="x",
payment_option_id=option_id,
)
assert calls[0]["description"] == (
f"Alex Martin — Cotisation classique — édition {edition.year}"
)
def test_promotion_records_the_sumup_transaction(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"),
)
token = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
).json()["pending_token"]
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(
id="co_1",
status="PAID",
hosted_checkout_url=None,
transaction_code="TEENSK4W2K",
transaction_id="tx-uuid-1",
),
)
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
assert fiche["fee_transaction_id"] == "tx-uuid-1"
def test_payment_confirmation_sends_an_email_on_promotion(
raw_client, client, edition, monkeypatch, outbox
):
+78
View File
@@ -0,0 +1,78 @@
"""Journal des requêtes (`app/request_logging.py`) : référence de requête,
route modèle sans jeton ni chaîne de requête, erreurs détaillées sans
valeur saisie."""
import logging
import pytest
from fastapi.testclient import TestClient
from app.main import app
def test_each_response_carries_a_request_id(client):
resp = client.get("/health")
assert len(resp.headers["X-Request-ID"]) >= 8
def test_a_well_formed_incoming_request_id_is_kept(client):
resp = client.get("/health", headers={"X-Request-ID": "caddy-1234abcd"})
assert resp.headers["X-Request-ID"] == "caddy-1234abcd"
def test_a_malformed_incoming_request_id_is_replaced(client):
resp = client.get("/health", headers={"X-Request-ID": "x\nforged log line"})
assert resp.headers["X-Request-ID"] != "x\nforged log line"
def test_access_line_uses_the_route_template_and_hides_tokens(raw_client, edition, caplog):
with caplog.at_level(logging.INFO, logger="app.request"):
raw_client.get(f"/editions/{edition.id}/form/public?token=secret-access-token")
line = next(r.getMessage() for r in caplog.records if r.name == "app.request")
assert "/editions/{edition_id}/form/public" in line
assert f"edition_id={edition.id}" in line
assert "secret-access-token" not in caplog.text
def test_business_error_code_is_logged(client, edition, caplog):
with caplog.at_level(logging.INFO, logger="app.request"):
resp = client.get(f"/editions/{edition.id}/volunteers/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 404
record = next(r for r in caplog.records if r.name == "app.request")
assert record.levelno == logging.WARNING
assert "code=error.not_found" in record.getMessage()
def test_validation_error_logs_the_field_but_not_the_value(client, edition, caplog):
with caplog.at_level(logging.INFO, logger="app.request"):
resp = client.post(
f"/editions/{edition.id}/volunteers",
json={"first_name": "Alexandra-Secret", "last_name": 12345678},
)
assert resp.status_code == 422
assert "last_name" in caplog.text
assert "Alexandra-Secret" not in caplog.text
@pytest.fixture
def boom_route():
def boom():
raise RuntimeError("panne")
app.add_api_route("/__boom", boom)
yield
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != "/__boom"]
def test_unhandled_exception_returns_a_reference(boom_route, caplog):
with caplog.at_level(logging.ERROR, logger="app.request"):
resp = TestClient(app).get("/__boom")
assert resp.status_code == 500
body = resp.json()
assert body["detail"]["code"] == "error.internal"
assert body["detail"]["request_id"] == resp.headers["X-Request-ID"]
assert "RuntimeError" in caplog.text
+59
View File
@@ -0,0 +1,59 @@
"""Client SumUp : lecture de la transaction d'un checkout payé, et
diagnostic d'un appel refusé (journalisé sans données du payeur)."""
import logging
import httpx
from app.sumup import client as sumup
def test_transaction_is_read_from_the_checkout():
checkout = sumup._to_checkout(
{
"id": "co_1",
"status": "PAID",
"transaction_code": "TEENSK4W2K",
"transaction_id": "tx-1",
}
)
assert checkout.transaction_code == "TEENSK4W2K"
assert checkout.transaction_id == "tx-1"
def test_transaction_falls_back_to_the_successful_attempt():
checkout = sumup._to_checkout(
{
"id": "co_1",
"status": "PAID",
"transactions": [
{"id": "tx-failed", "transaction_code": "TFAILED", "status": "FAILED"},
{"id": "tx-ok", "transaction_code": "TOK", "status": "SUCCESSFUL"},
],
}
)
assert checkout.transaction_code == "TOK"
assert checkout.transaction_id == "tx-ok"
def test_unpaid_checkout_has_no_transaction():
checkout = sumup._to_checkout({"id": "co_1", "status": "PENDING"})
assert checkout.transaction_code is None
assert checkout.transaction_id is None
def test_refused_call_logs_the_sumup_error_code(monkeypatch, caplog):
request = httpx.Request("POST", "https://api.sumup.com/v0.1/checkouts")
response = httpx.Response(
409,
json={"error_code": "DUPLICATED_CHECKOUT", "message": "Checkout already exists"},
request=request,
)
monkeypatch.setattr(httpx, "request", lambda *a, **kw: response)
with caplog.at_level(logging.WARNING, logger="app.sumup"):
result = sumup.get_checkout(api_key="k", checkout_id="co_1")
assert result is None
assert "HTTP 409" in caplog.text
assert "DUPLICATED_CHECKOUT" in caplog.text