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
+2
View File
@@ -21,10 +21,12 @@ import db
from sheets import Sheets
from booking_api import bp as booking_bp
from public_booking import bp as public_booking_bp
from manage_booking import bp as manage_booking_bp
app = Flask(__name__, static_folder="static", static_url_path="")
app.register_blueprint(booking_bp)
app.register_blueprint(public_booking_bp)
app.register_blueprint(manage_booking_bp)
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
# Separate read-only token for the public iCal feed (calendar apps can't send
+48 -10
View File
@@ -14,6 +14,7 @@ from flask import Blueprint, jsonify, request
import availability
import booking_db as bdb
import booking_mail
bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
@@ -32,9 +33,10 @@ def _mint_manage_token(client_id, booking_id):
return jwt.encode(payload, TOKEN_SECRET, algorithm="HS256")
def _verify_manage_token(token):
def verify_manage_token(token):
"""Returns (client_id, booking_id), or None if the token is
missing/expired/malformed."""
missing/expired/malformed. Public: manage_booking.py (#18) also verifies
tokens to decide what to render, without itself owning the token format."""
try:
payload = jwt.decode(token, TOKEN_SECRET, algorithms=["HS256"])
except jwt.PyJWTError:
@@ -94,23 +96,54 @@ def _available_slots(client_id, resource, tz_name, duration_minutes, date_from,
busy=busy)
class _BadDuration(ValueError):
"""Raised by _resolve_duration_minutes on an unknown service_id or a
non-integer duration_minutes -- turned into a clean 4xx by slots()."""
def _resolve_duration_minutes(client_id, service_id, duration_param):
"""service_id is the normal (public-page) path; duration_minutes is an
alternative for it: bookings store the service's *name*, not its id
(#15's schema), so the manage-booking page (#18) -- which only has the
existing booking's duration, not a service_id -- browses reschedule slots
by duration directly."""
if service_id:
service = bdb.get_service(client_id, service_id)
if service is None:
raise _BadDuration("not found")
return service["duration_minutes"]
try:
return int(duration_param)
except (TypeError, ValueError):
raise _BadDuration("duration_minutes must be an integer") from None
@bp.get("/slots")
def slots():
client_id = request.args.get("client_id")
resource_id = request.args.get("resource_id")
service_id = request.args.get("service_id")
duration_param = request.args.get("duration_minutes")
date_from = _parse_date(request.args.get("date_from"))
date_to = _parse_date(request.args.get("date_to"))
if not (client_id and resource_id and service_id and date_from and date_to):
return jsonify({"error": "client_id, resource_id, service_id, date_from, "
"date_to are required"}), 400
exclude_booking_id = request.args.get("exclude_booking_id")
if not (client_id and resource_id and (service_id or duration_param)
and date_from and date_to):
return jsonify({"error": "client_id, resource_id, date_from, date_to and "
"either service_id or duration_minutes are "
"required"}), 400
resource = bdb.get_resource(client_id, resource_id)
service = bdb.get_service(client_id, service_id)
client = bdb.get_client(client_id)
if resource is None or service is None or client is None:
if resource is None or client is None:
return jsonify({"error": "not found"}), 404
try:
duration_minutes = _resolve_duration_minutes(client_id, service_id, duration_param)
except _BadDuration as e:
status = 404 if str(e) == "not found" else 400
return jsonify({"error": str(e)}), status
slot_list = _available_slots(client_id, resource, _tz_name(client),
service["duration_minutes"], date_from, date_to)
duration_minutes, date_from, date_to,
exclude_booking_id=exclude_booking_id)
return jsonify({"slots": [s.isoformat() for s in slot_list]})
@@ -161,6 +194,11 @@ def create_booking():
return jsonify({"error": "that slot was just taken"}), 409
token = _mint_manage_token(client_id, booking["booking_id"])
# #18: fires for every caller of this endpoint, public page (#17) included.
# A future ticket-6 owner-manual-entry flow only gets the confirmation
# email for free if it also creates bookings through this endpoint --
# calling bdb.create_booking() directly would bypass it.
booking_mail.send_booking_confirmation(client, booking, token)
return jsonify({"booking_id": booking["booking_id"], "status": booking["status"],
"token": token}), 201
@@ -168,7 +206,7 @@ def create_booking():
@bp.post("/cancel")
def cancel_booking():
body = request.get_json(force=True, silent=True) or {}
resolved = _verify_manage_token(body.get("token"))
resolved = verify_manage_token(body.get("token"))
if resolved is None:
return jsonify({"error": "invalid or expired token"}), 400
client_id, booking_id = resolved
@@ -184,7 +222,7 @@ def cancel_booking():
@bp.post("/reschedule")
def reschedule_booking():
body = request.get_json(force=True, silent=True) or {}
resolved = _verify_manage_token(body.get("token"))
resolved = verify_manage_token(body.get("token"))
if resolved is None:
return jsonify({"error": "invalid or expired token"}), 400
client_id, booking_id = resolved
+71
View File
@@ -0,0 +1,71 @@
"""Booking confirmation email (#18): sender-address resolution, the
customer-facing manage-booking link, and the render/send call. Booking
creation must succeed even if this fails -- see mailer.py's fire-and-forget
send, which this relies on rather than talking to smtplib directly.
"""
import os
import re
from zoneinfo import ZoneInfo
from flask import render_template
import mailer
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://onboard.mivanchenko.de").rstrip("/")
# customer_contact is a single free-text "E-Mail oder Telefon" field (#16/#17)
# -- not guaranteed to be an email address. Only attempt to send when it
# looks like one.
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def looks_like_email(contact):
return bool(_EMAIL_RE.match((contact or "").strip()))
def manage_url(token):
return f"{PUBLIC_BASE_URL}/manage/{token}"
# clients.domain is free text from the onboarding form (e.g. "cafe-lichtblick.de",
# but nothing stops "https://cafe-lichtblick.de/" being entered) -- strip any
# scheme/path/whitespace so a malformed value can't end up in a From header.
_DOMAIN_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*://)?([^/\s]+)", re.IGNORECASE)
def _clean_domain(domain):
m = _DOMAIN_RE.match((domain or "").strip())
return m.group(1).lower() if m else None
def _sender_for(client):
domain = _clean_domain((client or {}).get("domain"))
return f"noreply@{domain}" if domain else mailer.MAIL_FALLBACK_FROM
def send_booking_confirmation(client, booking, token):
"""No-op if customer_contact doesn't look like an email address -- it's a
free-text "E-Mail oder Telefon" field (#16/#17), so this is expected for
phone-only customers, not an error."""
if not looks_like_email(booking.get("customer_contact")):
print(f"[booking_mail] customer_contact for booking "
f"{booking.get('booking_id')} doesn't look like an email, "
f"skipping confirmation send", flush=True)
return
tz = ZoneInfo((client or {}).get("timezone") or "Europe/Berlin")
business_name = (client or {}).get("business_name") or "Ihr Termin"
html = render_template(
"emails/booking_confirmation.html",
business_name=business_name,
customer_name=booking["customer_name"],
service=booking["service"],
start_local=booking["start_time"].astimezone(tz),
status=booking["status"],
manage_url=manage_url(token),
)
mailer.send_email(
booking["customer_contact"],
f"Terminbestätigung {business_name}",
html,
from_addr=_sender_for(client),
)
+59
View File
@@ -0,0 +1,59 @@
"""Best-effort SMTP email sending (#18).
Mirrors app.py's DB -> Sheets mirror pattern: a single background worker
thread drains a queue, and a send failure is logged, never raised back to the
caller -- booking creation must succeed even if the mail relay is down.
"""
import os
import queue
import smtplib
import threading
import traceback
from email.message import EmailMessage
SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "")
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
# Used when a client has no domain configured -- see booking_mail.py.
MAIL_FALLBACK_FROM = os.environ.get("MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
_queue = queue.Queue()
def _send_now(msg):
if not SMTP_HOST:
print(f"[mailer] SMTP_HOST not configured, skipping send to {msg['To']}", flush=True)
return
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as smtp:
smtp.starttls()
if SMTP_USERNAME:
smtp.login(SMTP_USERNAME, SMTP_PASSWORD)
smtp.send_message(msg)
def _worker():
while True:
msg = _queue.get()
try:
_send_now(msg)
except Exception: # noqa: BLE001
print(f"[mailer] send to {msg['To']} failed:", flush=True)
traceback.print_exc()
finally:
_queue.task_done()
threading.Thread(target=_worker, daemon=True).start()
def send_email(to_addr, subject, html_body, from_addr=None):
"""Queue an HTML email for best-effort async delivery. Never raises and
never blocks the caller on the network -- the actual SMTP conversation
happens on the background worker thread."""
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = from_addr or MAIL_FALLBACK_FROM
msg["To"] = to_addr
msg.set_content(html_body, subtype="html")
_queue.put(msg)
+51
View File
@@ -0,0 +1,51 @@
"""Customer self-service manage-booking page (#18), reached via the
token-linked link sent in the confirmation email (booking_mail.py). The
signed token (booking_api.verify_manage_token) is the only source of
identity here -- customers have no account, so this route needs no login.
The actual cancel/reschedule mutations still go through booking_api.py's
JSON API (#16); this route only resolves the token, decides which of the
three states (invalid/expired, already used, active) to render, and lets the
page's own JS drive the API from there.
"""
from flask import Blueprint, render_template
from zoneinfo import ZoneInfo
import booking_db as bdb
from booking_api import verify_manage_token
bp = Blueprint("manage_booking", __name__)
@bp.get("/manage/<token>")
def manage_page(token):
resolved = verify_manage_token(token)
if resolved is None:
# Distinct from "already used" per #18's acceptance criteria -- an
# expired/malformed token never resolved to a booking at all.
return render_template("manage.html", state="invalid"), 400
client_id, booking_id = resolved
booking = bdb.get_booking(client_id, booking_id)
client = bdb.get_client(client_id)
if booking is None or client is None:
return render_template("manage.html", state="invalid"), 404
if booking["status"] == "cancelled":
return render_template("manage.html", state="used")
tz = ZoneInfo(client.get("timezone") or "Europe/Berlin")
duration_minutes = int(
(booking["end_time"] - booking["start_time"]).total_seconds() // 60)
return render_template(
"manage.html",
state="active",
token=token,
client_id=client_id,
booking_id=booking_id,
resource_id=booking["resource_id"],
service=booking["service"],
duration_minutes=duration_minutes,
start_local=booking["start_time"].astimezone(tz),
status=booking["status"],
)
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Terminbestätigung</title>
</head>
<body style="margin:0; padding:20px; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color:#16302f;">
<h1 style="font-size:1.2rem; margin:0 0 14px;">{{ business_name }}</h1>
<p>Hallo {{ customer_name }},</p>
{% if status == 'pending' %}
<p>Ihre Buchung wartet noch auf Bestätigung:</p>
{% else %}
<p>Ihre Buchung ist bestätigt:</p>
{% endif %}
<ul>
<li>Leistung: {{ service }}</li>
<li>Termin: {{ start_local.strftime('%d.%m.%Y, %H:%M') }} Uhr</li>
</ul>
<p>
Über den folgenden Link können Sie Ihren Termin jederzeit einsehen, verschieben oder
stornieren:
</p>
<p><a href="{{ manage_url }}">{{ manage_url }}</a></p>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Termin verwalten</title>
<style>
:root {
--brand: #0f8a7e;
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
--danger: #b3261e; --danger-bg: #fdecea;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg); color: var(--ink); padding: 20px; max-width: 480px; }
h1 { font-size: 1.2rem; margin: 0 0 18px; }
h2 { font-size: .95rem; margin: 0 0 10px; color: var(--muted);
text-transform: uppercase; letter-spacing: .5px; }
section { margin-bottom: 22px; }
.options { display: flex; flex-wrap: wrap; gap: 8px; }
.opt { border: 1px solid var(--line); border-radius: 9px; padding: 10px 14px;
background: #fff; cursor: pointer; font: inherit; font-size: .88rem; }
.opt.selected { background: var(--brand); border-color: var(--brand); color: #fff; }
input[type=date] { width: 100%; max-width: 320px; padding: 9px 12px;
border: 1px solid var(--line); border-radius: 9px; font: inherit; font-size: .9rem; }
.field { margin-bottom: 14px; }
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 9px;
padding: 11px 20px; font: inherit; font-size: .92rem; font-weight: 600; cursor: pointer; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-danger { background: var(--danger); }
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; display: none; }
.card { border: 1px solid var(--line); border-radius: 12px; padding: 20px;
background: #f6faf9; }
.muted { color: var(--muted); font-size: .85rem; }
</style>
</head>
<body>
{% if state == "invalid" %}
<h1>Termin verwalten</h1>
<div class="card">
<p>Dieser Link ist ungültig oder abgelaufen.</p>
<p class="muted">Bitte wenden Sie sich an das Unternehmen, wenn Sie Ihren Termin ändern
möchten.</p>
</div>
{% elif state == "used" %}
<h1>Termin verwalten</h1>
<div class="card">
<p>Diese Buchung wurde bereits storniert.</p>
<p class="muted">Dieser Link kann nicht mehr verwendet werden.</p>
</div>
{% else %}
<div id="manage-root">
<h1>Termin verwalten</h1>
<div class="error" id="error-banner"></div>
<section id="current-section">
<h2>Ihr Termin</h2>
<p id="current-details">
{{ service }} am {{ start_local.strftime('%d.%m.%Y') }} um
{{ start_local.strftime('%H:%M') }} Uhr
({{ 'wartet noch auf Bestätigung' if status == 'pending' else 'bestätigt' }})
</p>
<button type="button" class="btn btn-danger" id="cancel-btn">Termin stornieren</button>
</section>
<section id="reschedule-section">
<h2>Termin verschieben</h2>
<div class="field">
<input type="date" id="date-input" />
</div>
<div class="options" id="slot-options"></div>
<button type="button" class="btn" id="reschedule-btn" disabled style="margin-top:12px;">
Auf neuen Termin verschieben
</button>
</section>
<div class="card" id="confirmation" style="display:none;">
<h2>Erledigt</h2>
<p id="confirmation-message"></p>
</div>
</div>
<script>
(function () {
var TOKEN = {{ token|tojson }};
var CLIENT_ID = {{ client_id|tojson }};
var RESOURCE_ID = {{ resource_id|tojson }};
var BOOKING_ID = {{ booking_id|tojson }};
var DURATION_MINUTES = {{ duration_minutes|tojson }};
var selectedSlot = null;
var errorBanner = document.getElementById('error-banner');
var dateInput = document.getElementById('date-input');
var slotOptions = document.getElementById('slot-options');
var rescheduleBtn = document.getElementById('reschedule-btn');
var cancelBtn = document.getElementById('cancel-btn');
var confirmation = document.getElementById('confirmation');
var confirmationMessage = document.getElementById('confirmation-message');
var today = new Date();
dateInput.value = today.toISOString().slice(0, 10);
dateInput.min = today.toISOString().slice(0, 10);
function showError(msg) {
errorBanner.textContent = msg;
errorBanner.style.display = msg ? 'block' : 'none';
}
function showDone(msg) {
document.getElementById('manage-root').querySelectorAll('section').forEach(function (s) {
s.style.display = 'none';
});
errorBanner.style.display = 'none';
confirmationMessage.textContent = msg;
confirmation.style.display = 'block';
}
function loadSlots() {
slotOptions.innerHTML = '';
selectedSlot = null;
rescheduleBtn.disabled = true;
if (!dateInput.value) {
return;
}
var day = dateInput.value;
var params = new URLSearchParams({
client_id: CLIENT_ID, resource_id: RESOURCE_ID,
duration_minutes: DURATION_MINUTES, exclude_booking_id: BOOKING_ID,
date_from: day, date_to: day,
});
fetch('/api/booking/slots?' + params.toString())
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.slots || data.slots.length === 0) {
slotOptions.innerHTML = '<p class="muted">Keine freien Termine an diesem Tag.</p>';
return;
}
data.slots.forEach(function (iso) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'opt';
var d = new Date(iso);
btn.textContent = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
btn.addEventListener('click', function () {
slotOptions.querySelectorAll('.opt').forEach(function (b) {
b.classList.remove('selected');
});
btn.classList.add('selected');
selectedSlot = iso;
rescheduleBtn.disabled = false;
});
slotOptions.appendChild(btn);
});
})
.catch(function () {
showError('Termine konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
});
}
dateInput.addEventListener('change', loadSlots);
cancelBtn.addEventListener('click', function () {
showError('');
cancelBtn.disabled = true;
fetch('/api/booking/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN }),
})
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, body: body }; }); })
.then(function (res) {
if (!res.ok) {
showError(res.body.error || 'Die Stornierung ist fehlgeschlagen.');
cancelBtn.disabled = false;
return;
}
showDone('Ihr Termin wurde storniert.');
})
.catch(function () {
showError('Die Stornierung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
cancelBtn.disabled = false;
});
});
rescheduleBtn.addEventListener('click', function () {
showError('');
rescheduleBtn.disabled = true;
fetch('/api/booking/reschedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN, start_time: selectedSlot }),
})
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body }; }); })
.then(function (res) {
if (!res.ok) {
if (res.status === 409) {
showError('Dieser Termin wurde gerade eben vergeben. Bitte wählen Sie einen anderen.');
loadSlots();
} else {
showError(res.body.error || 'Die Verschiebung ist fehlgeschlagen.');
}
rescheduleBtn.disabled = false;
return;
}
showDone('Ihr Termin wurde verschoben auf '
+ new Date(selectedSlot).toLocaleString('de-DE', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit',
}) + '.');
})
.catch(function () {
showError('Die Verschiebung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
rescheduleBtn.disabled = false;
});
});
loadSlots();
})();
</script>
{% endif %}
</body>
</html>
+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