Booking confirmation email + customer self-service cancel/reschedule (#18)

Sends a confirmation email (best-effort, fire-and-forget SMTP via mailer.py)
on booking creation, with a manage-booking link embedding the ticket-2 signed
token. Adds /manage/<token>, a stateless cancel/reschedule page that reuses
the existing slot-picker against booking_api's create/cancel/reschedule API,
distinguishing an invalid/expired link from an already-cancelled one. Sender
address uses the client's own domain when configured, falling back to a
mivanchenko.de address otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:40:45 +02:00
parent b895663c3a
commit 644c99ee30
14 changed files with 872 additions and 15 deletions
+33 -3
View File
@@ -72,6 +72,36 @@ def test_create_booking_auto_confirm_true_yields_confirmed(client):
assert "token" in body
def test_create_booking_via_public_endpoint_sends_confirmation_email(client, monkeypatch):
"""#18's acceptance criterion: completing a booking via ticket 3's public
page (this same POST /api/booking endpoint) triggers the confirmation
email, with the manage-booking token embedded in it."""
sent = []
monkeypatch.setattr(
"booking_mail.mailer.send_email",
lambda to, subject, html, from_addr=None: sent.append(
{"to": to, "subject": subject, "html": html, "from_addr": from_addr}))
resource, service = _setup_resource_and_service(
auto_confirm=True, min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
slots = client.get("/api/booking/slots", query_string={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"],
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
resp = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slots[0],
"customer_name": "Kim", "customer_contact": "kim@example.com"})
assert resp.status_code == 201
token = resp.get_json()["token"]
assert len(sent) == 1
assert sent[0]["to"] == "kim@example.com"
assert f"/manage/{token}" in sent[0]["html"]
assert "Haircut" in sent[0]["html"]
def test_create_booking_auto_confirm_false_yields_pending(client):
resource, service = _setup_resource_and_service(
auto_confirm=False, min_notice_minutes=0, max_advance_days=365)
@@ -251,12 +281,12 @@ def test_reschedule_to_same_slot_is_a_noop_success(client):
def test_manage_token_is_scoped_to_its_own_client():
resource, service = _setup_resource_and_service(client_id=CLIENT_A)
from booking_api import _mint_manage_token, _verify_manage_token
from booking_api import _mint_manage_token, verify_manage_token
booking = bdb.create_booking(
CLIENT_A, resource["resource_id"], "Ivy", "i@example.com", "Haircut",
datetime.now(timezone.utc) + timedelta(days=1),
datetime.now(timezone.utc) + timedelta(days=1, hours=1))
token = _mint_manage_token(CLIENT_A, booking["booking_id"])
assert _verify_manage_token(token) == (CLIENT_A, booking["booking_id"])
assert verify_manage_token(token) == (CLIENT_A, booking["booking_id"])
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
assert _verify_manage_token(tampered) is None
assert verify_manage_token(tampered) is None