- FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration - SchedulingProvider interface with google_calendar (free/busy read-only), partner_api (Appendix B client) and mock implementations - Proposal engine: create → provider-routed delivery → owner actions (resolve/confirm+SMS/reject) → expiry + reminders (§9) - Signed single-use action links, .ics METHOD:REQUEST attachment - Partner outcome webhook with HMAC verification + polling fallback - SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP) - Fake partner API server in tests/ — Appendix B reference implementation - 43 tests: slot math, proposal lifecycle, action links, partner contract Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
97 lines
3.1 KiB
Python
97 lines
3.1 KiB
Python
"""Slot computation (§8.1): working hours − busy, quantization, notice, horizon."""
|
||
|
||
from datetime import date, datetime
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from gogo.scheduling.slots import compute_slots, subtract_busy
|
||
|
||
TZ = ZoneInfo("Europe/Sarajevo")
|
||
|
||
|
||
def dt(day: int, h: int, m: int = 0) -> datetime:
|
||
return datetime(2026, 7, day, h, m, tzinfo=TZ)
|
||
|
||
|
||
WH = {
|
||
"mon": [["09:00", "13:00"], ["14:00", "18:00"]], # lunch break
|
||
"tue": [["09:00", "18:00"]],
|
||
"wed": [["09:00", "18:00"]],
|
||
"thu": [["09:00", "18:00"]],
|
||
"fri": [["09:00", "18:00"]],
|
||
"sat": [["09:00", "14:00"]],
|
||
"sun": [],
|
||
}
|
||
|
||
# Mon 2026-07-13 .. Sun 2026-07-19
|
||
NOW = dt(13, 8, 0) # Monday 08:00
|
||
|
||
|
||
def test_subtract_busy_splits_interval():
|
||
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 10), dt(13, 11))])
|
||
assert free == [(dt(13, 9), dt(13, 10)), (dt(13, 11), dt(13, 13))]
|
||
|
||
|
||
def test_subtract_busy_no_overlap():
|
||
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 14), dt(13, 15))])
|
||
assert free == [(dt(13, 9), dt(13, 13))]
|
||
|
||
|
||
def test_slots_respect_break_and_duration():
|
||
slots = compute_slots(
|
||
working_hours=WH, busy=[], duration_min=60, date_from=date(2026, 7, 13),
|
||
date_to=date(2026, 7, 13), tz=TZ, now=NOW, min_notice_hours=0, max_slots=50,
|
||
)
|
||
starts = [s.start for s in slots]
|
||
# no slot may span the 13:00-14:00 break
|
||
assert dt(13, 13) not in starts
|
||
for s in slots:
|
||
assert not (s.start < dt(13, 14) and s.end > dt(13, 13))
|
||
assert slots[0].start == dt(13, 9)
|
||
assert all((s.end - s.start).total_seconds() == 3600 for s in slots)
|
||
|
||
|
||
def test_min_notice_pushes_first_slot():
|
||
slots = compute_slots(
|
||
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
|
||
date_to=date(2026, 7, 13), tz=TZ, now=dt(13, 9, 5), min_notice_hours=2,
|
||
)
|
||
assert slots[0].start >= dt(13, 11, 5)
|
||
|
||
|
||
def test_busy_blocks_remove_slots():
|
||
slots = compute_slots(
|
||
working_hours=WH, busy=[(dt(14, 9), dt(14, 12))], duration_min=60,
|
||
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
|
||
min_notice_hours=0,
|
||
)
|
||
assert slots[0].start == dt(14, 12)
|
||
|
||
|
||
def test_sunday_closed():
|
||
slots = compute_slots(
|
||
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 19),
|
||
date_to=date(2026, 7, 19), tz=TZ, now=NOW, min_notice_hours=0,
|
||
)
|
||
assert slots == []
|
||
|
||
|
||
def test_max_days_ahead_horizon():
|
||
slots = compute_slots(
|
||
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
|
||
date_to=date(2026, 8, 30), tz=TZ, now=NOW, min_notice_hours=0,
|
||
max_days_ahead=2, max_slots=1000,
|
||
)
|
||
assert max(s.start for s in slots).date() <= date(2026, 7, 15)
|
||
|
||
|
||
def test_buffer_extends_step():
|
||
slots = compute_slots(
|
||
working_hours=WH, busy=[], duration_min=45, buffer_min=15,
|
||
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
|
||
min_notice_hours=0,
|
||
)
|
||
# steps of 60 (45+15), but slot end = start + 45
|
||
assert slots[0].start == dt(14, 9)
|
||
assert slots[0].end == dt(14, 9, 45)
|
||
assert slots[1].start == dt(14, 10)
|