Availability engine + booking API: create/cancel/reschedule (#16)

Adds the plumbing that makes "can a customer actually get booked" true
end to end at the API layer, on top of #15's schema/tenancy layer.

- resource_hours table + min_notice_minutes/max_advance_days/buffer_minutes
  on resources -- config #15 didn't include but #16 depends on.
- availability.py: pure slot-generation function, correct across a
  Europe/Berlin DST transition (tested both directions).
- booking_api.py: JSON blueprint for slot listing, booking creation
  (auto_confirm -> confirmed/pending), and signed-JWT cancel/reschedule,
  registered into app.py.
- booking_db.py gains resource-hours CRUD, a tenant-scoped busy-bookings
  query for buffer/slot validation, and a read-only client lookup.

A true concurrent-threads test (not just sequential requests) surfaced a
real gap: Postgres can raise DeadlockDetected instead of ExclusionViolation
when two overlapping inserts race the exclusion constraint directly, which
went uncaught and would have 500'd instead of giving the clean 4xx the
ticket requires -- now caught alongside ExclusionViolation.

Also fixed: reschedule used the request's raw UTC offset to pick the
business day instead of the client's own timezone (could pick the wrong
day's hours/bookings near local midnight); the cancel/reschedule JWT no
longer falls back to reusing CRM_API_TOKEN as its signing secret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:52:33 +02:00
parent b5c0fc8a5a
commit 2f6e0c1459
11 changed files with 789 additions and 15 deletions
+262
View File
@@ -0,0 +1,262 @@
"""Flask test client / real-DB integration tests for the booking API (#16),
per #14's testing decision: assert on HTTP response + resulting DB state,
not on which internal function got called.
"""
from datetime import date, datetime, time, timedelta, timezone
import pytest
import booking_db as bdb
from app import app as flask_app
CLIENT_A = "C-TEST-A"
CLIENT_B = "C-TEST-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 _setup_resource_and_service(client_id=CLIENT_A, auto_confirm=True, **resource_kwargs):
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", auto_confirm))
conn.commit()
resource = bdb.create_resource(client_id, "Chair 1", **resource_kwargs)
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_slots_endpoint_lists_bookable_starts(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
resp = 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()})
assert resp.status_code == 200
body = resp.get_json()
assert len(body["slots"]) == 8 # 09:00-17:00, 60min slots
def test_create_booking_auto_confirm_true_yields_confirmed(client):
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": "Alice", "customer_contact": "alice@example.com"})
assert resp.status_code == 201
body = resp.get_json()
assert body["status"] == "confirmed"
assert "token" in body
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)
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": "Bob", "customer_contact": "bob@example.com"})
assert resp.status_code == 201
assert resp.get_json()["status"] == "pending"
def test_create_booking_rejects_slot_outside_business_rules(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
# 20:00 is outside the 09:00-17:00 hours configured above.
outside = datetime.combine(day, time(20, 0), tzinfo=timezone.utc).isoformat()
resp = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": outside,
"customer_name": "Carl", "customer_contact": "c@example.com"})
assert resp.status_code == 409
def test_concurrent_booking_requests_only_one_succeeds(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
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]
payload = {"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot,
"customer_name": "Race1", "customer_contact": "r1@example.com"}
first = client.post("/api/booking", json=payload)
payload2 = dict(payload, customer_name="Race2", customer_contact="r2@example.com")
second = client.post("/api/booking", json=payload2)
statuses = sorted([first.status_code, second.status_code])
assert statuses == [201, 409]
assert len(bdb.list_bookings(CLIENT_A)) == 1
def test_cancel_with_valid_token_cancels_booking(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
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 = 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": "d@example.com"}).get_json()
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"
# already-used: cancelling an already-cancelled booking is rejected, not
# silently repeated.
resp2 = client.post("/api/booking/cancel", json={"token": created["token"]})
assert resp2.status_code == 409
# garbage token is rejected cleanly, not a 500.
resp3 = client.post("/api/booking/cancel", json={"token": "not-a-real-token"})
assert resp3.status_code == 400
def test_cancel_with_expired_token_is_rejected(client):
import jwt as pyjwt
from booking_api import TOKEN_SECRET
resource, service = _setup_resource_and_service()
booking = bdb.create_booking(
CLIENT_A, resource["resource_id"], "Zara", "z@example.com", "Haircut",
datetime.now(timezone.utc) + timedelta(days=1),
datetime.now(timezone.utc) + timedelta(days=1, hours=1))
expired = pyjwt.encode(
{"client_id": CLIENT_A, "booking_id": booking["booking_id"],
"exp": datetime.now(timezone.utc) - timedelta(minutes=1)},
TOKEN_SECRET, algorithm="HS256")
resp = client.post("/api/booking/cancel", json={"token": expired})
assert resp.status_code == 400
assert bdb.get_booking(CLIENT_A, booking["booking_id"])["status"] != "cancelled"
def test_reschedule_of_already_cancelled_booking_is_rejected(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
slot_list = 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"]
created = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot_list[0],
"customer_name": "Jan", "customer_contact": "j@example.com"}).get_json()
client.post("/api/booking/cancel", json={"token": created["token"]})
resp = client.post("/api/booking/reschedule", json={
"token": created["token"], "start_time": slot_list[1]})
assert resp.status_code == 409
def test_reschedule_with_valid_token_updates_time(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
slot_list = 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"]
created = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot_list[0],
"customer_name": "Eve", "customer_contact": "e@example.com"}).get_json()
resp = client.post("/api/booking/reschedule", json={
"token": created["token"], "start_time": slot_list[2]})
assert resp.status_code == 200
booking = bdb.get_booking(CLIENT_A, created["booking_id"])
assert booking["start_time"].isoformat() == slot_list[2]
def test_reschedule_into_occupied_slot_fails_cleanly(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
slot_list = 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"]
first = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot_list[0],
"customer_name": "Fay", "customer_contact": "f@example.com"}).get_json()
second = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot_list[1],
"customer_name": "Gus", "customer_contact": "g@example.com"}).get_json()
resp = client.post("/api/booking/reschedule", json={
"token": second["token"], "start_time": slot_list[0]})
assert resp.status_code == 409
assert bdb.get_booking(CLIENT_A, second["booking_id"])["start_time"].isoformat() \
== slot_list[1]
def test_reschedule_to_same_slot_is_a_noop_success(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
slot_list = 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"]
created = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot_list[0],
"customer_name": "Hana", "customer_contact": "h@example.com"}).get_json()
resp = client.post("/api/booking/reschedule", json={
"token": created["token"], "start_time": slot_list[0]})
assert resp.status_code == 200
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
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"])
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
assert _verify_manage_token(tampered) is None