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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user