sched: schedule RPC methods, transport/client decoding, round trips (T13)
This commit is contained in:
@@ -70,6 +70,30 @@ class FakeWorkflowClient:
|
||||
async def read_run_trace(self, **params: Any) -> object:
|
||||
return self._response("workflow.runs.trace", params)
|
||||
|
||||
async def create_schedule(self, **params: Any) -> object:
|
||||
return self._response("workflow.schedules.create", params)
|
||||
|
||||
async def get_schedule(self, **params: Any) -> object:
|
||||
return self._response("workflow.schedules.get", params)
|
||||
|
||||
async def list_schedules(self, **params: Any) -> object:
|
||||
return self._response("workflow.schedules.list", params)
|
||||
|
||||
async def update_schedule(self, **params: Any) -> object:
|
||||
return self._response("workflow.schedules.update", params)
|
||||
|
||||
async def pause_schedule(self, **params: Any) -> object:
|
||||
return self._response("workflow.schedules.pause", params)
|
||||
|
||||
async def resume_schedule(self, **params: Any) -> object:
|
||||
return self._response("workflow.schedules.resume", params)
|
||||
|
||||
async def delete_schedule(self, **params: Any) -> object:
|
||||
return self._response("workflow.schedules.delete", params)
|
||||
|
||||
async def list_schedule_occurrences(self, **params: Any) -> object:
|
||||
return self._response("workflow.schedules.occurrences.list", params)
|
||||
|
||||
async def _call(self, method: str, params: dict[str, Any]) -> object:
|
||||
return self._response(method, params)
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user