Files
homelable/backend/tests/test_documents.py
T
Pouzor 56d83668d4 fix(docs): follow the title the body gives, in the row and in the tree
Editing `title:` in the frontmatter left the tree showing the name the
document was created with. `_apply_body` refreshed the frontmatter and tags
caches from the body and stopped there, though that line is the only place a
document is ever named — `rename` exists in the store and nothing in the UI
calls it. So the title column follows the body too, on every save and on a
restore, which brings back the name that body carried. It reslugs with it,
ignores a blank or non-string value, and an explicit title in the same request
still wins.

That alone would not have moved the row the user was looking at:
`buildDeviceTree` labelled a device leaf from the inventory whatever the
document said. Once a device is documented the row is that document, so it
carries the document's title, and falls back to the device's label only while
there is nothing to open.

ha-relevant: yes
2026-09-07 10:38:24 +02:00

775 lines
36 KiB
Python

"""The documents API.
Covers what the plan promises the user: a document per device generated once,
a Library tree that cannot be knotted, history that survives a restore, and a
document that outlives the device it describes.
"""
import uuid
from httpx import AsyncClient
async def _device(client: AsyncClient, headers: dict, **body) -> dict:
payload = {"label": "nas-01", "hostname": "nas-01.lan", "ip": "192.168.1.20", "discovery_source": "manual", **body}
res = await client.post("/api/v1/scan/pending", json=payload, headers=headers)
assert res.status_code in (200, 201), res.text
return res.json()
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
res = await client.post("/api/v1/documents", json={"title": "Page", **body}, headers=headers)
assert res.status_code == 201, res.text
return res.json()
async def _design(client: AsyncClient, headers: dict) -> str:
res = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
assert res.status_code == 201, res.text
return res.json()["id"]
# ── auth ────────────────────────────────────────────────────────────────────
async def test_list_requires_auth(client: AsyncClient):
assert (await client.get("/api/v1/documents")).status_code == 401
async def test_create_requires_auth(client: AsyncClient):
assert (await client.post("/api/v1/documents", json={"title": "X"})).status_code == 401
async def test_search_requires_auth(client: AsyncClient):
assert (await client.get("/api/v1/documents/search?q=x")).status_code == 401
async def test_coverage_requires_auth(client: AsyncClient):
assert (await client.get("/api/v1/documents/coverage")).status_code == 401
async def test_delete_requires_auth(client: AsyncClient):
assert (await client.delete("/api/v1/documents/x")).status_code == 401
# ── create ──────────────────────────────────────────────────────────────────
async def test_create_a_blank_page(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="VLAN plan")
assert doc["kind"] == "page"
assert doc["slug"] == "vlan-plan"
assert "# VLAN plan" in doc["body"]
async def test_create_a_page_from_a_template(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Reboot the NAS", template_id="runbook")
assert "## Rollback" in doc["body"]
assert doc["template_id"] == "runbook"
async def test_create_a_page_with_an_explicit_body(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Raw", body="---\ntags: [a]\n---\n\nhello")
assert doc["body"].endswith("hello")
# The frontmatter cache is derived from the body, never sent on its own.
assert doc["frontmatter"] == {"tags": ["a"]}
assert doc["tags"] == ["a"]
async def test_create_a_folder(client: AsyncClient, headers: dict):
folder = await _create(client, headers, title="Runbooks", kind="folder")
child = await _create(client, headers, title="Reboot", parent_id=folder["id"])
assert child["parent_id"] == folder["id"]
async def test_create_rejects_an_unknown_kind(client: AsyncClient, headers: dict):
res = await client.post("/api/v1/documents", json={"title": "X", "kind": "wat"}, headers=headers)
assert res.status_code == 422
async def test_create_rejects_an_unknown_template(client: AsyncClient, headers: dict):
res = await client.post("/api/v1/documents", json={"title": "X", "template_id": "wat"}, headers=headers)
assert res.status_code == 422
async def test_create_rejects_a_missing_parent(client: AsyncClient, headers: dict):
res = await client.post(
"/api/v1/documents", json={"title": "X", "parent_id": str(uuid.uuid4())}, headers=headers
)
assert res.status_code == 404
async def test_create_rejects_a_page_that_also_names_a_device(client: AsyncClient, headers: dict):
device = await _device(client, headers)
res = await client.post(
"/api/v1/documents", json={"title": "X", "kind": "page", "device_id": device["id"]}, headers=headers
)
assert res.status_code == 400
async def test_create_rejects_a_device_document_naming_nothing(client: AsyncClient, headers: dict):
res = await client.post("/api/v1/documents", json={"title": "X", "kind": "device"}, headers=headers)
assert res.status_code == 400
async def test_create_rejects_two_links_at_once(client: AsyncClient, headers: dict):
device = await _device(client, headers)
res = await client.post(
"/api/v1/documents",
json={"title": "X", "kind": "device", "device_id": device["id"], "design_id": await _design(client, headers)},
headers=headers,
)
assert res.status_code == 400
async def test_create_rejects_a_device_that_does_not_exist(client: AsyncClient, headers: dict):
res = await client.post(
"/api/v1/documents",
json={"title": "X", "kind": "device", "device_id": str(uuid.uuid4())},
headers=headers,
)
assert res.status_code == 404
async def test_a_device_gets_at_most_one_document(client: AsyncClient, headers: dict):
device = await _device(client, headers)
body = {"title": "nas", "kind": "device", "device_id": device["id"]}
assert (await client.post("/api/v1/documents", json=body, headers=headers)).status_code == 201
second = await client.post("/api/v1/documents", json=body, headers=headers)
assert second.status_code == 409
async def test_a_sibling_title_gets_a_distinct_slug(client: AsyncClient, headers: dict):
first = await _create(client, headers, title="Network")
second = await _create(client, headers, title="Network")
assert first["slug"] == "network"
assert second["slug"] == "network-2"
# ── the generated device document ───────────────────────────────────────────
async def test_a_device_document_is_scaffolded_from_the_live_facts(client: AsyncClient, headers: dict):
device = await _device(
client, headers, hostname="nas-01.lan", vendor="Synology", notes="Holds the backups."
)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
assert "| Hostname | nas-01.lan |" in doc["body"]
assert "| IP | 192.168.1.20 |" in doc["body"]
assert "Holds the backups." in doc["body"]
assert doc["facts_snapshot"]["ip"] == "192.168.1.20"
assert doc["facts_synced_at"] is not None
async def test_the_old_notes_column_is_left_untouched_by_scaffolding(client: AsyncClient, headers: dict):
device = await _device(client, headers, notes="Holds the backups.")
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
res = await client.get("/api/v1/scan/pending", headers=headers)
row = next(d for d in res.json() if d["id"] == device["id"])
assert row["notes"] == "Holds the backups."
async def test_a_device_document_records_its_zone_and_neighbours(client: AsyncClient, headers: dict):
design_id = await _design(client, headers)
device = await _device(client, headers)
zone = await client.post(
"/api/v1/nodes",
json={"type": "groupRect", "label": "Garage", "design_id": design_id, "pos_x": 0, "pos_y": 0},
headers=headers,
)
zone_id = zone.json()["id"]
# A node binds to its inventory row by its addresses, not by an id on the
# payload — the canvas is one more way to document hardware, not a store.
node = await client.post(
"/api/v1/nodes",
json={
"type": "nas",
"label": "nas-01",
"design_id": design_id,
"ip": "192.168.1.20",
"hostname": "nas-01.lan",
"parent_id": zone_id,
"pos_x": 0,
"pos_y": 0,
},
headers=headers,
)
assert node.json()["device_id"] == device["id"], node.text
peer = await client.post(
"/api/v1/nodes",
json={"type": "switch", "label": "switch-core", "design_id": design_id, "pos_x": 0, "pos_y": 0},
headers=headers,
)
await client.post(
"/api/v1/edges",
json={"source": node.json()["id"], "target": peer.json()["id"], "type": "ethernet"},
headers=headers,
)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
assert "Zone **Garage**." in doc["body"]
assert "`switch-core`" in doc["body"]
# ── blocks ──────────────────────────────────────────────────────────────────
async def test_a_block_can_be_regenerated_on_demand(client: AsyncClient, headers: dict):
device = await _device(client, headers, hostname="nas-01.lan")
res = await client.get(
f"/api/v1/documents/blocks?block=device-info&device_id={device['id']}", headers=headers
)
assert res.status_code == 200, res.text
assert "| Hostname | nas-01.lan |" in res.json()["markdown"]
async def test_an_unknown_block_is_rejected(client: AsyncClient, headers: dict):
device = await _device(client, headers)
res = await client.get(f"/api/v1/documents/blocks?block=nope&device_id={device['id']}", headers=headers)
assert res.status_code == 400
async def test_a_block_for_a_missing_device_is_404(client: AsyncClient, headers: dict):
res = await client.get(
f"/api/v1/documents/blocks?block=device-info&device_id={uuid.uuid4()}", headers=headers
)
assert res.status_code == 404
# ── read / list ─────────────────────────────────────────────────────────────
async def test_get_a_missing_document_is_404(client: AsyncClient, headers: dict):
assert (await client.get(f"/api/v1/documents/{uuid.uuid4()}", headers=headers)).status_code == 404
async def test_listing_carries_no_body(client: AsyncClient, headers: dict):
await _create(client, headers, title="VLAN plan")
row = (await client.get("/api/v1/documents", headers=headers)).json()[0]
assert "body" not in row
async def test_listing_filters_by_kind_and_parent(client: AsyncClient, headers: dict):
folder = await _create(client, headers, title="Runbooks", kind="folder")
await _create(client, headers, title="Reboot", parent_id=folder["id"])
await _create(client, headers, title="Loose")
folders = (await client.get("/api/v1/documents?kind=folder", headers=headers)).json()
assert [d["title"] for d in folders] == ["Runbooks"]
children = (await client.get(f"/api/v1/documents?parent_id={folder['id']}", headers=headers)).json()
assert [d["title"] for d in children] == ["Reboot"]
async def test_listing_filters_by_tag(client: AsyncClient, headers: dict):
await _create(client, headers, title="A", body="---\ntags: [Network]\n---\n")
await _create(client, headers, title="B", body="---\ntags: [storage]\n---\n")
hits = (await client.get("/api/v1/documents?tag=network", headers=headers)).json()
assert [d["title"] for d in hits] == ["A"]
# ── update ──────────────────────────────────────────────────────────────────
async def test_editing_the_body_refreshes_the_frontmatter_cache(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page")
res = await client.patch(
f"/api/v1/documents/{doc['id']}", json={"body": "---\ntags: [x]\n---\n\nnew"}, headers=headers
)
assert res.status_code == 200, res.text
assert res.json()["tags"] == ["x"]
async def test_the_body_renames_the_document(client: AsyncClient, headers: dict):
"""`title:` in the frontmatter is the only place a document is named."""
doc = await _create(client, headers, title="Page")
res = await client.patch(
f"/api/v1/documents/{doc['id']}",
json={"body": "---\ntitle: SMB / CIFS\n---\n\n# SMB / CIFS\n"},
headers=headers,
)
assert res.status_code == 200, res.text
assert res.json()["title"] == "SMB / CIFS"
assert res.json()["slug"] == "smb-cifs"
async def test_a_body_without_a_title_keeps_the_one_it_has(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page")
res = await client.patch(
f"/api/v1/documents/{doc['id']}", json={"body": "---\ntags: [x]\n---\n\nnew"}, headers=headers
)
assert res.json()["title"] == "Page"
async def test_a_blank_title_in_the_body_is_ignored(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page")
res = await client.patch(
f"/api/v1/documents/{doc['id']}", json={"body": "---\ntitle: ' '\n---\n\nnew"}, headers=headers
)
assert res.json()["title"] == "Page"
async def test_an_explicit_title_wins_over_the_body_in_the_same_request(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page")
res = await client.patch(
f"/api/v1/documents/{doc['id']}",
json={"title": "Chosen", "body": "---\ntitle: From the body\n---\n\nnew"},
headers=headers,
)
assert res.json()["title"] == "Chosen"
async def test_a_device_document_renamed_in_its_body_keeps_its_device(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
res = await client.patch(
f"/api/v1/documents/{doc['id']}",
json={"body": "---\ntitle: The big NAS\n---\n\n# The big NAS\n"},
headers=headers,
)
assert res.json()["title"] == "The big NAS"
assert res.json()["device_id"] == device["id"]
async def test_renaming_reslugs(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page")
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"title": "New name"}, headers=headers)
assert res.json()["slug"] == "new-name"
async def test_starring_and_reviewing(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page")
res = await client.patch(
f"/api/v1/documents/{doc['id']}", json={"starred": True, "reviewed": True}, headers=headers
)
assert res.json()["starred"] is True
assert res.json()["reviewed_at"] is not None
async def test_patching_a_missing_document_is_404(client: AsyncClient, headers: dict):
res = await client.patch(f"/api/v1/documents/{uuid.uuid4()}", json={"title": "X"}, headers=headers)
assert res.status_code == 404
async def test_a_folder_cannot_be_moved_inside_itself(client: AsyncClient, headers: dict):
outer = await _create(client, headers, title="Outer", kind="folder")
inner = await _create(client, headers, title="Inner", kind="folder", parent_id=outer["id"])
res = await client.patch(f"/api/v1/documents/{outer['id']}", json={"parent_id": inner["id"]}, headers=headers)
assert res.status_code == 400
async def test_a_document_can_only_be_filed_under_a_folder(client: AsyncClient, headers: dict):
page = await _create(client, headers, title="Page")
other = await _create(client, headers, title="Other")
res = await client.patch(f"/api/v1/documents/{other['id']}", json={"parent_id": page["id"]}, headers=headers)
assert res.status_code == 400
async def test_a_device_document_does_not_live_in_the_library_tree(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas", kind="device", device_id=device["id"])
folder = await _create(client, headers, title="F", kind="folder")
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"parent_id": folder["id"]}, headers=headers)
assert res.status_code == 400
async def test_resyncing_facts_clears_the_drift_without_touching_the_body(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas", kind="device", device_id=device["id"])
await client.patch(f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers)
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"resync_facts": True}, headers=headers)
assert res.json()["facts_snapshot"]["ip"] == "192.168.1.99"
# The header the user owns still says what it always said.
assert "192.168.1.20" in res.json()["body"]
# ── drift ───────────────────────────────────────────────────────────────────
async def test_a_fresh_device_document_has_not_drifted(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
assert doc["drifted"] is False
read = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
assert read["drifted"] is False
async def test_changing_the_device_marks_the_document_drifted(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
await client.patch(
f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers
)
read = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
assert read["drifted"] is True
async def test_a_property_edit_alone_marks_the_document_drifted(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
await client.patch(
f"/api/v1/scan/pending/{device['id']}",
json={"properties": [{"key": "Rack", "value": "A1"}]},
headers=headers,
)
read = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
assert read["drifted"] is True
async def test_editing_the_body_does_not_make_a_document_drift(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "my words"}, headers=headers)
assert res.json()["drifted"] is False
async def test_accepting_the_facts_clears_the_drift(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
await client.patch(
f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers
)
res = await client.patch(
f"/api/v1/documents/{doc['id']}", json={"resync_facts": True}, headers=headers
)
assert res.json()["drifted"] is False
async def test_regenerating_clears_the_drift(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
await client.patch(
f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers
)
res = await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
assert res.json()["drifted"] is False
async def test_the_listing_carries_the_drift_flag_for_the_tree_badge(
client: AsyncClient, headers: dict
):
fresh = await _device(client, headers, label="switch-01", ip="192.168.1.2")
stale = await _device(client, headers, label="nas-01", ip="192.168.1.20")
in_sync = await _create(client, headers, title="switch-01", kind="device", device_id=fresh["id"])
moved = await _create(client, headers, title="nas-01", kind="device", device_id=stale["id"])
await client.patch(
f"/api/v1/scan/pending/{stale['id']}", json={"ip": "192.168.1.99"}, headers=headers
)
listing = (await client.get("/api/v1/documents", headers=headers)).json()
by_id = {d["id"]: d for d in listing}
assert by_id[moved["id"]]["drifted"] is True
assert by_id[in_sync["id"]]["drifted"] is False
async def test_a_library_page_never_drifts(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="VLAN plan")
assert doc["drifted"] is False
# ── revisions ───────────────────────────────────────────────────────────────
async def test_an_edit_records_the_previous_body(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page", body="first")
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "second"}, headers=headers)
revisions = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()
assert len(revisions) == 1
assert revisions[0]["reason"] == "edit"
stored = (await client.get(f"/api/v1/documents/revisions/{revisions[0]['id']}", headers=headers)).json()
assert stored["body"] == "first"
async def test_saving_an_unchanged_body_records_nothing(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page", body="same")
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "same"}, headers=headers)
assert (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json() == []
async def test_restoring_brings_back_an_old_body_and_is_itself_undoable(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page", body="first")
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "second"}, headers=headers)
revision = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()[0]
res = await client.post(
f"/api/v1/documents/{doc['id']}/revisions/{revision['id']}/restore", headers=headers
)
assert res.status_code == 200, res.text
assert res.json()["body"] == "first"
history = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()
assert [r["reason"] for r in history][0] == "restore"
async def test_restoring_brings_back_the_title_that_body_carried(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page", body="---\ntitle: First name\n---\n\nfirst")
await client.patch(
f"/api/v1/documents/{doc['id']}",
json={"body": "---\ntitle: Second name\n---\n\nsecond"},
headers=headers,
)
revision = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()[0]
res = await client.post(
f"/api/v1/documents/{doc['id']}/revisions/{revision['id']}/restore", headers=headers
)
assert res.json()["title"] == "First name"
async def test_restoring_a_revision_of_another_document_is_404(client: AsyncClient, headers: dict):
a = await _create(client, headers, title="A", body="one")
b = await _create(client, headers, title="B", body="one")
await client.patch(f"/api/v1/documents/{a['id']}", json={"body": "two"}, headers=headers)
revision = (await client.get(f"/api/v1/documents/{a['id']}/revisions", headers=headers)).json()[0]
res = await client.post(f"/api/v1/documents/{b['id']}/revisions/{revision['id']}/restore", headers=headers)
assert res.status_code == 404
async def test_history_is_pruned_to_the_limit(client: AsyncClient, headers: dict):
from app.services.doc_tree import REVISION_LIMIT
doc = await _create(client, headers, title="Page", body="v0")
for i in range(1, REVISION_LIMIT + 6):
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": f"v{i}"}, headers=headers)
revisions = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()
assert len(revisions) == REVISION_LIMIT
# ── regenerate ──────────────────────────────────────────────────────────────
async def test_regenerate_requires_auth(client: AsyncClient):
assert (await client.post("/api/v1/documents/x/regenerate")).status_code == 401
async def test_regenerating_a_device_document_rebuilds_it_from_the_facts(
client: AsyncClient, headers: dict
):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "everything I wrote"}, headers=headers)
res = await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
assert res.status_code == 200, res.text
body = res.json()
assert "everything I wrote" not in body["body"]
assert "192.168.1.20" in body["body"]
# A regenerated document is only the template again, as it was on day one.
assert body["edited_at"] is None
assert body["template_id"] == "device"
async def test_regenerating_reads_the_devices_current_facts(client: AsyncClient, headers: dict):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
await client.patch(
f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers
)
res = await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
assert "192.168.1.99" in res.json()["body"]
# Regenerating documents the device as it is now, so the drift is gone.
assert res.json()["facts_snapshot"]["ip"] == "192.168.1.99"
async def test_regenerating_keeps_the_replaced_body_in_the_history(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="VLAN plan", body="my own words")
await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
revisions = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()
assert revisions[0]["reason"] == "regenerate"
stored = (await client.get(f"/api/v1/documents/revisions/{revisions[0]['id']}", headers=headers)).json()
assert stored["body"] == "my own words"
async def test_regenerating_a_library_page_uses_its_template(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Restart the NAS", template_id="runbook")
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "gone"}, headers=headers)
res = await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
assert res.status_code == 200, res.text
assert "gone" not in res.json()["body"]
assert "# Restart the NAS" in res.json()["body"]
assert res.json()["template_id"] == "runbook"
async def test_regenerating_a_folder_is_rejected(client: AsyncClient, headers: dict):
folder = await _create(client, headers, title="Runbooks", kind="folder")
res = await client.post(f"/api/v1/documents/{folder['id']}/regenerate", headers=headers)
assert res.status_code == 400
async def test_regenerating_an_unknown_document_is_404(client: AsyncClient, headers: dict):
res = await client.post(f"/api/v1/documents/{uuid.uuid4()}/regenerate", headers=headers)
assert res.status_code == 404
# ── delete ──────────────────────────────────────────────────────────────────
async def test_deleting_a_folder_takes_its_subtree(client: AsyncClient, headers: dict):
outer = await _create(client, headers, title="Outer", kind="folder")
inner = await _create(client, headers, title="Inner", kind="folder", parent_id=outer["id"])
leaf = await _create(client, headers, title="Leaf", parent_id=inner["id"])
assert (await client.delete(f"/api/v1/documents/{outer['id']}", headers=headers)).status_code == 204
for doc_id in (outer["id"], inner["id"], leaf["id"]):
assert (await client.get(f"/api/v1/documents/{doc_id}", headers=headers)).status_code == 404
async def test_deleting_a_missing_document_is_404(client: AsyncClient, headers: dict):
assert (await client.delete(f"/api/v1/documents/{uuid.uuid4()}", headers=headers)).status_code == 404
async def test_a_deleted_document_leaves_the_search_index(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Page", body="unmistakable")
await client.delete(f"/api/v1/documents/{doc['id']}", headers=headers)
hits = (await client.get("/api/v1/documents/search?q=unmistakable", headers=headers)).json()["hits"]
assert hits == []
# ── the document outlives what it describes ─────────────────────────────────
async def test_deleting_a_device_orphans_its_document_but_keeps_the_body(client: AsyncClient, headers: dict):
device = await _device(client, headers, notes="Holds the backups.")
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
assert (await client.delete(f"/api/v1/scan/pending/{device['id']}", headers=headers)).status_code == 200
after = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
assert after["device_id"] is None
assert after["title"] == "nas-01"
assert "Holds the backups." in after["body"]
async def test_deleting_a_node_orphans_the_document_describing_it(client: AsyncClient, headers: dict):
design_id = await _design(client, headers)
zone = await client.post(
"/api/v1/nodes",
json={"type": "groupRect", "label": "Garage", "design_id": design_id, "pos_x": 0, "pos_y": 0},
headers=headers,
)
doc = await _create(client, headers, title="Garage", kind="node", node_id=zone.json()["id"])
await client.delete(f"/api/v1/nodes/{zone.json()['id']}", headers=headers)
after = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
assert after["node_id"] is None
assert after["title"] == "Garage"
# ── search ──────────────────────────────────────────────────────────────────
async def test_search_names_its_engine_and_returns_hits(client: AsyncClient, headers: dict):
await _create(client, headers, title="VLAN plan", body="Guest traffic is isolated.")
res = await client.get("/api/v1/documents/search?q=isolated", headers=headers)
assert res.status_code == 200, res.text
payload = res.json()
assert payload["engine"] in ("fts5", "like")
assert [h["title"] for h in payload["hits"]] == ["VLAN plan"]
async def test_search_carries_the_device_link_so_a_hit_can_be_opened(client: AsyncClient, headers: dict):
device = await _device(client, headers)
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
hits = (await client.get("/api/v1/documents/search?q=nas", headers=headers)).json()["hits"]
assert hits[0]["device_id"] == device["id"]
async def test_search_rejects_a_silly_limit(client: AsyncClient, headers: dict):
assert (await client.get("/api/v1/documents/search?q=x&limit=0", headers=headers)).status_code == 422
# ── scaffold and coverage ───────────────────────────────────────────────────
async def test_scaffold_documents_every_device_that_has_none(client: AsyncClient, headers: dict):
await _device(client, headers, label="a", ip="10.0.0.1")
await _device(client, headers, label="b", ip="10.0.0.2")
res = await client.post("/api/v1/documents/scaffold", json={}, headers=headers)
assert res.status_code == 200, res.text
assert len(res.json()["created"]) == 2
async def test_scaffold_is_repeatable_and_skips_what_exists(client: AsyncClient, headers: dict):
await _device(client, headers, label="a", ip="10.0.0.1")
await client.post("/api/v1/documents/scaffold", json={}, headers=headers)
again = (await client.post("/api/v1/documents/scaffold", json={}, headers=headers)).json()
assert again["created"] == []
assert again["skipped"] == 1
async def test_scaffold_can_be_limited_to_devices_that_have_notes(client: AsyncClient, headers: dict):
await _device(client, headers, label="a", ip="10.0.0.1", notes="worth keeping")
await _device(client, headers, label="b", ip="10.0.0.2")
res = (
await client.post("/api/v1/documents/scaffold", json={"only_with_notes": True}, headers=headers)
).json()
assert [d["title"] for d in res["created"]] == ["a"]
assert res["skipped"] == 1
async def test_scaffold_can_be_limited_to_named_devices(client: AsyncClient, headers: dict):
first = await _device(client, headers, label="a", ip="10.0.0.1")
await _device(client, headers, label="b", ip="10.0.0.2")
res = (
await client.post("/api/v1/documents/scaffold", json={"device_ids": [first["id"]]}, headers=headers)
).json()
assert [d["title"] for d in res["created"]] == ["a"]
async def test_a_migrated_document_records_why_it_exists(client: AsyncClient, headers: dict):
device = await _device(client, headers, notes="worth keeping")
created = (
await client.post("/api/v1/documents/scaffold", json={}, headers=headers)
).json()["created"][0]
revisions = (await client.get(f"/api/v1/documents/{created['id']}/revisions", headers=headers)).json()
assert revisions[0]["reason"] == "migrate"
assert device["id"]
async def test_coverage_counts_what_is_missing_and_unmigrated(client: AsyncClient, headers: dict):
await _device(client, headers, label="a", ip="10.0.0.1", notes="notes here")
await _device(client, headers, label="b", ip="10.0.0.2")
before = (await client.get("/api/v1/documents/coverage", headers=headers)).json()
assert before["devices"] == 2
assert before["documented"] == 0
assert before["missing"] == 2
assert before["notes_unmigrated"] == 1
await client.post("/api/v1/documents/scaffold", json={}, headers=headers)
after = (await client.get("/api/v1/documents/coverage", headers=headers)).json()
assert after["documented"] == 2
assert after["missing"] == 0
assert after["notes_unmigrated"] == 0
# Generated and not yet touched.
assert after["header_only"] == 2
async def test_coverage_reports_a_document_that_has_drifted(client: AsyncClient, headers: dict):
device = await _device(client, headers)
await _create(client, headers, title="nas", kind="device", device_id=device["id"])
assert (await client.get("/api/v1/documents/coverage", headers=headers)).json()["drifted"] == 0
await client.patch(f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers)
assert (await client.get("/api/v1/documents/coverage", headers=headers)).json()["drifted"] == 1
async def test_coverage_stops_calling_a_document_header_only_once_it_is_edited(
client: AsyncClient, headers: dict
):
device = await _device(client, headers)
doc = await _create(client, headers, title="nas", kind="device", device_id=device["id"])
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "written by hand"}, headers=headers)
assert (await client.get("/api/v1/documents/coverage", headers=headers)).json()["header_only"] == 0
async def test_coverage_counts_library_pages_separately(client: AsyncClient, headers: dict):
await _create(client, headers, title="VLAN plan")
await _create(client, headers, title="Runbooks", kind="folder")
assert (await client.get("/api/v1/documents/coverage", headers=headers)).json()["library_pages"] == 2