"""T25: Schedule pause/resume lifecycle tests. Tests schedule_manager.py against a local embedded Temporal server (temporalio[testing] — WorkflowEnvironment.start_local()). Requires no Docker; the Temporal testing library bundles a self-contained server. Run with: uv run pytest tests/test_schedule_lifecycle.py -v """ from __future__ import annotations import asyncio import uuid import pytest from temporalio.client import ScheduleOverlapPolicy from temporalio.testing import WorkflowEnvironment from activity_core.models import ActivityDefinition, CronTriggerConfig from activity_core.schedule_manager import ( delete_schedule, list_schedules, schedule_id, upsert_schedule, ) def _make_defn( *, cron: str = "0 9 * * 1-5", misfire_policy: str = "skip", enabled: bool = True, jitter: int = 0, ) -> ActivityDefinition: return ActivityDefinition( id=uuid.uuid4(), name="test-activity", enabled=enabled, trigger_config=CronTriggerConfig( cron_expression=cron, misfire_policy=misfire_policy, jitter_seconds=jitter, ), ) @pytest.fixture(scope="module") async def env(): """Start a local embedded Temporal server for the test module.""" async with await WorkflowEnvironment.start_local() as e: yield e # ── T25a: upsert creates a schedule and list_schedules finds it ────────────── @pytest.mark.asyncio async def test_upsert_schedule_creates_schedule(env: WorkflowEnvironment) -> None: defn = _make_defn() sid = schedule_id(defn.id) await upsert_schedule(env.client, defn) # The embedded test server's visibility index is eventually consistent — # wait briefly for the new schedule to appear in the listing. ids: list[str] = [] for _ in range(10): schedules = await list_schedules(env.client) ids = [s["schedule_id"] for s in schedules] if sid in ids: break await asyncio.sleep(0.3) assert sid in ids, f"Expected schedule {sid!r} in {ids}" # Cleanup await delete_schedule(env.client, defn.id) # ── T25b: upsert with enabled=False creates a paused schedule ──────────────── @pytest.mark.asyncio async def test_upsert_disabled_creates_paused_schedule(env: WorkflowEnvironment) -> None: defn = _make_defn(enabled=False) await upsert_schedule(env.client, defn) handle = env.client.get_schedule_handle(schedule_id(defn.id)) desc = await handle.describe() assert desc.schedule.state.paused, "Schedule should be paused when enabled=False" await delete_schedule(env.client, defn.id) # ── T25c: second upsert (enabled=True) unpauses the schedule ──────────────── @pytest.mark.asyncio async def test_upsert_reenables_paused_schedule(env: WorkflowEnvironment) -> None: defn_disabled = _make_defn(enabled=False) await upsert_schedule(env.client, defn_disabled) # Re-enable the same activity defn_enabled = ActivityDefinition( id=defn_disabled.id, name=defn_disabled.name, enabled=True, trigger_config=defn_disabled.trigger_config, ) await upsert_schedule(env.client, defn_enabled) handle = env.client.get_schedule_handle(schedule_id(defn_enabled.id)) desc = await handle.describe() assert not desc.schedule.state.paused, "Schedule should be unpaused after re-enable" await delete_schedule(env.client, defn_enabled.id) # ── T25d: delete_schedule removes the schedule ─────────────────────────────── @pytest.mark.asyncio async def test_delete_schedule_removes_schedule(env: WorkflowEnvironment) -> None: defn = _make_defn() await upsert_schedule(env.client, defn) await delete_schedule(env.client, defn.id) sid = schedule_id(defn.id) ids: list[str] = [] for _ in range(10): schedules = await list_schedules(env.client) ids = [s["schedule_id"] for s in schedules] if sid not in ids: break await asyncio.sleep(0.3) assert sid not in ids, "Schedule should be gone after delete" # ── T25e: delete_schedule is idempotent (no-op for non-existent schedule) ──── @pytest.mark.asyncio async def test_delete_schedule_nonexistent_is_noop(env: WorkflowEnvironment) -> None: # Should not raise await delete_schedule(env.client, uuid.uuid4()) # ── T24: misfire_policy round-trip ─────────────────────────────────────────── @pytest.mark.asyncio async def test_misfire_policy_skip_sets_overlap_skip(env: WorkflowEnvironment) -> None: defn = _make_defn(misfire_policy="skip") await upsert_schedule(env.client, defn) handle = env.client.get_schedule_handle(schedule_id(defn.id)) desc = await handle.describe() assert desc.schedule.policy.overlap == ScheduleOverlapPolicy.SKIP await delete_schedule(env.client, defn.id) @pytest.mark.asyncio async def test_misfire_policy_catchup_sets_overlap_buffer_all(env: WorkflowEnvironment) -> None: defn = _make_defn(misfire_policy="catchup") await upsert_schedule(env.client, defn) handle = env.client.get_schedule_handle(schedule_id(defn.id)) desc = await handle.describe() assert desc.schedule.policy.overlap == ScheduleOverlapPolicy.BUFFER_ALL await delete_schedule(env.client, defn.id) @pytest.mark.asyncio async def test_misfire_policy_compress_sets_overlap_buffer_one(env: WorkflowEnvironment) -> None: defn = _make_defn(misfire_policy="compress") await upsert_schedule(env.client, defn) handle = env.client.get_schedule_handle(schedule_id(defn.id)) desc = await handle.describe() assert desc.schedule.policy.overlap == ScheduleOverlapPolicy.BUFFER_ONE await delete_schedule(env.client, defn.id)