"""T13 schedule client tests (codec + snapshot + facade accessors).""" from __future__ import annotations from typing import Any, cast import pytest from wf_client import App, Schedule from wf_client.codec import ( decode_occurrence_page, decode_schedule_list, decode_schedule_result, ) from wf_client.errors import InvalidResponse from wf_client.protocols import WorkflowClientPort from .conftest import FakeWorkflowClient def _schedule_payload(**overrides: Any) -> dict[str, Any]: payload: dict[str, Any] = { "id": "s", "deployment_id": "dep.personal", "trigger": {"kind": "cron", "expression": "* * * * *", "timezone": "UTC"}, "input_bindings": [{"target": "msg", "value": "hi"}], "max_steps": None, "overlap": "skip", "misfire": "skip", "max_active_runs": 1, "lateness_allowance_s": 60.0, "revision": 1, "enabled": True, "paused": False, "deleted": False, "exhausted": False, "blocked_reason": None, "created_at": "2026-09-08T12:00:00+00:00", "updated_at": "2026-09-08T12:00:00+00:00", } payload.update(overrides) return payload def _occurrence_payload(**overrides: Any) -> dict[str, Any]: payload: dict[str, Any] = { "occurrences": [ { "schedule_id": "s", "occurrence_id": "s|2026-09-08T12:00:00+00:00", "kind": "admitted", "resolved_at": "2026-09-08T12:00:00+00:00", "run_id": "run-0", "revision": 1, "reason": "rev=1", "admitted_at": "2026-09-08T12:00:00+00:00", "started_at": None, "checkpoint_id": None, "interval_start": None, "interval_end": None, "interval_count": 0, "created_at": "2026-09-08T12:00:00+00:00", } ], "total": 1, "cursor": None, "next_cursor": None, "limit": 50, } payload.update(overrides) return payload class _Port: def __init__(self, payload: dict[str, Any] | None = None) -> None: self.calls: list[tuple[str, dict[str, Any]]] = [] self.payload = payload or _schedule_payload(revision=2) async def get_schedule(self, **params: Any) -> object: self.calls.append(("get_schedule", params)) return self.payload def test_decode_schedule_result_returns_wire_projection() -> None: result = decode_schedule_result(_schedule_payload()) assert result["id"] == "s" assert result["deployment_id"] == "dep.personal" assert result["revision"] == 1 assert result["enabled"] is True def test_decode_schedule_result_rejects_malformed_payload() -> None: with pytest.raises(InvalidResponse, match="workflow.schedules.get"): decode_schedule_result({"id": "s"}) def test_decode_schedule_list_and_occurrence_page() -> None: listed = decode_schedule_list({"schedules": [_schedule_payload()]}) page = decode_occurrence_page(_occurrence_payload()) assert listed["schedules"][0]["id"] == "s" assert page["total"] == 1 assert page["occurrences"][0]["run_id"] == "run-0" with pytest.raises(InvalidResponse, match="workflow.schedules.list"): decode_schedule_list({"schedules": [{"id": "broken"}]}) with pytest.raises(InvalidResponse, match="workflow.schedules.occurrences.list"): decode_occurrence_page({"total": "many"}) @pytest.mark.asyncio async def test_schedule_snapshot_exposes_minimal_surface() -> None: schedule = Schedule.from_payload( cast(WorkflowClientPort, _Port()), _schedule_payload() ) assert schedule.schedule_id == "s" assert schedule.id == "s" assert schedule.deployment_id == "dep.personal" assert schedule.trigger == { "kind": "cron", "expression": "* * * * *", "timezone": "UTC", } assert schedule.input_bindings == [{"target": "msg", "value": "hi"}] assert schedule.revision == 1 assert schedule.enabled is True assert schedule.paused is False assert schedule.deleted is False assert schedule.overlap == "skip" assert schedule.misfire == "skip" assert schedule.max_active_runs == 1 assert schedule.lateness_allowance_s == 60.0 assert schedule.max_steps is None @pytest.mark.asyncio async def test_schedule_snapshot_defensively_copies_json_data() -> None: schedule = Schedule.from_payload( cast(WorkflowClientPort, _Port()), _schedule_payload() ) exposed_trigger = schedule.trigger exposed_bindings = schedule.input_bindings assert isinstance(exposed_trigger, dict) exposed_trigger["expression"] = "mutated" exposed_bindings[0]["value"] = "mutated" exposed_bindings.append({"target": "extra", "value": 1}) assert schedule.trigger == { "kind": "cron", "expression": "* * * * *", "timezone": "UTC", } assert schedule.input_bindings == [{"target": "msg", "value": "hi"}] @pytest.mark.asyncio async def test_schedule_rejects_mismatched_identity() -> None: with pytest.raises(InvalidResponse, match="workflow.schedules.get"): Schedule.from_payload( cast(WorkflowClientPort, _Port()), _schedule_payload(id="other"), expected_schedule_id="s", ) @pytest.mark.asyncio async def test_refresh_returns_a_new_snapshot() -> None: port = _Port() original = Schedule.from_payload( cast(WorkflowClientPort, port), _schedule_payload() ) refreshed = await original.refresh() assert refreshed is not original assert refreshed.revision == 2 assert original.revision == 1 assert port.calls == [("get_schedule", {"schedule_id": "s"})] @pytest.mark.asyncio async def test_app_schedule_accessors_round_trip() -> None: payload = _schedule_payload() fake = FakeWorkflowClient( **{ "workflow.schedules.create": payload, "workflow.schedules.get": payload, "workflow.schedules.list": {"schedules": [payload]}, "workflow.schedules.update": _schedule_payload(revision=2), "workflow.schedules.pause": _schedule_payload(paused=True), "workflow.schedules.resume": _schedule_payload(paused=False), "workflow.schedules.delete": _schedule_payload(deleted=True), "workflow.schedules.occurrences.list": _occurrence_payload(), } ) app = App._from_port(cast(WorkflowClientPort, fake)) created = await app.create_schedule( schedule_id="s", deployment_id="dep.personal", trigger={ "kind": "cron", "expression": "* * * * *", "timezone": "UTC", }, ) fetched = await app.schedule("s") listed = await app.schedules() updated = await app.update_schedule( schedule_id="s", expected_revision=1, max_active_runs=3 ) paused = await app.pause_schedule("s") resumed = await app.resume_schedule("s") deleted = await app.delete_schedule("s") page = await app.schedule_occurrences("s", limit=10) assert created.schedule_id == "s" assert created.revision == 1 assert fetched.revision == 1 assert [item.schedule_id for item in listed] == ["s"] assert updated.revision == 2 assert paused.paused is True assert resumed.paused is False assert deleted.deleted is True assert page["total"] == 1 assert page["occurrences"][0]["run_id"] == "run-0" assert fake.calls[0] == ( "workflow.schedules.create", { "schedule_id": "s", "deployment_id": "dep.personal", "trigger": { "kind": "cron", "expression": "* * * * *", "timezone": "UTC", }, "input_bindings": None, "overlap": "skip", "misfire": "skip", "max_active_runs": 1, "lateness_allowance_s": 60.0, "max_steps": None, "enabled": True, }, ) assert ( "workflow.schedules.occurrences.list", {"schedule_id": "s", "cursor": None, "limit": 10}, ) in fake.calls @pytest.mark.asyncio async def test_app_schedule_rejects_mismatched_identity() -> None: fake = FakeWorkflowClient( **{"workflow.schedules.get": _schedule_payload(id="other")} ) app = App._from_port(cast(WorkflowClientPort, fake)) with pytest.raises(InvalidResponse, match="workflow.schedules.get"): await app.schedule("s")