Logs de diagnostic et rapprochement SumUp ↔ bénévole
- 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:
co-authored by
Claude Opus 5.5
parent
d722a81a87
commit
d0fa1e109c
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user