Public booking page + iframe embed (#17)
Adds the customer-facing /book/<slug> page: service/slot picker, booking form, and confirmation screen, built on #16's existing booking JSON API. Includes iframe auto-fit height reporting (mirroring deploy/booking/booking_layout.js's eaBookingHeight message), brand-color theming via a ?color= query param, a honeypot field with a fake-success response indistinguishable from a real booking, and a clear "just taken" message on slot-conflict. Caddy per-IP rate limiting is documented in deploy/booking/RATE_LIMIT.md for manual application (no Caddyfile is tracked in this repo). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,9 +20,11 @@ from waitress import serve
|
||||
import db
|
||||
from sheets import Sheets
|
||||
from booking_api import bp as booking_bp
|
||||
from public_booking import bp as public_booking_bp
|
||||
|
||||
app = Flask(__name__, static_folder="static", static_url_path="")
|
||||
app.register_blueprint(booking_bp)
|
||||
app.register_blueprint(public_booking_bp)
|
||||
|
||||
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
|
||||
# Separate read-only token for the public iCal feed (calendar apps can't send
|
||||
|
||||
@@ -117,6 +117,19 @@ def slots():
|
||||
@bp.post("")
|
||||
def create_booking():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
if (body.get("website") or "").strip():
|
||||
# Honeypot field: real customers never see or fill it (hidden from
|
||||
# sighted users and screen readers alike), so a filled value means a
|
||||
# scripted bot filled every field it could find. Fake a normal-looking
|
||||
# success instead of a 4xx so a scripted client has no signal it was
|
||||
# caught -- no booking is created, but the id/token are shaped exactly
|
||||
# like a real create_booking() response (same id format, same client_id
|
||||
# in the token's claims) so nothing about this response is
|
||||
# distinguishable from a genuine one by a client inspecting it.
|
||||
fake_booking_id = bdb.new_id("BK")
|
||||
return jsonify({"booking_id": fake_booking_id, "status": "confirmed",
|
||||
"token": _mint_manage_token(body.get("client_id") or "",
|
||||
fake_booking_id)}), 201
|
||||
client_id = body.get("client_id")
|
||||
resource_id = body.get("resource_id")
|
||||
service_id = body.get("service_id")
|
||||
|
||||
@@ -26,15 +26,23 @@ class UnknownResource(Exception):
|
||||
guards against a booking write smuggling in another tenant's resource."""
|
||||
|
||||
|
||||
def _new_id(prefix):
|
||||
def new_id(prefix):
|
||||
return f"{prefix}-{int(time.time() * 1000)}-{secrets.token_hex(3)}"
|
||||
|
||||
|
||||
def _list_active(table, client_id):
|
||||
with db.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT * FROM {table} WHERE client_id = %s AND active ORDER BY name",
|
||||
(client_id,))
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
# ---- resources ----
|
||||
|
||||
def create_resource(client_id, name, active=True, min_notice_minutes=60,
|
||||
max_advance_days=30, buffer_minutes=0):
|
||||
resource_id = _new_id("RS")
|
||||
resource_id = new_id("RS")
|
||||
with db.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO resources (resource_id, client_id, name, active, "
|
||||
@@ -90,7 +98,7 @@ def get_resource_hours(client_id, resource_id):
|
||||
# ---- services ----
|
||||
|
||||
def create_service(client_id, name, duration_minutes, price=None, active=True):
|
||||
service_id = _new_id("SV")
|
||||
service_id = new_id("SV")
|
||||
with db.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO services (service_id, client_id, name, duration_minutes, "
|
||||
@@ -109,6 +117,14 @@ def get_service(client_id, service_id):
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def list_active_services(client_id):
|
||||
return _list_active("services", client_id)
|
||||
|
||||
|
||||
def list_active_resources(client_id):
|
||||
return _list_active("resources", client_id)
|
||||
|
||||
|
||||
# ---- bookings ----
|
||||
|
||||
_BOOKING_UPDATABLE = {"resource_id", "customer_name", "customer_contact",
|
||||
@@ -126,7 +142,7 @@ def create_booking(client_id, resource_id, customer_name, customer_contact,
|
||||
into a clean error."""
|
||||
if get_resource(client_id, resource_id) is None:
|
||||
raise UnknownResource(f"no resource {resource_id} for client {client_id}")
|
||||
booking_id = _new_id("BK")
|
||||
booking_id = new_id("BK")
|
||||
try:
|
||||
with db.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -224,10 +240,20 @@ def get_client(client_id):
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def get_client_by_slug(slug):
|
||||
"""Resolve a client for the public /book/<slug> page. slug is
|
||||
unauthenticated user input, so this is the one lookup that goes straight
|
||||
from an untrusted string to a client_id -- every other public-booking
|
||||
call still requires the resolved client_id explicitly."""
|
||||
with db.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM clients WHERE slug = %s", (slug,))
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
# ---- users (owner login) ----
|
||||
|
||||
def create_user(client_id, email, password):
|
||||
user_id = _new_id("U")
|
||||
user_id = new_id("U")
|
||||
password_hash = generate_password_hash(password)
|
||||
with db.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Public booking page blueprint (#17): the customer-facing /book/<slug> page,
|
||||
embedded via iframe into a client's landing page. This route only resolves a
|
||||
slug to a client and renders the slot-grid/booking-form page around it -- all
|
||||
booking mutations still go through booking_api.py's JSON API (#16), which the
|
||||
page's own JS calls via fetch.
|
||||
|
||||
IP rate limiting on these public routes is enforced at the Caddy layer (per
|
||||
#17's acceptance criteria), not in Flask -- see deploy/booking/RATE_LIMIT.md
|
||||
for the Caddy config to apply by hand on the homelab.
|
||||
"""
|
||||
from flask import Blueprint, abort, render_template, request
|
||||
|
||||
import booking_db as bdb
|
||||
|
||||
bp = Blueprint("public_booking", __name__)
|
||||
|
||||
DEFAULT_BRAND_COLOR = "#0f8a7e"
|
||||
|
||||
|
||||
@bp.get("/book/<slug>")
|
||||
def book_page(slug):
|
||||
client = bdb.get_client_by_slug(slug)
|
||||
if client is None:
|
||||
abort(404)
|
||||
resources = bdb.list_active_resources(client["client_id"])
|
||||
services = bdb.list_active_services(client["client_id"])
|
||||
if not resources or not services:
|
||||
# No bookable services/resources configured yet -- nothing to show a
|
||||
# customer rather than a broken/empty booking form.
|
||||
abort(404)
|
||||
return render_template(
|
||||
"book.html",
|
||||
client_id=client["client_id"],
|
||||
business_name=client.get("business_name") or slug,
|
||||
resources=[{"resource_id": r["resource_id"], "name": r["name"]} for r in resources],
|
||||
services=[{"service_id": s["service_id"], "name": s["name"],
|
||||
"duration_minutes": s["duration_minutes"],
|
||||
"price": float(s["price"]) if s["price"] is not None else None}
|
||||
for s in services],
|
||||
brand_color=request.args.get("color") or DEFAULT_BRAND_COLOR,
|
||||
)
|
||||
@@ -0,0 +1,315 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Termin buchen — {{ business_name }}</title>
|
||||
<style>
|
||||
:root {
|
||||
--brand: {{ brand_color }};
|
||||
--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; }
|
||||
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; }
|
||||
.opt:disabled { opacity: .4; cursor: not-allowed; }
|
||||
label { display: block; font-size: .82rem; color: var(--muted); margin-bottom: 5px; }
|
||||
input[type=date], input[type=text], input[type=email] {
|
||||
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; }
|
||||
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
|
||||
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; display: none; }
|
||||
.confirmation { display: none; border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 20px; background: #f6faf9; }
|
||||
.confirmation h2 { color: var(--ink); text-transform: none; letter-spacing: normal; font-size: 1.05rem; }
|
||||
.muted { color: var(--muted); font-size: .85rem; }
|
||||
/* Honeypot: hidden from sighted users and from screen readers, but present
|
||||
in the DOM/markup and tab order so a scripted client filling every
|
||||
visible-looking field still trips it. */
|
||||
.hp-field { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="booking-root">
|
||||
<h1>Termin buchen — {{ business_name }}</h1>
|
||||
|
||||
<div class="error" id="error-banner"></div>
|
||||
|
||||
<section>
|
||||
<h2>Leistung</h2>
|
||||
<div class="options" id="service-options">
|
||||
{% for s in services %}
|
||||
<button type="button" class="opt" data-service-id="{{ s.service_id }}"
|
||||
data-duration="{{ s.duration_minutes }}">
|
||||
{{ s.name }} ({{ s.duration_minutes }} min{% if s.price is not none %}, {{ "%.2f"|format(s.price) }} €{% endif %})
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if resources|length > 1 %}
|
||||
<section>
|
||||
<h2>Mitarbeiter</h2>
|
||||
<div class="options" id="resource-options">
|
||||
{% for r in resources %}
|
||||
<button type="button" class="opt" data-resource-id="{{ r.resource_id }}">{{ r.name }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2>Datum</h2>
|
||||
<div class="field">
|
||||
<input type="date" id="date-input" />
|
||||
</div>
|
||||
<div class="options" id="slot-options"></div>
|
||||
</section>
|
||||
|
||||
<section id="details-section">
|
||||
<h2>Ihre Daten</h2>
|
||||
<div class="field">
|
||||
<label for="customer-name">Name</label>
|
||||
<input type="text" id="customer-name" autocomplete="name" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="customer-contact">E-Mail oder Telefon</label>
|
||||
<input type="text" id="customer-contact" autocomplete="email" />
|
||||
</div>
|
||||
<!-- Honeypot: real customers never see this field; bots that fill every
|
||||
field in the DOM do, and get silently rejected server-side. -->
|
||||
<div class="field hp-field" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
<button type="button" class="btn" id="submit-btn" disabled>Termin buchen</button>
|
||||
</section>
|
||||
|
||||
<div class="confirmation" id="confirmation">
|
||||
<h2>Termin bestätigt</h2>
|
||||
<p id="confirmation-details"></p>
|
||||
<p class="muted" id="confirmation-status"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var CLIENT_ID = {{ client_id|tojson }};
|
||||
var RESOURCES = {{ resources|tojson }};
|
||||
var services = {{ services|tojson }};
|
||||
|
||||
var selectedService = null;
|
||||
var selectedResource = RESOURCES.length === 1 ? RESOURCES[0].resource_id : null;
|
||||
var selectedSlot = null;
|
||||
|
||||
var errorBanner = document.getElementById('error-banner');
|
||||
var dateInput = document.getElementById('date-input');
|
||||
var slotOptions = document.getElementById('slot-options');
|
||||
var submitBtn = document.getElementById('submit-btn');
|
||||
var confirmation = document.getElementById('confirmation');
|
||||
|
||||
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 updateSubmitState() {
|
||||
var name = document.getElementById('customer-name').value.trim();
|
||||
var contact = document.getElementById('customer-contact').value.trim();
|
||||
submitBtn.disabled = !(selectedService && selectedResource && selectedSlot
|
||||
&& name && contact);
|
||||
}
|
||||
|
||||
// Wires an "options" button group (service/resource pickers) so
|
||||
// clicking a button marks it selected, clears its siblings, and hands
|
||||
// the button to onPick -- both groups share this exact select-one shape.
|
||||
function wireOptionGroup(container, onPick) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.querySelectorAll('.opt').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
container.querySelectorAll('.opt').forEach(function (b) {
|
||||
b.classList.remove('selected');
|
||||
});
|
||||
btn.classList.add('selected');
|
||||
onPick(btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
wireOptionGroup(document.getElementById('service-options'), function (btn) {
|
||||
selectedService = btn.getAttribute('data-service-id');
|
||||
selectedSlot = null;
|
||||
loadSlots();
|
||||
});
|
||||
|
||||
wireOptionGroup(document.getElementById('resource-options'), function (btn) {
|
||||
selectedResource = btn.getAttribute('data-resource-id');
|
||||
selectedSlot = null;
|
||||
loadSlots();
|
||||
});
|
||||
|
||||
dateInput.addEventListener('change', function () {
|
||||
selectedSlot = null;
|
||||
loadSlots();
|
||||
});
|
||||
|
||||
function loadSlots() {
|
||||
slotOptions.innerHTML = '';
|
||||
updateSubmitState();
|
||||
if (!selectedService || !selectedResource || !dateInput.value) {
|
||||
return;
|
||||
}
|
||||
var day = dateInput.value;
|
||||
var params = new URLSearchParams({
|
||||
client_id: CLIENT_ID, resource_id: selectedResource,
|
||||
service_id: selectedService, 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;
|
||||
updateSubmitState();
|
||||
});
|
||||
slotOptions.appendChild(btn);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Termine konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('customer-name').addEventListener('input', updateSubmitState);
|
||||
document.getElementById('customer-contact').addEventListener('input', updateSubmitState);
|
||||
|
||||
submitBtn.addEventListener('click', function () {
|
||||
showError('');
|
||||
submitBtn.disabled = true;
|
||||
fetch('/api/booking', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID, resource_id: selectedResource,
|
||||
service_id: selectedService, start_time: selectedSlot,
|
||||
customer_name: document.getElementById('customer-name').value.trim(),
|
||||
customer_contact: document.getElementById('customer-contact').value.trim(),
|
||||
website: document.getElementById('website').value,
|
||||
}),
|
||||
})
|
||||
.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.');
|
||||
selectedSlot = null;
|
||||
loadSlots();
|
||||
} else {
|
||||
showError(res.body.error || 'Die Buchung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||
}
|
||||
updateSubmitState();
|
||||
return;
|
||||
}
|
||||
var service = services.find(function (s) { return s.service_id === selectedService; });
|
||||
document.getElementById('booking-root').querySelectorAll('section').forEach(function (s) {
|
||||
s.style.display = 'none';
|
||||
});
|
||||
errorBanner.style.display = 'none';
|
||||
document.getElementById('confirmation-details').textContent =
|
||||
(service ? service.name : 'Termin') + ' am '
|
||||
+ new Date(selectedSlot).toLocaleString('de-DE', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
document.getElementById('confirmation-status').textContent =
|
||||
res.body.status === 'pending'
|
||||
? 'Ihre Buchung wartet noch auf Bestätigung.'
|
||||
: 'Ihre Buchung ist bestätigt.';
|
||||
confirmation.style.display = 'block';
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Die Buchung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||
updateSubmitState();
|
||||
});
|
||||
});
|
||||
|
||||
loadSlots();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Iframe auto-fit: report rendered height to the parent frame so an
|
||||
embedding landing page can size the iframe without an inner scrollbar.
|
||||
Mirrors deploy/booking/booking_layout.js's approach (same message key,
|
||||
eaBookingHeight, so an existing embedding page's listener needs no
|
||||
change when its src is repointed at this page). -->
|
||||
<script>
|
||||
(function () {
|
||||
function reportHeight() {
|
||||
var el = document.getElementById('booking-root');
|
||||
var h = el ? Math.ceil(el.getBoundingClientRect().height) + 40 : document.body.scrollHeight;
|
||||
if (h && h > 0) {
|
||||
try { window.parent.postMessage({ eaBookingHeight: h }, '*'); } catch (e) { /* not embedded */ }
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (window.parent === window) {
|
||||
return;
|
||||
}
|
||||
reportHeight();
|
||||
var target = document.getElementById('booking-root') || document.body;
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(reportHeight).observe(target);
|
||||
}
|
||||
if (window.MutationObserver) {
|
||||
new MutationObserver(reportHeight).observe(target, { subtree: true, childList: true, attributes: true });
|
||||
}
|
||||
window.addEventListener('resize', reportHeight);
|
||||
var ticks = 0;
|
||||
var iv = setInterval(function () {
|
||||
reportHeight();
|
||||
if (++ticks > 25) { clearInterval(iv); }
|
||||
}, 250);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Flask test client / real-DB integration tests for the public booking page
|
||||
and its honeypot abuse-protection (#17), 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
|
||||
|
||||
CLIENT_A = "C-TEST-PUBLIC-A"
|
||||
CLIENT_B = "C-TEST-PUBLIC-B"
|
||||
|
||||
|
||||
@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 _make_client(client_id=CLIENT_A, slug="happynails", business_name="Happy Nails"):
|
||||
with bdb.db.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO clients (client_id, business_name, slug, timezone, auto_confirm) "
|
||||
"VALUES (%s, %s, %s, %s, %s) "
|
||||
"ON CONFLICT (client_id) DO UPDATE SET business_name = EXCLUDED.business_name, "
|
||||
"slug = EXCLUDED.slug, timezone = EXCLUDED.timezone, "
|
||||
"auto_confirm = EXCLUDED.auto_confirm",
|
||||
(client_id, business_name, slug, "Europe/Berlin", True))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _setup_bookable_client(client_id=CLIENT_A, slug="happynails"):
|
||||
_make_client(client_id, slug)
|
||||
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 test_book_page_renders_for_known_slug_with_services_and_resources(client):
|
||||
resource, service = _setup_bookable_client()
|
||||
resp = client.get("/book/happynails")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_data(as_text=True)
|
||||
assert "Happy Nails" in body
|
||||
assert "Haircut" in body
|
||||
assert resource["resource_id"] in body
|
||||
|
||||
|
||||
def test_book_page_404s_for_unknown_slug(client):
|
||||
resp = client.get("/book/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_book_page_404s_when_client_has_no_active_service(client):
|
||||
_make_client(CLIENT_B, slug="no-services-client")
|
||||
bdb.create_resource(CLIENT_B, "Chair 1")
|
||||
# No services created for this client.
|
||||
resp = client.get("/book/no-services-client")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_book_page_404s_when_client_has_only_inactive_service(client):
|
||||
_make_client(CLIENT_B, slug="inactive-service-client")
|
||||
bdb.create_resource(CLIENT_B, "Chair 1")
|
||||
bdb.create_service(CLIENT_B, "Haircut", 60, active=False)
|
||||
resp = client.get("/book/inactive-service-client")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_honeypot_filled_silently_rejects_booking(client):
|
||||
resource, service = _setup_bookable_client()
|
||||
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"]
|
||||
assert slot
|
||||
|
||||
resp = client.post("/api/booking", json={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"service_id": service["service_id"], "start_time": slot[0],
|
||||
"customer_name": "Bot", "customer_contact": "bot@example.com",
|
||||
"website": "https://spam.example"})
|
||||
|
||||
# Looks like an ordinary success to the caller...
|
||||
assert resp.status_code == 201
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "confirmed"
|
||||
assert "token" in body
|
||||
# ...but no booking was actually created.
|
||||
assert bdb.list_bookings(CLIENT_A) == []
|
||||
|
||||
|
||||
def test_honeypot_empty_creates_a_real_booking(client):
|
||||
resource, service = _setup_bookable_client()
|
||||
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"]
|
||||
|
||||
resp = client.post("/api/booking", json={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"service_id": service["service_id"], "start_time": slot[0],
|
||||
"customer_name": "Real Customer", "customer_contact": "real@example.com",
|
||||
"website": ""})
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert len(bdb.list_bookings(CLIENT_A)) == 1
|
||||
Reference in New Issue
Block a user