"""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