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
+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 == []