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
+93
View File
@@ -0,0 +1,93 @@
"""Unit tests for booking_mail.py (#18): sender-address resolution and the
skip-if-not-an-email guard, per the acceptance criterion that customer_contact
(free-text "E-Mail oder Telefon") may not actually be an email address.
"""
from datetime import datetime, timedelta, timezone
import pytest
import booking_mail
from app import app as flask_app
@pytest.mark.parametrize("contact,expected", [
("alice@example.com", True),
("Alice@Example.COM", True),
("+49 151 2345678", False),
("0151-2345678", False),
("not-an-email", False),
("", False),
(None, False),
])
def test_looks_like_email(contact, expected):
assert booking_mail.looks_like_email(contact) is expected
def test_manage_url_embeds_token(monkeypatch):
monkeypatch.setattr(booking_mail, "PUBLIC_BASE_URL", "https://onboard.example.com")
assert booking_mail.manage_url("abc.def.ghi") == \
"https://onboard.example.com/manage/abc.def.ghi"
def test_sender_uses_client_domain_when_configured():
client = {"domain": "happynails.de"}
assert booking_mail._sender_for(client) == "noreply@happynails.de"
def test_sender_falls_back_when_client_has_no_domain(monkeypatch):
monkeypatch.setattr(booking_mail.mailer, "MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
assert booking_mail._sender_for({"domain": None}) == "noreply@mivanchenko.de"
assert booking_mail._sender_for({}) == "noreply@mivanchenko.de"
assert booking_mail._sender_for(None) == "noreply@mivanchenko.de"
@pytest.mark.parametrize("raw,expected", [
("happynails.de", "happynails.de"),
("https://happynails.de", "happynails.de"),
("https://happynails.de/", "happynails.de"),
("http://happynails.de/shop", "happynails.de"),
(" happynails.de ", "happynails.de"),
("HappyNails.de", "happynails.de"),
])
def test_sender_sanitizes_domain_entered_with_scheme_or_path(raw, expected):
assert booking_mail._sender_for({"domain": raw}) == f"noreply@{expected}"
def _booking(contact="alice@example.com"):
start = datetime.now(timezone.utc) + timedelta(days=1)
return {
"customer_name": "Alice",
"customer_contact": contact,
"service": "Haircut",
"start_time": start,
"status": "confirmed",
}
def test_send_booking_confirmation_sends_when_contact_is_email(monkeypatch):
captured = []
monkeypatch.setattr(booking_mail.mailer, "send_email",
lambda to, subject, html, from_addr=None:
captured.append((to, subject, html, from_addr)))
client = {"domain": "happynails.de", "business_name": "Happy Nails",
"timezone": "Europe/Berlin"}
with flask_app.test_request_context():
booking_mail.send_booking_confirmation(client, _booking(), "sometoken")
assert len(captured) == 1
to, subject, html, from_addr = captured[0]
assert to == "alice@example.com"
assert from_addr == "noreply@happynails.de"
assert "Happy Nails" in subject
assert "/manage/sometoken" in html
assert "Haircut" in html
def test_send_booking_confirmation_skips_when_contact_is_phone(monkeypatch):
captured = []
monkeypatch.setattr(booking_mail.mailer, "send_email",
lambda *a, **kw: captured.append((a, kw)))
client = {"business_name": "Happy Nails", "timezone": "Europe/Berlin"}
with flask_app.test_request_context():
booking_mail.send_booking_confirmation(client, _booking(contact="0151-2345678"),
"sometoken")
assert captured == []
+98
View File
@@ -0,0 +1,98 @@
"""Unit tests for mailer.py's SMTP call shape (#18). Calls _send_now directly
rather than going through the background-thread queue, so assertions are
synchronous -- the queue itself is just plumbing, already covered indirectly
by app.py's identical Sheets-mirror pattern.
"""
from email.message import EmailMessage
import pytest
import mailer
class _FakeSMTP:
sent = []
login_calls = []
def __init__(self, host, port, timeout=None):
self.host = host
self.port = port
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
pass
def login(self, username, password):
_FakeSMTP.login_calls.append((username, password))
def send_message(self, msg):
_FakeSMTP.sent.append(msg)
@pytest.fixture(autouse=True)
def _reset_fake_smtp():
_FakeSMTP.sent = []
_FakeSMTP.login_calls = []
yield
def _msg(to="alice@example.com"):
msg = EmailMessage()
msg["Subject"] = "Terminbestätigung"
msg["From"] = "noreply@example.com"
msg["To"] = to
msg.set_content("<p>hi</p>", subtype="html")
return msg
def test_send_now_skips_when_smtp_host_not_configured(monkeypatch):
monkeypatch.setattr(mailer, "SMTP_HOST", "")
monkeypatch.setattr(mailer, "smtplib", type("m", (), {"SMTP": _FakeSMTP}))
mailer._send_now(_msg())
assert _FakeSMTP.sent == []
def test_send_now_sends_via_smtp_when_configured(monkeypatch):
monkeypatch.setattr(mailer, "SMTP_HOST", "smtp.example.com")
monkeypatch.setattr(mailer, "SMTP_PORT", 587)
monkeypatch.setattr(mailer, "SMTP_USERNAME", "")
monkeypatch.setattr(mailer, "smtplib", type("m", (), {"SMTP": _FakeSMTP}))
msg = _msg()
mailer._send_now(msg)
assert _FakeSMTP.sent == [msg]
assert _FakeSMTP.login_calls == []
def test_send_now_logs_in_when_username_configured(monkeypatch):
monkeypatch.setattr(mailer, "SMTP_HOST", "smtp.example.com")
monkeypatch.setattr(mailer, "SMTP_USERNAME", "mailer")
monkeypatch.setattr(mailer, "SMTP_PASSWORD", "secret")
monkeypatch.setattr(mailer, "smtplib", type("m", (), {"SMTP": _FakeSMTP}))
mailer._send_now(_msg())
assert _FakeSMTP.login_calls == [("mailer", "secret")]
def test_send_email_builds_html_message_and_enqueues(monkeypatch):
captured = []
monkeypatch.setattr(mailer._queue, "put", lambda m: captured.append(m))
mailer.send_email("bob@example.com", "Subject line", "<p>body</p>",
from_addr="noreply@custom.example")
assert len(captured) == 1
msg = captured[0]
assert msg["To"] == "bob@example.com"
assert msg["Subject"] == "Subject line"
assert msg["From"] == "noreply@custom.example"
assert msg.get_content_type() == "text/html"
def test_send_email_defaults_from_addr_to_fallback(monkeypatch):
captured = []
monkeypatch.setattr(mailer._queue, "put", lambda m: captured.append(m))
monkeypatch.setattr(mailer, "MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
mailer.send_email("bob@example.com", "Subject", "<p>body</p>")
assert captured[0]["From"] == "noreply@mivanchenko.de"
+137
View File
@@ -0,0 +1,137 @@
"""Flask test client / real-DB integration tests for the manage-booking page
(#18), per #14's testing decision: assert on HTTP response + resulting DB
state.
"""
from datetime import date, time, timedelta
import pytest
import booking_db as bdb
from app import app as flask_app
from booking_api import _mint_manage_token
CLIENT_A = "C-TEST-MANAGE-A"
@pytest.fixture
def client():
flask_app.config["TESTING"] = True
return flask_app.test_client()
def _next_monday(after):
d = after + timedelta(days=1)
while d.weekday() != 0:
d += timedelta(days=1)
return d
def _setup_resource_and_service(client_id=CLIENT_A):
with bdb.db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO clients (client_id, timezone, auto_confirm) VALUES (%s, %s, %s) "
"ON CONFLICT (client_id) DO UPDATE SET timezone = EXCLUDED.timezone, "
"auto_confirm = EXCLUDED.auto_confirm",
(client_id, "Europe/Berlin", True))
conn.commit()
resource = bdb.create_resource(client_id, "Chair 1", min_notice_minutes=0,
max_advance_days=365)
bdb.set_resource_hours(client_id, resource["resource_id"], 0, time(9, 0), time(17, 0))
service = bdb.create_service(client_id, "Haircut", 60, price=25)
return resource, service
def _create_booking(client, resource, service, slot):
return client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot,
"customer_name": "Dana", "customer_contact": "dana@example.com"}).get_json()
def test_manage_page_shows_invalid_message_for_garbage_token(client):
resp = client.get("/manage/not-a-real-token")
assert resp.status_code == 400
body = resp.get_data(as_text=True)
assert "ungültig" in body.lower() or "abgelaufen" in body.lower()
def test_manage_page_shows_invalid_message_for_unknown_booking(client):
token = _mint_manage_token(CLIENT_A, "BK-does-not-exist")
resp = client.get(f"/manage/{token}")
assert resp.status_code == 404
def test_manage_page_shows_used_message_for_cancelled_booking(client):
resource, service = _setup_resource_and_service()
day = _next_monday(date.today())
slot = 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"][0]
created = _create_booking(client, resource, service, slot)
client.post("/api/booking/cancel", json={"token": created["token"]})
resp = client.get(f"/manage/{created['token']}")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert "storniert" in body.lower()
def test_manage_page_renders_active_booking_with_manage_ui(client):
resource, service = _setup_resource_and_service()
day = _next_monday(date.today())
slot = 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"][0]
created = _create_booking(client, resource, service, slot)
resp = client.get(f"/manage/{created['token']}")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert "Haircut" in body
assert resource["resource_id"] in body
assert created["token"] in body
def test_manage_page_cancel_flow_reaches_cancel_api(client):
resource, service = _setup_resource_and_service()
day = _next_monday(date.today())
slot = 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"][0]
created = _create_booking(client, resource, service, slot)
resp = client.post("/api/booking/cancel", json={"token": created["token"]})
assert resp.status_code == 200
assert bdb.get_booking(CLIENT_A, created["booking_id"])["status"] == "cancelled"
followup = client.get(f"/manage/{created['token']}")
assert "storniert" in followup.get_data(as_text=True).lower()
def test_manage_page_reschedule_slots_browsable_by_duration(client):
"""The manage page's reschedule picker uses duration_minutes (not
service_id, which bookings don't store) against /api/booking/slots."""
resource, service = _setup_resource_and_service()
day = _next_monday(date.today())
slot = 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"][0]
created = _create_booking(client, resource, service, slot)
resp = client.get("/api/booking/slots", query_string={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"duration_minutes": "60", "exclude_booking_id": created["booking_id"],
"date_from": day.isoformat(), "date_to": day.isoformat()})
assert resp.status_code == 200
slots = resp.get_json()["slots"]
# The booking's own slot is excluded from the busy check, so it's
# available again for a same-slot reschedule (a no-op success).
assert slot in slots
reschedule_resp = client.post("/api/booking/reschedule", json={
"token": created["token"], "start_time": slots[1]})
assert reschedule_resp.status_code == 200