Flows and dashboards get ids: the store is addressed by id with a key index, the first start gives every document its key as id, fluksio store migrate --dry-run and ls; the rename routes are gone
This commit is contained in:
@@ -86,3 +86,29 @@ def test_a_save_based_on_a_version_someone_moved_past_is_refused(
|
||||
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"]["current_version"] == 1
|
||||
|
||||
|
||||
def test_a_dashboard_is_known_by_an_id_no_save_changes(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
saved = client.put(
|
||||
f"{PREFIX}/put_identified",
|
||||
headers=superuser_token_headers,
|
||||
json=a_dashboard("put_identified"),
|
||||
).json()
|
||||
assert saved["id"] == "put_identified"
|
||||
|
||||
elsewhere = client.put(
|
||||
f"{PREFIX}/put_identified",
|
||||
headers=superuser_token_headers,
|
||||
json={**saved, "id": "elsewhere"},
|
||||
)
|
||||
assert elsewhere.status_code == 422
|
||||
renamed = client.post(
|
||||
f"{PREFIX}/put_identified/rename",
|
||||
headers=superuser_token_headers,
|
||||
json={"name": "renamed"},
|
||||
)
|
||||
assert renamed.status_code in (404, 405)
|
||||
|
||||
client.delete(f"{PREFIX}/put_identified", headers=superuser_token_headers)
|
||||
|
||||
@@ -77,6 +77,45 @@ def test_name_mismatch_is_rejected(
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_a_flow_is_known_by_an_id_no_save_changes(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""For now the id of a new flow is its key, and the URL names both."""
|
||||
saved = client.put(
|
||||
f"{PREFIX}/identified",
|
||||
headers=superuser_token_headers,
|
||||
json=a_flow("identified"),
|
||||
).json()["definition"]
|
||||
assert saved["id"] == "identified"
|
||||
listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()["data"]
|
||||
assert {"id": "identified", "name": "identified"}.items() <= next(
|
||||
f for f in listed if f["name"] == "identified"
|
||||
).items()
|
||||
|
||||
elsewhere = client.put(
|
||||
f"{PREFIX}/identified",
|
||||
headers=superuser_token_headers,
|
||||
json={**saved, "id": "elsewhere"},
|
||||
)
|
||||
assert elsewhere.status_code == 400
|
||||
# A key changes by a rename, and that route is not there yet.
|
||||
renamed = client.post(
|
||||
f"{PREFIX}/identified/rename",
|
||||
headers=superuser_token_headers,
|
||||
json={"new_name": "renamed"},
|
||||
)
|
||||
assert renamed.status_code in (404, 405)
|
||||
# Shaped like an id the server issues, which a new key may not be.
|
||||
issued = client.put(
|
||||
f"{PREFIX}/f_k3q7m2xa",
|
||||
headers=superuser_token_headers,
|
||||
json=a_flow("f_k3q7m2xa"),
|
||||
)
|
||||
assert issued.status_code == 400
|
||||
|
||||
client.delete(f"{PREFIX}/identified", headers=superuser_token_headers)
|
||||
|
||||
|
||||
def test_broken_node_is_reported_and_siblings_stay_active(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
@@ -347,68 +386,6 @@ def test_node_types_are_listed(
|
||||
assert "properties" in by_type["mqtt"]["params_schema"]
|
||||
|
||||
|
||||
def test_rename_flow(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
||||
|
||||
response = client.post(
|
||||
f"{PREFIX}/demo/rename",
|
||||
headers=superuser_token_headers,
|
||||
json={"new_name": "demo_renamed"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["definition"]["name"] == "demo_renamed"
|
||||
|
||||
assert (
|
||||
client.get(f"{PREFIX}/demo", headers=superuser_token_headers).status_code == 404
|
||||
)
|
||||
assert (
|
||||
client.get(
|
||||
f"{PREFIX}/demo_renamed", headers=superuser_token_headers
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
# Put it back so the tests that follow find the flow they expect.
|
||||
client.post(
|
||||
f"{PREFIX}/demo_renamed/rename",
|
||||
headers=superuser_token_headers,
|
||||
json={"new_name": "demo"},
|
||||
)
|
||||
|
||||
|
||||
def test_rename_onto_a_taken_name_is_refused(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
||||
client.put(
|
||||
f"{PREFIX}/occupied", headers=superuser_token_headers, json=a_flow("occupied")
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"{PREFIX}/demo/rename",
|
||||
headers=superuser_token_headers,
|
||||
json={"new_name": "occupied"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
client.delete(f"{PREFIX}/occupied", headers=superuser_token_headers)
|
||||
|
||||
|
||||
def test_rename_rejects_an_invalid_name(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
||||
|
||||
response = client.post(
|
||||
f"{PREFIX}/demo/rename",
|
||||
headers=superuser_token_headers,
|
||||
json={"new_name": "Not A Flow Name"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_a_node_that_raises_answers_with_its_error(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
@@ -467,42 +444,6 @@ def test_acknowledging_a_failure_is_announced_on_the_bus(
|
||||
client.delete(f"{PREFIX}/acked", headers=superuser_token_headers)
|
||||
|
||||
|
||||
def test_renaming_a_flow_leaves_nothing_under_the_old_name(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
saved = client.put(
|
||||
f"{PREFIX}/movable", headers=superuser_token_headers, json=a_flow("movable")
|
||||
).json()
|
||||
client.put(
|
||||
f"{PREFIX}/movable/nodes/sensor/source",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": WORKING_NODE},
|
||||
)
|
||||
client.post(
|
||||
f"{PREFIX}/movable/publish",
|
||||
headers=superuser_token_headers,
|
||||
json={"version": saved["definition"]["version"]},
|
||||
)
|
||||
client.post(
|
||||
f"{PREFIX}/movable/run", headers=superuser_token_headers, json={"inputs": {}}
|
||||
)
|
||||
|
||||
state = client.app.state.flow_controller.state
|
||||
assert [key for key in state.keys() if "movable." in key]
|
||||
|
||||
client.post(
|
||||
f"{PREFIX}/movable/rename",
|
||||
headers=superuser_token_headers,
|
||||
json={"new_name": "moved"},
|
||||
)
|
||||
|
||||
# The values repopulate under the new name on the next run; what the old
|
||||
# name left behind would sit there for good.
|
||||
assert [key for key in state.keys() if "movable." in key] == []
|
||||
|
||||
client.delete(f"{PREFIX}/moved", headers=superuser_token_headers)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_the_live_socket_ends_when_its_client_goes(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
@@ -58,7 +58,6 @@ def test_the_stores_refuse_them_themselves(tmp_path: Path) -> None:
|
||||
for call in (
|
||||
lambda: flows.delete_flow(".."),
|
||||
lambda: flows.read_flow("_lib"),
|
||||
lambda: flows.rename_flow("..", "elsewhere"),
|
||||
lambda: flows.write_node_source("demo", "../x", "code"),
|
||||
lambda: flows.write_lib_source("..", "code"),
|
||||
lambda: flows.exists("demo\n"),
|
||||
|
||||
@@ -110,17 +110,11 @@ def test_a_new_dashboard_is_a_draft_until_it_is_published(store: DashboardStore)
|
||||
assert store.read("house").title == created.title
|
||||
|
||||
|
||||
def test_an_unpublished_dashboard_can_be_renamed_and_deleted(store: DashboardStore):
|
||||
"""And renaming it does not put it on a wall either."""
|
||||
def test_an_unpublished_dashboard_can_be_deleted(store: DashboardStore):
|
||||
store.write_draft(default_dashboard("house"), 0)
|
||||
|
||||
store.rename("house", "home")
|
||||
|
||||
store.delete("house")
|
||||
assert not store.exists("house")
|
||||
assert store.has_draft("home") and not store.is_published("home")
|
||||
|
||||
store.delete("home")
|
||||
assert not store.exists("home")
|
||||
|
||||
|
||||
def test_a_duplicate_is_an_unpublished_copy_of_the_working_copy(
|
||||
@@ -139,16 +133,12 @@ def test_a_duplicate_is_an_unpublished_copy_of_the_working_copy(
|
||||
assert store.duplicate("house").name == "house_copy_2"
|
||||
|
||||
|
||||
def test_deleting_and_renaming(store: DashboardStore):
|
||||
def test_deleting(store: DashboardStore):
|
||||
store.write(default_dashboard("house"))
|
||||
|
||||
renamed = store.rename("house", "home")
|
||||
assert renamed.name == "home"
|
||||
assert not store.exists("house")
|
||||
|
||||
store.delete("home")
|
||||
store.delete("house")
|
||||
with pytest.raises(DashboardNotFound):
|
||||
store.read("home")
|
||||
store.read("house")
|
||||
|
||||
|
||||
def test_the_deepest_chart_decides_how_much_past_is_kept(store: DashboardStore):
|
||||
|
||||
@@ -164,25 +164,6 @@ def test_resaving_the_published_source_creates_no_draft(store: FlowStore):
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
def test_a_duplicate_is_a_local_draft_of_what_the_editor_shows(store: FlowStore):
|
||||
flow = a_flow(title="Warm").model_copy(update={"origin": FlowOrigin()})
|
||||
flow.nodes.append(NodeDef(id="reader", requires=[MessageSpec(name="heating.temp")]))
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Ids: who a flow or dashboard is, apart from what it is called.
|
||||
|
||||
The store keeps them, and the startup migration gives every document from
|
||||
before them its key as its id. A mistake in either loses data silently, which
|
||||
is why the migration is driven through crashes, refusals and stores without git.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fluksio import cli
|
||||
from fluksio.flow import identity
|
||||
from fluksio.flow.dashboards import (
|
||||
DASHBOARD_DIR,
|
||||
DashboardDef,
|
||||
DashboardExists,
|
||||
DashboardStore,
|
||||
default_dashboard,
|
||||
)
|
||||
from fluksio.flow.identity import COMMIT_MESSAGE, IdsRefused, assign_ids
|
||||
from fluksio.flow.schemas import FlowDef
|
||||
from fluksio.flow.store import (
|
||||
ISSUED_ID,
|
||||
FlowExists,
|
||||
FlowStore,
|
||||
IdentityChange,
|
||||
ReservedKey,
|
||||
new_id,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The store
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> FlowStore:
|
||||
return FlowStore(tmp_path / "flows")
|
||||
|
||||
|
||||
def test_a_flow_is_given_its_key_as_id_and_keeps_both(store: FlowStore):
|
||||
created = store.write_draft(FlowDef(name="heating"), 0)
|
||||
assert (created.id, created.name) == ("heating", "heating")
|
||||
|
||||
published = store.publish_flow("heating", created.version)
|
||||
store.write_draft(published.model_copy(update={"title": "Warm"}), 1)
|
||||
discarded = store.discard_draft("heating")
|
||||
|
||||
assert json.loads((store.root / "heating" / "flow.json").read_text())["id"] == (
|
||||
"heating"
|
||||
)
|
||||
assert (discarded.id, discarded.name) == ("heating", "heating")
|
||||
|
||||
|
||||
def test_a_document_from_before_ids_reads_with_its_directory_as_id(store: FlowStore):
|
||||
(store.root / "heating").mkdir()
|
||||
(store.root / "heating" / "flow.json").write_text('{"name": "heating"}')
|
||||
(store.root / DASHBOARD_DIR / "home").mkdir(parents=True)
|
||||
(store.root / DASHBOARD_DIR / "home" / "dashboard.json").write_text(
|
||||
'{"name": "home"}'
|
||||
)
|
||||
|
||||
assert store.read_flow("heating").id == "heating"
|
||||
assert DashboardStore(store).read("home").id == "home"
|
||||
# And a save of it, without an id, is the same flow and not a new one.
|
||||
assert store.write_draft(FlowDef(name="heating", title="Warm"), 1).version == 2
|
||||
|
||||
|
||||
def test_a_save_cannot_change_an_id_or_a_key(store: FlowStore):
|
||||
store.write_flow(FlowDef(id="x_heating", name="heating"))
|
||||
|
||||
with pytest.raises(IdentityChange):
|
||||
store.write_draft(FlowDef(id="x_heating", name="warmth"), 1)
|
||||
with pytest.raises(IdentityChange):
|
||||
store.write_flow(FlowDef(id="x_heating", name="warmth"))
|
||||
|
||||
dashboards = DashboardStore(store)
|
||||
dashboards.write(DashboardDef(id="x_home", name="home"))
|
||||
with pytest.raises(IdentityChange):
|
||||
dashboards.write_draft(DashboardDef(id="x_home", name="house"), 1)
|
||||
assert dashboards.read("x_home").name == "home"
|
||||
|
||||
|
||||
def test_a_key_belongs_to_one_flow_drafts_included(store: FlowStore):
|
||||
"""A draft nobody published holds its key, and a delete lets it go."""
|
||||
store.write_draft(FlowDef(id="x_heating", name="heating"), 0)
|
||||
assert store.key_index() == {"heating": "x_heating"}
|
||||
|
||||
with pytest.raises(FlowExists):
|
||||
store.write_draft(FlowDef(name="heating"), 0)
|
||||
with pytest.raises(FlowExists):
|
||||
store.write_flow(FlowDef(id="y_heating", name="heating"))
|
||||
|
||||
store.delete_flow("x_heating")
|
||||
assert store.write_draft(FlowDef(name="heating"), 0).id == "heating"
|
||||
assert store.key_index() == {"heating": "heating"}
|
||||
|
||||
|
||||
def test_a_key_belongs_to_one_dashboard_drafts_included(store: FlowStore):
|
||||
dashboards = DashboardStore(store)
|
||||
dashboards.write_draft(DashboardDef(id="x_home", name="home"), 0)
|
||||
# Its own id space: a flow may share the key.
|
||||
store.write_draft(FlowDef(name="home"), 0)
|
||||
|
||||
with pytest.raises(DashboardExists):
|
||||
dashboards.write_draft(default_dashboard("home"), 0)
|
||||
with pytest.raises(DashboardExists):
|
||||
dashboards.write(DashboardDef(id="y_home", name="home"))
|
||||
|
||||
dashboards.delete("x_home")
|
||||
assert dashboards.write(default_dashboard("home")).id == "home"
|
||||
|
||||
|
||||
def test_a_copy_takes_a_key_nobody_holds(store: FlowStore):
|
||||
store.write_flow(FlowDef(name="heating"))
|
||||
store.write_draft(FlowDef(id="x_other", name="heating_copy"), 0)
|
||||
dashboards = DashboardStore(store)
|
||||
dashboards.write(default_dashboard("home"))
|
||||
dashboards.write_draft(DashboardDef(id="x_other", name="home_copy"), 0)
|
||||
|
||||
copy = store.duplicate_flow("heating")
|
||||
board = dashboards.duplicate("home")
|
||||
|
||||
assert (copy.id, copy.name) == ("heating_copy_2", "heating_copy_2")
|
||||
assert (board.id, board.name) == ("home_copy_2", "home_copy_2")
|
||||
|
||||
|
||||
def test_a_new_key_shaped_like_an_issued_id_is_refused(store: FlowStore):
|
||||
with pytest.raises(ReservedKey):
|
||||
store.write_draft(FlowDef(name="f_k3q7m2xa"), 0)
|
||||
with pytest.raises(ReservedKey):
|
||||
DashboardStore(store).write_draft(default_dashboard("d_k3q7m2xa"), 0)
|
||||
assert store.write_draft(FlowDef(name="f_k3q7m2xa_lab"), 0).name == (
|
||||
"f_k3q7m2xa_lab"
|
||||
)
|
||||
|
||||
|
||||
def test_an_existing_key_shaped_like_an_issued_id_stays_saveable(store: FlowStore):
|
||||
(store.root / "f_k3q7m2xa").mkdir()
|
||||
(store.root / "f_k3q7m2xa" / "flow.json").write_text('{"name": "f_k3q7m2xa"}')
|
||||
|
||||
saved = store.write_draft(FlowDef(name="f_k3q7m2xa", title="Old"), 1)
|
||||
|
||||
assert (saved.id, saved.title) == ("f_k3q7m2xa", "Old")
|
||||
|
||||
|
||||
def test_a_new_id_is_random_and_avoids_whatever_is_taken():
|
||||
asked: list[str] = []
|
||||
|
||||
def taken(candidate: str) -> bool:
|
||||
asked.append(candidate)
|
||||
return len(asked) < 4
|
||||
|
||||
issued = new_id("d", taken)
|
||||
|
||||
assert issued == asked[-1] and len(asked) == 4
|
||||
assert all(ISSUED_ID.fullmatch(c) and c.startswith("d_") for c in asked)
|
||||
fresh = {new_id("f", lambda _: False) for _ in range(50)}
|
||||
assert len(fresh) == 50
|
||||
assert all(ISSUED_ID.fullmatch(c) and c.startswith("f_") for c in fresh)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The migration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def put(root: Path, rel: str, data: dict | str) -> None:
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(data if isinstance(data, str) else json.dumps(data))
|
||||
|
||||
|
||||
def git(root: Path, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(root), *args], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
|
||||
|
||||
#: Every document a store from before ids holds, by path. The id each gets.
|
||||
GOLDEN = {
|
||||
# Published and a draft, and a field this version does not model.
|
||||
"heating/flow.json": {
|
||||
"name": "heating",
|
||||
"version": 3,
|
||||
"later": {"kept": True},
|
||||
"nodes": [{"id": "sensor", "provides": [{"name": "temp"}]}],
|
||||
},
|
||||
"heating/flow.draft.json": {
|
||||
"name": "heating",
|
||||
"version": 5,
|
||||
"nodes": [{"id": "reader", "requires": [{"name": "solar.watts"}]}],
|
||||
},
|
||||
# Only a draft.
|
||||
"lab/flow.draft.json": {"name": "lab", "version": 1},
|
||||
# Stopped, which is a file beside it that has no id to get.
|
||||
"stopped/flow.json": {"name": "stopped"},
|
||||
# A dashboard and a flow share a key: separate id spaces.
|
||||
"home/flow.json": {"name": "home", "version": 2},
|
||||
f"{DASHBOARD_DIR}/home/dashboard.json": {"name": "home", "version": 4},
|
||||
f"{DASHBOARD_DIR}/home/dashboard.draft.json": {
|
||||
"name": "home",
|
||||
"version": 4,
|
||||
"widgets": [],
|
||||
},
|
||||
# Pages and sections, as a dashboard was written before it was one grid.
|
||||
f"{DASHBOARD_DIR}/legacy/dashboard.json": {
|
||||
"name": "legacy",
|
||||
"version": 2,
|
||||
"pages": [
|
||||
{
|
||||
"sections": [
|
||||
{"widgets": [{"id": "t", "type": "stat", "config": {}}]},
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
f"{DASHBOARD_DIR}/draft_only/dashboard.draft.json": {"name": "draft_only"},
|
||||
}
|
||||
|
||||
#: What is not a flow or a dashboard, and so is never touched.
|
||||
BYSTANDERS = {
|
||||
"stopped/runtime.json": '{"enabled": false}',
|
||||
# A device called "flow", which `*/flow.json` would otherwise find.
|
||||
"_devices/flow.json": '{"id": "flow", "title": "Hall"}',
|
||||
"_lib/shared.py": "def process():\n return {}\n",
|
||||
"heating/nodes/sensor.py": "def process():\n return {'temp': 1}\n",
|
||||
}
|
||||
|
||||
#: The versions the golden store moves to: past published and draft alike.
|
||||
VERSIONS = {
|
||||
"heating/flow.json": 6,
|
||||
"heating/flow.draft.json": 6,
|
||||
"lab/flow.draft.json": 2,
|
||||
"stopped/flow.json": 2,
|
||||
"home/flow.json": 3,
|
||||
f"{DASHBOARD_DIR}/home/dashboard.json": 5,
|
||||
f"{DASHBOARD_DIR}/home/dashboard.draft.json": 5,
|
||||
f"{DASHBOARD_DIR}/legacy/dashboard.json": 3,
|
||||
f"{DASHBOARD_DIR}/draft_only/dashboard.draft.json": 2,
|
||||
}
|
||||
|
||||
|
||||
def golden(root: Path, extra: dict | None = None) -> Path:
|
||||
"""A store in the shape it had before ids, committed."""
|
||||
FlowStore(root)
|
||||
for rel, data in {**GOLDEN, **BYSTANDERS, **(extra or {})}.items():
|
||||
put(root, rel, data)
|
||||
git(root, "add", "-A")
|
||||
git(root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "Old")
|
||||
return root
|
||||
|
||||
|
||||
def snapshot(root: Path) -> dict[str, bytes]:
|
||||
"""Every file, git's own included, so a write anywhere shows."""
|
||||
return {
|
||||
str(p.relative_to(root)): p.read_bytes()
|
||||
for p in sorted(root.rglob("*"))
|
||||
if p.is_file()
|
||||
}
|
||||
|
||||
|
||||
def documents(root: Path) -> dict[str, dict]:
|
||||
return {rel: json.loads((root / rel).read_text()) for rel in GOLDEN}
|
||||
|
||||
|
||||
def assert_migrated(root: Path) -> None:
|
||||
for rel, data in documents(root).items():
|
||||
before = GOLDEN[rel]
|
||||
assert data["id"] == Path(rel).parent.name, rel
|
||||
assert data["version"] == VERSIONS[rel], rel
|
||||
# Nothing else about it changed, the fields nobody models included.
|
||||
assert {k: v for k, v in data.items() if k not in ("id", "version")} == {
|
||||
k: v for k, v in before.items() if k != "version"
|
||||
}, rel
|
||||
for rel, text in BYSTANDERS.items():
|
||||
assert (root / rel).read_text() == text, rel
|
||||
assert not (root / ".git" / ".assign-ids.json").exists()
|
||||
|
||||
|
||||
def test_a_store_from_before_ids_gets_each_key_as_id(tmp_path: Path, caplog):
|
||||
root = golden(tmp_path / "flows")
|
||||
commits = git(root, "rev-list", "--count", "HEAD")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=identity.__name__):
|
||||
changed = assign_ids(root)
|
||||
|
||||
assert changed == sorted(GOLDEN)
|
||||
assert_migrated(root)
|
||||
assert "heating/flow.json" in caplog.text
|
||||
# One commit of those documents and nothing else.
|
||||
assert git(root, "log", "-1", "--format=%s").strip() == COMMIT_MESSAGE
|
||||
assert sorted(git(root, "show", "--name-only", "--format=", "HEAD").split()) == (
|
||||
sorted(GOLDEN)
|
||||
)
|
||||
assert int(git(root, "rev-list", "--count", "HEAD")) == int(commits) + 1
|
||||
|
||||
# What the stores read is what they read before, but for the version.
|
||||
flows, dashboards = FlowStore(root), DashboardStore(FlowStore(root))
|
||||
assert flows.read_flow("heating", draft=True).nodes[0].id == "reader"
|
||||
assert flows.key_index() == {n: n for n in ("heating", "home", "lab", "stopped")}
|
||||
assert [w.id for w in dashboards.read("legacy").widgets] == ["t"]
|
||||
assert dashboards.key_index() == {n: n for n in ("draft_only", "home", "legacy")}
|
||||
|
||||
# Once is all: a second start changes nothing and says nothing.
|
||||
before = snapshot(root)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING, logger=identity.__name__):
|
||||
assert assign_ids(root) == []
|
||||
assert snapshot(root) == before
|
||||
assert caplog.text == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("survives", range(len(GOLDEN) + 1))
|
||||
def test_a_run_interrupted_part_way_is_finished_by_the_next(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, survives: int
|
||||
):
|
||||
"""The note of the versions is the first write, so it is always there."""
|
||||
root = golden(tmp_path / "flows")
|
||||
real = identity._replace
|
||||
writes: list[Path] = []
|
||||
|
||||
def crash(path: Path, text: str) -> None:
|
||||
if len(writes) == survives:
|
||||
# Half of the temporary file, and never the replace.
|
||||
path.with_name(f".{path.name}.tmp").write_text(text[:20])
|
||||
raise OSError("power cut")
|
||||
writes.append(path)
|
||||
real(path, text)
|
||||
|
||||
monkeypatch.setattr(identity, "_replace", crash)
|
||||
with pytest.raises(OSError):
|
||||
assign_ids(root)
|
||||
monkeypatch.setattr(identity, "_replace", real)
|
||||
|
||||
assert assign_ids(root) == sorted(GOLDEN)
|
||||
assert_migrated(root)
|
||||
assert git(root, "log", "-1", "--format=%s").strip() == COMMIT_MESSAGE
|
||||
assert git(root, "status", "--porcelain") == ""
|
||||
assert not list(root.rglob("*.tmp"))
|
||||
|
||||
|
||||
def test_a_run_interrupted_before_its_commit_is_committed_by_the_next(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Every document has its id by then, and the note is what is left."""
|
||||
root = golden(tmp_path / "flows")
|
||||
|
||||
def crash(*_: object) -> bool:
|
||||
raise OSError("power cut")
|
||||
|
||||
monkeypatch.setattr(identity, "_commit", crash)
|
||||
with pytest.raises(OSError):
|
||||
assign_ids(root)
|
||||
monkeypatch.undo()
|
||||
assert git(root, "log", "-1", "--format=%s").strip() == "Old"
|
||||
|
||||
assert assign_ids(root) == sorted(GOLDEN)
|
||||
assert_migrated(root)
|
||||
assert git(root, "log", "-1", "--format=%s").strip() == COMMIT_MESSAGE
|
||||
assert assign_ids(root) == []
|
||||
|
||||
|
||||
def test_documents_that_have_their_id_keep_their_version(tmp_path: Path):
|
||||
"""A pair half given its id moves the other half past both."""
|
||||
root = golden(
|
||||
tmp_path / "flows",
|
||||
{
|
||||
"heating/flow.json": {"id": "heating", "name": "heating", "version": 3},
|
||||
# An older engine saved this after the published one got its id.
|
||||
"heating/flow.draft.json": {"name": "heating", "version": 7},
|
||||
"lab/flow.draft.json": {"id": "lab", "name": "lab", "version": 1},
|
||||
# Moved apart by a rename, with its id: nothing to do with it.
|
||||
"warmth/flow.json": {"id": "warmth", "name": "radiators"},
|
||||
},
|
||||
)
|
||||
|
||||
changed = assign_ids(root)
|
||||
|
||||
assert "heating/flow.json" not in changed and "lab/flow.draft.json" not in changed
|
||||
assert "warmth/flow.json" not in changed
|
||||
assert json.loads((root / "heating/flow.json").read_text())["version"] == 3
|
||||
assert json.loads((root / "heating/flow.draft.json").read_text())["version"] == 8
|
||||
assert json.loads((root / "lab/flow.draft.json").read_text())["version"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extra", "named"),
|
||||
[
|
||||
# Which one the flow is cannot be told: the engine runs it as `warmth`.
|
||||
({"heating/flow.json": {"name": "warmth"}}, "heating/flow.json"),
|
||||
({"heating/flow.json": {"id": "warmth", "name": "heating"}}, "'warmth'"),
|
||||
(
|
||||
{
|
||||
"pair/flow.json": {"id": "pair", "name": "one"},
|
||||
"pair/flow.draft.json": {"id": "pair", "name": "two"},
|
||||
},
|
||||
"'one' and 'two'",
|
||||
),
|
||||
(
|
||||
{
|
||||
"a/flow.json": {"id": "a", "name": "same"},
|
||||
"c/flow.draft.json": {"id": "c", "name": "same"},
|
||||
},
|
||||
"'a' and 'c' share the key 'same'",
|
||||
),
|
||||
({"lab/flow.draft.json": "{not json"}, "lab/flow.draft.json"),
|
||||
({f"{DASHBOARD_DIR}/home/dashboard.json": {"name": "hall"}}, "'hall'"),
|
||||
],
|
||||
)
|
||||
def test_a_store_in_doubt_is_refused_and_left_as_it_was(
|
||||
tmp_path: Path, extra: dict, named: str
|
||||
):
|
||||
root = golden(tmp_path / "flows", extra)
|
||||
before = snapshot(root)
|
||||
|
||||
with pytest.raises(IdsRefused) as refused:
|
||||
assign_ids(root)
|
||||
|
||||
assert named in str(refused.value)
|
||||
assert f"fluksio store migrate --dry-run {root}" in str(refused.value)
|
||||
assert snapshot(root) == before
|
||||
|
||||
|
||||
def test_uncommitted_changes_beside_it_stay_uncommitted(tmp_path: Path):
|
||||
root = golden(tmp_path / "flows")
|
||||
(root / "_lib" / "shared.py").write_text("edited behind the API\n")
|
||||
(root / "notes.txt").write_text("someone's notes\n")
|
||||
git(root, "add", "notes.txt")
|
||||
|
||||
assign_ids(root)
|
||||
|
||||
assert sorted(git(root, "show", "--name-only", "--format=", "HEAD").split()) == (
|
||||
sorted(GOLDEN)
|
||||
)
|
||||
assert git(root, "status", "--porcelain").splitlines() == [
|
||||
" M _lib/shared.py",
|
||||
"A notes.txt",
|
||||
]
|
||||
|
||||
|
||||
def test_a_store_without_a_repository(tmp_path: Path):
|
||||
root = tmp_path / "flows"
|
||||
for rel, data in {**GOLDEN, **BYSTANDERS}.items():
|
||||
put(root, rel, data)
|
||||
|
||||
assert assign_ids(root) == sorted(GOLDEN)
|
||||
|
||||
assert_migrated(root)
|
||||
assert not (root / ".assign-ids.json").exists()
|
||||
assert not (root / ".git").exists()
|
||||
|
||||
|
||||
def test_a_machine_without_git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
root = golden(tmp_path / "flows")
|
||||
real = subprocess.run
|
||||
|
||||
def no_git(cmd, *args, **kwargs):
|
||||
if cmd and cmd[0] == "git":
|
||||
raise FileNotFoundError(2, "No such file or directory", "git")
|
||||
return real(cmd, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(identity.subprocess, "run", no_git)
|
||||
|
||||
assert assign_ids(root) == sorted(GOLDEN)
|
||||
assert_migrated(root)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# `fluksio store`
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_dry_run_changes_no_bytes(tmp_path: Path, capsys):
|
||||
root = golden(tmp_path / "flows")
|
||||
before = snapshot(root)
|
||||
|
||||
assert cli.main(["store", "migrate", "--dry-run", str(root)]) == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "4 flows, 3 dashboards" in out
|
||||
assert "heating/flow.draft.json (version 6)" in out
|
||||
assert snapshot(root) == before
|
||||
|
||||
|
||||
def test_the_dry_run_creates_no_repository_and_names_what_it_refuses(
|
||||
tmp_path: Path, capsys
|
||||
):
|
||||
root = tmp_path / "flows"
|
||||
for rel, data in {**GOLDEN, "odd/flow.json": {"name": "even"}}.items():
|
||||
put(root, rel, data)
|
||||
before = snapshot(root)
|
||||
|
||||
assert cli.main(["store", "migrate", "--dry-run", str(root)]) == 1
|
||||
|
||||
assert "odd/flow.json: its name 'even' is not its directory 'odd'" in (
|
||||
capsys.readouterr().out
|
||||
)
|
||||
assert snapshot(root) == before
|
||||
assert not (root / ".git").exists()
|
||||
|
||||
|
||||
def test_ls_lists_ids_keys_and_kinds(tmp_path: Path, capsys):
|
||||
root = golden(
|
||||
tmp_path / "flows", {"warmth/flow.json": {"id": "warmth", "name": "radiators"}}
|
||||
)
|
||||
|
||||
assert cli.main(["store", "ls", str(root)]) == 0
|
||||
|
||||
rows = [line.split() for line in capsys.readouterr().out.splitlines()]
|
||||
assert rows[0] == ["ID", "KEY", "KIND"]
|
||||
assert ["home", "home", "dashboard"] in rows and ["home", "home", "flow"] in rows
|
||||
assert ["warmth", "radiators", "flow"] in rows
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
|
||||
from fluksio.flow.messages import MessageSpec
|
||||
from fluksio.flow.schemas import FlowDef, NodeDef
|
||||
from fluksio.flow.store import FlowExists, FlowNotFound, FlowStore
|
||||
from fluksio.flow.store import FlowNotFound, FlowStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -92,48 +92,6 @@ def test_deleting_removes_flow_and_its_nodes(store: FlowStore):
|
||||
assert not (store.root / "heating").exists()
|
||||
|
||||
|
||||
def test_renaming_a_flow_carries_its_nodes(store: FlowStore):
|
||||
store.write_flow(a_flow())
|
||||
store.write_node_source("heating", "sensor", "def process():\n return {}\n")
|
||||
|
||||
renamed = store.rename_flow("heating", "warmth")
|
||||
|
||||
assert renamed.name == "warmth"
|
||||
assert store.list_flows() == ["warmth"]
|
||||
assert "def process" in store.read_node_source("warmth", "sensor")
|
||||
|
||||
|
||||
def test_renaming_a_flow_repoints_the_flows_reading_it(store: FlowStore):
|
||||
store.write_flow(a_flow())
|
||||
store.write_flow(
|
||||
FlowDef(
|
||||
name="display",
|
||||
nodes=[
|
||||
NodeDef(
|
||||
id="gauge",
|
||||
# Reads across the flow boundary, so the name must follow.
|
||||
requires=[MessageSpec(name="heating.temp")],
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
store.rename_flow("heating", "warmth")
|
||||
|
||||
display = store.read_flow("display")
|
||||
assert display.nodes[0].requires[0].name == "warmth.temp"
|
||||
|
||||
|
||||
def test_renaming_onto_an_existing_name_is_refused(store: FlowStore):
|
||||
store.write_flow(a_flow())
|
||||
store.write_flow(FlowDef(name="warmth"))
|
||||
|
||||
with pytest.raises(FlowExists):
|
||||
store.rename_flow("heating", "warmth")
|
||||
|
||||
assert store.list_flows() == ["heating", "warmth"]
|
||||
|
||||
|
||||
def test_opening_the_store_renames_trigger_nodes_to_debounce(store: FlowStore):
|
||||
"""The old name has no alias, so what is stored under it is rewritten once."""
|
||||
store.write_flow(
|
||||
|
||||
Reference in New Issue
Block a user