Files
homelable/backend/tests/test_doc_tree.py
T
Pouzor e6d338be13 feat(docs): a real document space for the homelab
The only writing surface was `device_inventory.notes`, a single TEXT column
rendered as plain preformatted text. No structure, no links, no search, and
nowhere to write anything that is not about one device.

This adds markdown documents, a navigable tree and a Documentation section in
the left rail — the first app-level view that is not a canvas.

Model — three tables. `documents` (kind: device / node / design / page /
folder), `document_revisions` capped at 50 per document, and a `documents_fts`
FTS5 index. A document attaches to the *inventory row*, not to a node, so one
device reads the same on every canvas. Every link is SET NULL and the title is
denormalized: deleting a device or a canvas must never destroy what the user
wrote. Foreign keys are off in this SQLite setup, so `doc_links.unlink_documents`
clears those columns by hand in the nodes, scan and designs delete paths.

FTS5 is optional. The SQLite shipped in the LXC and Docker images may not carry
it — `doc_search` probes once at boot and falls back to LIKE, and the search
response names the engine it used so the UI can drop snippet highlighting.

A device document is scaffolded once, from the scan facts: identity table,
hardware, services (one section per service), network, operations,
troubleshooting, dependencies, changelog. Then it belongs to the user and
nothing rewrites it. The old `notes` are appended verbatim under `## Notes`
behind a provenance comment, and `device_inventory.notes` is left untouched —
HACS, the YAML export, MCP and canvas search still read it.

Drift is surfaced, never corrected: `facts_snapshot` powers a banner, and
`GET /documents/blocks` re-renders one generated block from current facts for
the editor's slash commands. `review_every` plus `reviewed_at` produce a
"needs attention" bucket, because homelab docs die of staleness.

The Devices tree is pivoted client-side from the already-loaded inventory —
zone, group, type, physical/virtual, subnet, rack, vendor, discovery source,
status, tag, A-Z — so re-pivoting costs no request. The Library beside it is
real folders the user makes. The Canvas pivot is scoped to the loaded canvas:
`NodeData` carries no `design_id`, so naming every canvas a device appears on
would be a guess.

Saving is explicit, per the product rule. The dirty body is mirrored to
localStorage and offered back on reopen; a draft whose base no longer matches
the document is discarded rather than replayed.

react-markdown is loaded behind React.lazy, so the canvas path does not pay for
it: the main bundle is unchanged and the section brings its own 199 kB chunk.
Raw HTML is not rendered, which removes the XSS surface and means no sanitizer.

Export/import, a print view, an aggregated open-tasks view and images inside a
document are deliberately not in this lot. MCP tools are lot 2.

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

134 lines
5.0 KiB
Python

"""Slugs, frontmatter parsing, and the two tree guards."""
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Document
from app.services import doc_tree
def _doc(**kwargs) -> Document:
base = dict(kind="folder", title="T", slug="t", body="")
base.update(kwargs)
return Document(**base)
# ── slugs ───────────────────────────────────────────────────────────────────
def test_slugify_lowercases_and_collapses_punctuation():
assert doc_tree.slugify("NAS-01 / main storage!") == "nas-01-main-storage"
def test_slugify_of_nothing_is_untitled():
assert doc_tree.slugify("") == "untitled"
assert doc_tree.slugify(" ") == "untitled"
assert doc_tree.slugify("!!!") == "untitled"
def test_slugify_truncates_a_long_title():
assert len(doc_tree.slugify("x" * 200)) == 80
async def test_unique_slug_suffixes_a_taken_sibling(db_session: AsyncSession):
db_session.add(_doc(title="Network", slug="network"))
await db_session.flush()
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network-2"
async def test_unique_slug_keeps_counting_past_the_second(db_session: AsyncSession):
db_session.add(_doc(slug="network"))
db_session.add(_doc(slug="network-2"))
await db_session.flush()
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network-3"
async def test_unique_slug_only_looks_at_siblings(db_session: AsyncSession):
parent = _doc(title="Folder", slug="folder")
db_session.add(parent)
await db_session.flush()
db_session.add(_doc(slug="network", parent_id=parent.id))
await db_session.flush()
# Same slug, different parent — no clash.
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network"
async def test_unique_slug_ignores_the_document_being_renamed(db_session: AsyncSession):
doc = _doc(title="Network", slug="network")
db_session.add(doc)
await db_session.flush()
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None, exclude_id=doc.id) == "network"
# ── frontmatter ─────────────────────────────────────────────────────────────
def test_parse_frontmatter_reads_the_leading_block():
body = "---\ntitle: NAS\ntags: [a, b]\n---\n\n# NAS\n"
assert doc_tree.parse_frontmatter(body) == {"title": "NAS", "tags": ["a", "b"]}
def test_parse_frontmatter_ignores_a_block_that_is_not_first():
assert doc_tree.parse_frontmatter("# Title\n\n---\ntitle: NAS\n---\n") == {}
def test_parse_frontmatter_of_malformed_yaml_is_empty_not_an_error():
# The user is mid-edit; the cache goes stale, the save does not fail.
assert doc_tree.parse_frontmatter("---\ntitle: [unclosed\n---\nbody") == {}
def test_parse_frontmatter_of_a_non_mapping_is_empty():
assert doc_tree.parse_frontmatter("---\n- a\n- b\n---\n") == {}
def test_parse_frontmatter_of_an_empty_body_is_empty():
assert doc_tree.parse_frontmatter("") == {}
def test_tags_accepts_a_list_or_a_comma_string():
assert doc_tree.tags_from({"tags": ["a", " b "]}) == ["a", "b"]
assert doc_tree.tags_from({"tags": "a, b"}) == ["a", "b"]
assert doc_tree.tags_from({"tags": 7}) == []
assert doc_tree.tags_from({}) == []
# ── tree guards ─────────────────────────────────────────────────────────────
async def _chain(db_session: AsyncSession, depth: int) -> list[Document]:
docs: list[Document] = []
parent_id = None
for i in range(depth):
doc = _doc(title=f"L{i}", slug=f"l{i}", parent_id=parent_id)
db_session.add(doc)
await db_session.flush()
docs.append(doc)
parent_id = doc.id
return docs
async def test_is_ancestor_walks_the_whole_chain(db_session: AsyncSession):
a, b, c = await _chain(db_session, 3)
assert await doc_tree.is_ancestor(db_session, a.id, c.id)
assert not await doc_tree.is_ancestor(db_session, c.id, a.id)
async def test_a_document_is_its_own_ancestor(db_session: AsyncSession):
(a,) = await _chain(db_session, 1)
assert await doc_tree.is_ancestor(db_session, a.id, a.id)
async def test_subtree_ids_collects_every_descendant(db_session: AsyncSession):
a, b, c = await _chain(db_session, 3)
sibling = _doc(title="S", slug="s", parent_id=a.id)
db_session.add(sibling)
await db_session.flush()
ids = await doc_tree.subtree_ids(db_session, a.id)
assert set(ids) == {a.id, b.id, c.id, sibling.id}
# Root first, so a caller deleting in reverse removes leaves first.
assert ids[0] == a.id
async def test_subtree_ids_of_a_leaf_is_just_itself(db_session: AsyncSession):
(a,) = await _chain(db_session, 1)
assert await doc_tree.subtree_ids(db_session, a.id) == [a.id]