Files
app/backend/tests/flow/test_drafts.py
T
Melvin StroblandClaude Fable 5 606ab3c423 Separate editing from running with a draft/publish split
Edits autosave to flow.draft.json and nodes.draft/ instead of the files the
engine reads, so the pipeline keeps running the published version until
someone publishes. Every save carries the version it was based on: a second
client editing the same flow is refused with 409 and offered the choice
between their version and its own, rather than silently overwriting.

Draft saves no longer rebuild the pipeline; validation and node status for a
draft come from a throwaway build that never touches live state.

Also fixes a latent bug where an empty state backend is falsy, so Pipeline
quietly built itself a second, private state and left message history empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:13:15 +02:00

134 lines
4.3 KiB
Python

"""Editing writes drafts; only publishing changes what the engine reads."""
from pathlib import Path
import pytest
from app.flow.messages import MessageSpec
from app.flow.schemas import FlowDef, NodeDef
from app.flow.store import FlowStore, StaleVersion
SOURCE = "def process(params):\n return {}\n"
EDITED = "def process(params):\n return {'temp': 1}\n"
@pytest.fixture
def store(tmp_path: Path) -> FlowStore:
return FlowStore(tmp_path / "flows")
def a_flow(title: str = "") -> FlowDef:
return FlowDef(
name="heating",
title=title,
nodes=[NodeDef(id="sensor", provides=[MessageSpec(name="temp")])],
)
def test_a_draft_is_invisible_to_the_engine(store: FlowStore):
store.write_draft(a_flow(), 0)
assert store.has_draft("heating")
assert store.list_flows() == ["heating"] # the editor sees it
assert store.read_all() == [] # the engine does not
def test_the_version_counter_advances_per_save(store: FlowStore):
first = store.write_draft(a_flow(), 0)
assert first.version == 1
second = store.write_draft(a_flow(title="Warm"), first.version)
assert second.version == 2
def test_resaving_identical_content_neither_bumps_nor_commits(store: FlowStore):
stored = store.write_draft(a_flow(), 0)
again = store.write_draft(a_flow(), stored.version)
assert again.version == stored.version
def test_a_stale_write_is_refused(store: FlowStore):
store.write_draft(a_flow(), 0)
store.write_draft(a_flow(title="Mine"), 1)
# A second client still holding version 1 must not overwrite version 2.
with pytest.raises(StaleVersion) as excinfo:
store.write_draft(a_flow(title="Theirs"), 1)
assert excinfo.value.current == 2
def test_publishing_promotes_the_draft_and_its_sources(store: FlowStore):
stored = store.write_draft(a_flow(title="Warm"), 0)
store.write_node_source("heating", "sensor", EDITED, draft=True)
published = store.publish_flow("heating", stored.version)
assert published.title == "Warm"
assert not store.has_draft("heating")
assert store.read_flow("heating").title == "Warm"
assert store.read_node_source("heating", "sensor") == EDITED
assert [flow.name for flow in store.read_all()] == ["heating"]
def test_publishing_a_stale_draft_is_refused(store: FlowStore):
store.write_draft(a_flow(), 0)
with pytest.raises(StaleVersion):
store.publish_flow("heating", 99)
def test_discarding_goes_back_to_what_is_running(store: FlowStore):
store.write_flow(a_flow(title="Published"))
store.write_node_source("heating", "sensor", SOURCE)
store.write_draft(a_flow(title="Edited"), a_flow().version)
store.write_node_source("heating", "sensor", EDITED, draft=True)
restored = store.discard_draft("heating")
assert restored.title == "Published"
assert not store.has_draft("heating")
assert store.read_node_source("heating", "sensor", draft=True) == SOURCE
def test_an_edited_source_alone_counts_as_a_draft(store: FlowStore):
"""Editing only code still has to be published before the engine runs it."""
store.write_flow(a_flow())
store.write_node_source("heating", "sensor", SOURCE)
store.write_node_source("heating", "sensor", EDITED, draft=True)
assert store.has_draft("heating")
assert store.read_node_source("heating", "sensor") == SOURCE
assert store.read_node_source("heating", "sensor", draft=True) == EDITED
store.publish_flow("heating", store.read_flow("heating").version)
assert store.read_node_source("heating", "sensor") == EDITED
def test_resaving_the_published_source_creates_no_draft(store: FlowStore):
store.write_flow(a_flow())
store.write_node_source("heating", "sensor", SOURCE)
assert store.write_node_source("heating", "sensor", SOURCE, draft=True) is False
assert not store.has_draft("heating")
def test_renaming_carries_the_draft_and_repoints_other_drafts(store: FlowStore):
store.write_draft(a_flow(), 0)
store.write_draft(
FlowDef(
name="display",
nodes=[NodeDef(id="gauge", requires=[MessageSpec(name="heating.temp")])],
),
0,
)
renamed = store.rename_flow("heating", "warmth")
assert renamed.name == "warmth"
assert store.has_draft("warmth")
assert store.read_flow("display", draft=True).nodes[0].requires[0].name == (
"warmth.temp"
)