"""Occurrence identity and UTC persistence helpers (T02).""" from __future__ import annotations from datetime import datetime, timezone import pytest from wf_scheduling.calendar import CronSource from wf_scheduling.occurrences import ( Occurrence, is_already_consumed, monotonic_ns, occurrence_id, scheduled_at_str, wall_now_utc, ) def utc(*args: int) -> datetime: return datetime(*args, tzinfo=timezone.utc) def test_occurrence_id_is_deterministic_utc_pair() -> None: at = utc(2026, 9, 8, 13, 0, 0) assert occurrence_id("sched-1", at) == occurrence_id("sched-1", at) assert occurrence_id("sched-1", at) != occurrence_id("sched-2", at) assert occurrence_id("sched-1", at) != occurrence_id( "sched-1", utc(2026, 9, 8, 14, 0, 0) ) assert "sched-1" in occurrence_id("sched-1", at) def test_occurrence_id_rejects_naive() -> None: with pytest.raises(ValueError, match="aware"): occurrence_id("s", datetime(2026, 9, 8, 12, 0)) def test_scheduled_at_serializes_as_utc_rfc3339() -> None: from datetime import timedelta at = datetime(2026, 9, 8, 12, 0, tzinfo=timezone(timedelta(hours=2))) assert scheduled_at_str(at) == "2026-09-08T10:00:00+00:00" with pytest.raises(ValueError, match="aware"): scheduled_at_str(datetime(2026, 9, 8, 12, 0)) def test_occurrence_model_exposes_contract_fields() -> None: occ = Occurrence(schedule_id="s", scheduled_at=utc(2026, 9, 8, 13, 0, 0)) assert occ.schedule_id == "s" assert occ.occurrence_id == occurrence_id("s", utc(2026, 9, 8, 13, 0, 0)) assert occ.scheduled_at == utc(2026, 9, 8, 13, 0, 0) payload = occ.to_binding_dict() assert payload["schedule_id"] == "s" assert payload["scheduled_at"] == "2026-09-08T13:00:00+00:00" def test_calendar_identities_are_unique_and_increasing() -> None: src = CronSource("30 9 * * *", "UTC") cursor = utc(2026, 9, 1, 0, 0, 0) seen: set[str] = set() prev: datetime | None = None for _ in range(10): nxt = src.next_after(cursor) assert nxt is not None ident = occurrence_id("s", nxt) assert ident not in seen seen.add(ident) if prev is not None: assert nxt > prev prev = nxt cursor = nxt def test_rollback_guard_never_readmits_consumed() -> None: consumed = utc(2026, 9, 8, 12, 20, 0) assert is_already_consumed(utc(2026, 9, 8, 12, 0, 0), consumed) assert is_already_consumed(consumed, consumed) assert not is_already_consumed(utc(2026, 9, 8, 12, 30, 0), consumed) def test_wall_and_monotonic_clocks_split() -> None: wall = wall_now_utc() assert wall.tzinfo is not None assert wall.utcoffset() is not None first = monotonic_ns() second = monotonic_ns() assert second >= first