Files
openswarm/backend/tests/test_cloud_workflow_target.py
T

289 lines
12 KiB
Python

"""Handing a workflow's timer to the cloud, and taking it back.
The two things worth breaking a test over: the local timer and the cloud timer must never both be
live (that runs a workflow twice), and a cloud we could not reach must never render as a cloud that
said no (that is a paywall built out of a dropped packet).
"""
import pytest
from fastapi import HTTPException
from backend.apps.workflows import storage
from backend.apps.workflows.cloud import client as cloud
from backend.apps.workflows.cloud.routes import TargetRequest, set_workflow_target
from backend.apps.workflows.workflows import delete_workflow
from backend.apps.workflows.cloud.status import compute_status
from backend.apps.workflows.models import ScheduleConfig, Workflow, WorkflowStep
pytestmark = pytest.mark.usefixtures("isolated_workflows_data")
def p_sched(**overrides) -> ScheduleConfig:
base = dict(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="UTC")
base.update(overrides)
return ScheduleConfig(**base)
def p_wf(**overrides) -> Workflow:
base = dict(title="Morning digest", steps=[WorkflowStep(text="summarize the news")], schedule=p_sched())
base.update(overrides)
wf = Workflow(**base)
storage.save_workflow(wf)
return wf
def p_hosted(**overrides) -> dict:
row = {"id": "cloud-1", "enabled": True, "next_run_at": 1893499200000}
row.update(overrides)
return row
def p_preflight_body(**overrides) -> dict:
body = {
"plan": "pro",
"limits": {"workflows": 3, "runsPerMonth": 100, "concurrent": 1},
"usage": {"enabled": 1, "runs_this_month": 12},
"capability": {"ok": True, "reason": None},
"hosted": None,
}
body.update(overrides)
return body
def p_answer(monkeypatch, handler) -> list:
"""Replace the single network chokepoint. Every call is recorded so a test can assert we did
NOT talk to the cloud as well as what we said."""
seen: list = []
async def p_call(method: str, path: str, body=None):
seen.append((method, path, body))
return handler(method, path, body)
monkeypatch.setattr(cloud, "p_call", p_call)
return seen
@pytest.mark.asyncio
async def test_signed_out_is_a_known_answer_and_carries_no_numbers(monkeypatch):
wf = p_wf()
def handler(method, path, body):
raise cloud.SignedOut()
p_answer(monkeypatch, handler)
status = await compute_status(wf)
assert status.state == "signed_out"
assert not hasattr(status, "limits") and not hasattr(status, "usage")
@pytest.mark.asyncio
async def test_an_unreachable_cloud_is_unknown_not_denied(monkeypatch):
wf = p_wf()
def handler(method, path, body):
raise cloud.CloudUnreachable("ConnectError")
p_answer(monkeypatch, handler)
status = await compute_status(wf)
assert status.state == "unknown"
# The whole point: nothing here can be read as "you have no plan" or "0 runs left".
assert not hasattr(status, "limits")
assert status.schedule_supported is True
@pytest.mark.asyncio
async def test_a_ready_status_reports_the_plan_the_server_named(monkeypatch):
wf = p_wf()
p_answer(monkeypatch, lambda method, path, body: p_preflight_body())
status = await compute_status(wf)
assert status.state == "ready"
assert status.plan == "pro"
assert status.limits.workflows == 3
assert status.usage.runs_this_month == 12
assert status.hosted is None
@pytest.mark.asyncio
async def test_a_refused_flip_leaves_the_workflow_on_this_device(monkeypatch):
wf = p_wf()
def handler(method, path, body):
raise cloud.CloudRefused("Cloud workflows need a Pro plan or higher.", 402)
p_answer(monkeypatch, handler)
outcome = await set_workflow_target(wf.id, TargetRequest(target="cloud", enabled=True))
assert outcome.ok is False
assert outcome.message == "Cloud workflows need a Pro plan or higher."
assert storage.get_workflow(wf.id).execution_target == "device"
assert storage.get_workflow(wf.id).cloud_workflow_id is None
@pytest.mark.asyncio
async def test_an_unsupported_schedule_never_reaches_the_network(monkeypatch):
wf = p_wf(schedule=p_sched(repeat_unit="month", day_of_month=1))
seen = p_answer(monkeypatch, lambda method, path, body: p_preflight_body())
outcome = await set_workflow_target(wf.id, TargetRequest(target="cloud", enabled=True))
assert outcome.ok is False
assert seen == []
assert storage.get_workflow(wf.id).execution_target == "device"
@pytest.mark.asyncio
async def test_weekdays_go_up_with_the_days_the_user_picked(monkeypatch):
""""Every weekday at 9am" is the schedule people actually write, and it used to be refused."""
wf = p_wf(schedule=p_sched(repeat_unit="week", on_days=[5, 1, 2, 3, 4], timezone="America/Los_Angeles"))
seen = p_answer(monkeypatch, lambda method, path, body: p_hosted())
outcome = await set_workflow_target(wf.id, TargetRequest(target="cloud", enabled=True))
assert outcome.ok is True
sent = seen[-1][2]["schedule"]
assert sent == {"kind": "weekly", "days": [1, 2, 3, 4, 5], "hour": 9, "minute": 0,
"timezone": "America/Los_Angeles"}
assert storage.get_workflow(wf.id).execution_target == "cloud"
@pytest.mark.asyncio
async def test_a_capped_schedule_hands_over_its_cap_and_what_it_has_already_spent(monkeypatch):
wf = p_wf(schedule=p_sched(max_runs=5, runs_count=2))
seen = p_answer(monkeypatch, lambda method, path, body: p_hosted())
assert (await set_workflow_target(wf.id, TargetRequest(target="cloud", enabled=True))).ok is True
body = seen[-1][2]
assert body["schedule"]["max_runs"] == 5
# Without this the cloud would give a schedule with 3 runs left a fresh 5.
assert body["runs_before"] == 2
@pytest.mark.asyncio
async def test_runs_the_cloud_performed_come_back_onto_our_own_counter(monkeypatch):
wf = p_wf(schedule=p_sched(max_runs=5, runs_count=2), execution_target="cloud",
cloud_workflow_id="cloud-1")
storage.save_workflow(wf)
p_answer(monkeypatch, lambda method, path, body: p_preflight_body(hosted=p_hosted(runs_done=4)))
await compute_status(wf)
assert storage.get_workflow(wf.id).schedule.runs_count == 4
@pytest.mark.asyncio
async def test_an_accepted_flip_records_which_copy_is_up_there(monkeypatch):
wf = p_wf()
p_answer(monkeypatch, lambda method, path, body: p_hosted())
outcome = await set_workflow_target(wf.id, TargetRequest(target="cloud", enabled=True))
assert outcome.ok is True
saved = storage.get_workflow(wf.id)
assert saved.execution_target == "cloud"
assert saved.cloud_workflow_id == "cloud-1"
assert saved.cloud_definition_signature is not None
# The cloud owns the clock now, so the app shows the cloud's next fire and not our frozen one.
assert saved.next_run_at is not None
assert saved.next_run_at.timestamp() * 1000 == p_hosted()["next_run_at"]
@pytest.mark.asyncio
async def test_an_unreachable_cloud_cannot_take_the_timer_back(monkeypatch):
wf = p_wf(execution_target="cloud", cloud_workflow_id="cloud-1")
storage.save_workflow(wf)
def handler(method, path, body):
raise cloud.CloudUnreachable("ReadTimeout")
p_answer(monkeypatch, handler)
outcome = await set_workflow_target(wf.id, TargetRequest(target="device", enabled=True))
assert outcome.ok is False
# Flipping the local timer on while the cloud still holds one is how a workflow runs twice.
assert storage.get_workflow(wf.id).execution_target == "cloud"
assert storage.get_workflow(wf.id).cloud_workflow_id == "cloud-1"
@pytest.mark.asyncio
async def test_taking_the_timer_back_clears_every_trace_of_the_cloud_copy(monkeypatch):
wf = p_wf(execution_target="cloud", cloud_workflow_id="cloud-1", cloud_definition_signature="abc")
storage.save_workflow(wf)
seen = p_answer(monkeypatch, lambda method, path, body: {"ok": True})
outcome = await set_workflow_target(wf.id, TargetRequest(target="device", enabled=True))
assert outcome.ok is True
assert seen == [("POST", "/cloud-1/delete", {})]
saved = storage.get_workflow(wf.id)
assert saved.execution_target == "device"
assert saved.cloud_workflow_id is None
assert saved.cloud_definition_signature is None
assert saved.next_run_at is not None, "the local timer has to be armed again on the way back"
@pytest.mark.asyncio
async def test_a_hosted_copy_that_vanished_shows_as_hosted_nothing(monkeypatch):
wf = p_wf(execution_target="cloud", cloud_workflow_id="cloud-gone")
storage.save_workflow(wf)
p_answer(monkeypatch, lambda method, path, body: p_preflight_body(hosted=None))
status = await compute_status(wf)
assert status.state == "ready"
assert status.target == "cloud" and status.hosted is None
assert storage.get_workflow(wf.id).next_run_at is None, "nothing is going to run it, so do not promise a time"
@pytest.mark.asyncio
async def test_an_edited_workflow_reads_as_out_of_sync(monkeypatch):
wf = p_wf()
p_answer(monkeypatch, lambda method, path, body: p_hosted())
await set_workflow_target(wf.id, TargetRequest(target="cloud", enabled=True))
p_answer(monkeypatch, lambda method, path, body: p_preflight_body(hosted=p_hosted()))
assert (await compute_status(storage.get_workflow(wf.id))).hosted.in_sync is True
edited = storage.get_workflow(wf.id)
edited.steps = [WorkflowStep(text="summarize the sports news instead")]
storage.save_workflow(edited)
assert (await compute_status(edited)).hosted.in_sync is False
@pytest.mark.asyncio
async def test_an_old_control_plane_leaves_capability_unknown_rather_than_ok(monkeypatch):
wf = p_wf()
def handler(method, path, body):
if path == "/preflight":
raise cloud.CloudRefused("Not Found", 404)
return {
"workflows": [],
"limits": {"workflows": 3, "runsPerMonth": 100, "concurrent": 1},
"usage": {"enabled": 0, "runs_this_month": 0},
}
seen = p_answer(monkeypatch, handler)
status = await compute_status(wf)
assert status.state == "ready"
assert status.capability is None
assert status.limits.workflows == 3
# The cloud router is mounted AT /api/workflows and a trailing slash 404s there, which would turn this fallback into a second refusal.
assert seen[-1][1] == "", "the collection path must not carry a trailing slash"
@pytest.mark.asyncio
async def test_trashing_a_cloud_workflow_stops_the_cloud_copy(monkeypatch):
wf = p_wf()
p_answer(monkeypatch, lambda method, path, body: p_hosted())
await set_workflow_target(wf.id, TargetRequest(target="cloud", enabled=True))
seen = p_answer(monkeypatch, lambda method, path, body: {"ok": True})
await delete_workflow(storage.get_workflow(wf.id).id)
assert seen == [("POST", "/cloud-1/delete", {})]
trashed = storage.get_workflow(wf.id)
assert trashed.deleted_at is not None
assert trashed.cloud_workflow_id is None
@pytest.mark.asyncio
async def test_a_workflow_the_cloud_still_holds_cannot_be_trashed_into_a_ghost(monkeypatch):
wf = p_wf()
p_answer(monkeypatch, lambda method, path, body: p_hosted())
await set_workflow_target(wf.id, TargetRequest(target="cloud", enabled=True))
def handler(method, path, body):
raise cloud.CloudUnreachable("ConnectError")
p_answer(monkeypatch, handler)
with pytest.raises(HTTPException) as caught:
await delete_workflow(wf.id)
assert caught.value.status_code == 409
# Deleting it locally would leave a hosted copy running on its own schedule, billing a user who cannot see it.
assert storage.get_workflow(wf.id).deleted_at is None