"""Unit tests for maintenance windows. Pure domain: no DB, no clock, no mocks. `now` is a parameter everywhere in `windows.py`, which is why none of these tests monkeypatch `datetime.now` — there is nothing to patch. That is the point of the design. """ from datetime import UTC, datetime, timedelta from zoneinfo import ZoneInfo import pytest from svcforge_core.domain.windows import ( BadWindow, MaintenanceWindow, next_window_open, parse_window, schedule_upgrade_at, ) HCM = MaintenanceWindow("0 3 * * 0", "Asia/Ho_Chi_Minh") # 03:00 every Sunday, Vietnam time def test_next_window_open_with_naive_now_raises_value_error() -> None: """The one bug this module exists to prevent, caught at the boundary. A naive datetime does not raise when you build it; it raises when you compare it, which is inside a worker at 03:00. mypy sees `datetime` either way. """ with pytest.raises(ValueError, match="aware"): next_window_open(HCM, datetime(2026, 7, 18, 20, 0)) # naive on purpose def test_next_window_open_returns_the_hcm_sunday_expressed_in_utc() -> None: """Sunday 03:00 in Ho Chi Minh (UTC+7, no DST) is Saturday 20:00 UTC. `now` here IS that instant, and the answer is that instant: the window is open right now, so the upgrade runs now. Strictly-greater semantics would push it a full week. """ now = datetime(2026, 7, 18, 20, 0, tzinfo=UTC) assert now.weekday() == 5 # a Saturday opens = next_window_open(HCM, now) assert opens.tzinfo is UTC assert opens == datetime(2026, 7, 18, 20, 0, tzinfo=UTC) assert opens.astimezone(ZoneInfo("Asia/Ho_Chi_Minh")) == datetime( 2026, 7, 19, 3, 0, tzinfo=ZoneInfo("Asia/Ho_Chi_Minh") ) def test_next_window_open_rolls_to_next_week_once_the_window_has_passed() -> None: """A second past the open and you wait for the next one. Guards the -1s inclusivity trick.""" opens = next_window_open(HCM, datetime(2026, 7, 18, 20, 0, 1, tzinfo=UTC)) assert opens == datetime(2026, 7, 25, 20, 0, tzinfo=UTC) assert opens.tzinfo is UTC def test_schedule_upgrade_at_with_security_returns_now_exactly() -> None: """A CVE with a public exploit does not wait until Sunday.""" now = datetime(2026, 7, 18, 20, 0, tzinfo=UTC) assert schedule_upgrade_at(HCM, security=True, now=now) == now def test_schedule_upgrade_at_without_window_returns_now() -> None: """maintenance_window is null -> upgrade any time.""" now = datetime(2026, 7, 15, 9, 30, tzinfo=UTC) assert schedule_upgrade_at(None, security=False, now=now) == now assert next_window_open(None, now).tzinfo is UTC def test_window_across_spring_forward_returns_one_aware_instant() -> None: """DST spring-forward, asserting croniter's REAL behaviour rather than trusting docs. On 2026-03-08 America/New_York jumps 02:00 EST -> 03:00 EDT, so a `30 2 * * *` window has no 02:30 that day. Observed: croniter does not skip the day and does not raise — it CLAMPS to the transition instant, yielding 03:00:00-04:00 (not 03:30). The window opens half an hour "late" in local terms, exactly once, and the following days resume at 02:30 EDT. One instant, aware, and the caller never sees a nonexistent local time. """ window = MaintenanceWindow("30 2 * * *", "America/New_York") now = datetime(2026, 3, 7, 17, 0, tzinfo=UTC) # Sat midday in New York, before the jump opens = next_window_open(window, now) assert opens.tzinfo is UTC assert opens == datetime(2026, 3, 8, 7, 0, tzinfo=UTC) # == 03:00 EDT, the clamp local = opens.astimezone(ZoneInfo("America/New_York")) assert (local.hour, local.minute) == (3, 0) assert local.utcoffset() == timedelta(hours=-4) # EDT: the jump has happened # The day after, the window is back where the tenant expects it. after = next_window_open(window, opens + timedelta(seconds=1)) assert after == datetime(2026, 3, 9, 6, 30, tzinfo=UTC) # 02:30 EDT def test_window_across_fall_back_returns_the_first_of_the_two_local_times() -> None: """Fall-back makes 01:30 happen twice. Observed: croniter yields BOTH, EDT then EST. next_window_open returns the earlier one (fold=0, -04:00). Not a bug to fix here: a window that opens twice on one night is what the tenant's cron literally asked for. """ window = MaintenanceWindow("30 1 * * *", "America/New_York") now = datetime(2026, 10, 31, 16, 0, tzinfo=UTC) first = next_window_open(window, now) second = next_window_open(window, first + timedelta(seconds=1)) assert first == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # 01:30 EDT assert second == datetime(2026, 11, 1, 6, 30, tzinfo=UTC) # 01:30 EST, one hour later assert first.tzinfo is UTC and second.tzinfo is UTC def test_parse_window_bad_cron_raises_bad_window() -> None: with pytest.raises(BadWindow, match="cron"): parse_window("not a cron|Asia/Ho_Chi_Minh") def test_parse_window_roundtrips_a_valid_spec() -> None: assert parse_window("0 3 * * 0|Asia/Ho_Chi_Minh") == HCM def test_parse_window_none_and_blank_mean_any_time() -> None: assert parse_window(None) is None assert parse_window(" ") is None def test_parse_window_unknown_zone_raises_bad_window() -> None: with pytest.raises(BadWindow, match="IANA"): parse_window("0 3 * * 0|Mars/Olympus_Mons") def test_parse_window_without_separator_raises_bad_window() -> None: with pytest.raises(BadWindow, match="CRON"): parse_window("0 3 * * 0") def test_parse_window_six_field_cron_raises_bad_window() -> None: """croniter's is_valid() accepts a 6-field (seconds) form; the column is 5-field.""" with pytest.raises(BadWindow, match="exactly 5 fields"): parse_window("0 0 3 * * 0|Asia/Ho_Chi_Minh")