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 == []