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:
@@ -0,0 +1,119 @@
|
||||
from datetime import date, datetime, time, timezone
|
||||
|
||||
import availability as av
|
||||
|
||||
BERLIN = "Europe/Berlin"
|
||||
NINE_TO_FIVE = {d: (time(9, 0), time(17, 0)) for d in range(7)}
|
||||
|
||||
|
||||
def _far_past():
|
||||
# "far enough in the past" relative to the 2026 test dates below, not
|
||||
# literally far past -- max_advance_days is finite, so "now" has to be
|
||||
# within max_advance_days of the date under test.
|
||||
return datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _far_future_notice():
|
||||
return {"min_notice_minutes": 0, "max_advance_days": 365 * 10}
|
||||
|
||||
|
||||
def test_normal_day_matches_hand_computed_slots():
|
||||
# 2026-07-06 is a Monday in Berlin summer time (CEST, UTC+2).
|
||||
day = date(2026, 7, 6)
|
||||
slots = av.generate_slots(
|
||||
{0: (time(9, 0), time(12, 0))}, duration_minutes=60,
|
||||
date_from=day, date_to=day, tz_name=BERLIN, now=_far_past(),
|
||||
**_far_future_notice())
|
||||
assert slots == [
|
||||
datetime(2026, 7, 6, 7, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 7, 6, 8, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc),
|
||||
]
|
||||
|
||||
|
||||
def test_dst_spring_forward_shifts_utc_offset_but_keeps_slot_count():
|
||||
# Berlin DST 2026 starts 2026-03-29 (clocks 02:00 -> 03:00 CET->CEST).
|
||||
before = date(2026, 3, 28) # CET, UTC+1
|
||||
on_day = date(2026, 3, 29) # transition day; business hours all CEST
|
||||
after = date(2026, 3, 30) # CEST, UTC+2
|
||||
|
||||
def slots_for(day):
|
||||
return av.generate_slots(
|
||||
NINE_TO_FIVE, duration_minutes=60, date_from=day, date_to=day,
|
||||
tz_name=BERLIN, now=_far_past(), **_far_future_notice())
|
||||
|
||||
before_slots = slots_for(before)
|
||||
on_day_slots = slots_for(on_day)
|
||||
after_slots = slots_for(after)
|
||||
|
||||
assert len(before_slots) == len(on_day_slots) == len(after_slots) == 8
|
||||
assert before_slots[0] == datetime(2026, 3, 28, 8, 0, tzinfo=timezone.utc)
|
||||
# one hour earlier in UTC once CEST (UTC+2) kicks in
|
||||
assert on_day_slots[0] == datetime(2026, 3, 29, 7, 0, tzinfo=timezone.utc)
|
||||
assert after_slots[0] == datetime(2026, 3, 30, 7, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_dst_fall_back_shifts_utc_offset_but_keeps_slot_count():
|
||||
# Berlin DST 2026 ends 2026-10-25 (clocks 03:00 -> 02:00 CEST->CET).
|
||||
before = date(2026, 10, 24) # CEST, UTC+2
|
||||
after = date(2026, 10, 26) # CET, UTC+1
|
||||
|
||||
def slots_for(day):
|
||||
return av.generate_slots(
|
||||
NINE_TO_FIVE, duration_minutes=60, date_from=day, date_to=day,
|
||||
tz_name=BERLIN, now=_far_past(), **_far_future_notice())
|
||||
|
||||
before_slots = slots_for(before)
|
||||
after_slots = slots_for(after)
|
||||
assert len(before_slots) == len(after_slots) == 8
|
||||
assert before_slots[0] == datetime(2026, 10, 24, 7, 0, tzinfo=timezone.utc)
|
||||
assert after_slots[0] == datetime(2026, 10, 26, 8, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_min_notice_excludes_near_term_slots():
|
||||
day = date(2026, 7, 6)
|
||||
now = datetime(2026, 7, 6, 6, 30, tzinfo=timezone.utc) # 08:30 local
|
||||
slots = av.generate_slots(
|
||||
{0: (time(9, 0), time(12, 0))}, duration_minutes=60,
|
||||
date_from=day, date_to=day, tz_name=BERLIN, now=now,
|
||||
min_notice_minutes=60, max_advance_days=365)
|
||||
# 07:00 UTC (09:00 local) is only 30min out -- excluded by 60min notice.
|
||||
assert slots == [
|
||||
datetime(2026, 7, 6, 8, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc),
|
||||
]
|
||||
|
||||
|
||||
def test_max_advance_excludes_far_future_slots():
|
||||
day = date(2026, 7, 6)
|
||||
now = datetime(2026, 7, 5, 0, 0, tzinfo=timezone.utc)
|
||||
slots = av.generate_slots(
|
||||
{0: (time(9, 0), time(12, 0))}, duration_minutes=60,
|
||||
date_from=day, date_to=day, tz_name=BERLIN, now=now,
|
||||
min_notice_minutes=0, max_advance_days=1)
|
||||
assert slots == []
|
||||
|
||||
|
||||
def test_buffer_excludes_slots_too_close_to_an_existing_booking():
|
||||
day = date(2026, 7, 6)
|
||||
# existing booking 09:00-10:00 local (07:00-08:00 UTC)
|
||||
busy = [(datetime(2026, 7, 6, 7, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 7, 6, 8, 0, tzinfo=timezone.utc))]
|
||||
slots = av.generate_slots(
|
||||
{0: (time(9, 0), time(12, 0))}, duration_minutes=60,
|
||||
date_from=day, date_to=day, tz_name=BERLIN, now=_far_past(),
|
||||
min_notice_minutes=0, max_advance_days=365, buffer_minutes=30,
|
||||
busy=busy)
|
||||
# 08:00 UTC (10:00 local) slot starts only 30min after the busy booking
|
||||
# ends at 08:00 -- exactly at the buffer boundary, so still blocked;
|
||||
# 09:00 UTC (11:00 local) is clear.
|
||||
assert slots == [datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc)]
|
||||
|
||||
|
||||
def test_no_hours_configured_for_weekday_yields_no_slots():
|
||||
day = date(2026, 7, 6) # Monday, but hours only configured for Tuesday
|
||||
slots = av.generate_slots(
|
||||
{1: (time(9, 0), time(12, 0))}, duration_minutes=60,
|
||||
date_from=day, date_to=day, tz_name=BERLIN, now=_far_past(),
|
||||
**_far_future_notice())
|
||||
assert slots == []
|
||||
@@ -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
|
||||
@@ -1,3 +1,4 @@
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
@@ -57,6 +58,39 @@ def test_overlapping_booking_same_resource_raises_conflict():
|
||||
"Haircut", _dt(10, 30), _dt(11, 30))
|
||||
|
||||
|
||||
def test_truly_concurrent_overlapping_inserts_only_one_wins():
|
||||
"""Two threads, each on its own DB connection, racing to insert the same
|
||||
overlapping slot -- not just a sequential second-request-fails check.
|
||||
The Postgres EXCLUDE constraint (not app-level locking) is what has to
|
||||
serialize this."""
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
start_barrier = threading.Barrier(2)
|
||||
results = {}
|
||||
|
||||
def attempt(name, customer):
|
||||
start_barrier.wait()
|
||||
try:
|
||||
results[name] = bdb.create_booking(
|
||||
CLIENT_A, r["resource_id"], customer, f"{customer}@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
except bdb.BookingConflict as e:
|
||||
results[name] = e
|
||||
|
||||
t1 = threading.Thread(target=attempt, args=("t1", "Race1"))
|
||||
t2 = threading.Thread(target=attempt, args=("t2", "Race2"))
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
outcomes = list(results.values())
|
||||
conflicts = [o for o in outcomes if isinstance(o, bdb.BookingConflict)]
|
||||
successes = [o for o in outcomes if not isinstance(o, bdb.BookingConflict)]
|
||||
assert len(conflicts) == 1
|
||||
assert len(successes) == 1
|
||||
assert len(bdb.list_bookings(CLIENT_A)) == 1
|
||||
|
||||
|
||||
def test_adjacent_non_overlapping_bookings_both_succeed():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
|
||||
Reference in New Issue
Block a user