Files
app/backend/tests/flow/test_identity.py
T

518 lines
18 KiB
Python

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