2f6e0c1459
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>
120 lines
4.8 KiB
Python
120 lines
4.8 KiB
Python
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 == []
|