Files
homelable/backend/tests/test_documents_migration.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

85 lines
3.1 KiB
Python

"""The documents DDL `create_all` cannot express.
A virtual table and three partial indexes, applied through `_try_migrate` at
boot. Both have to survive a second boot untouched, and the partial uniques are
what stop two documents claiming the same device behind the route's own check.
"""
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import create_async_engine
from app.db.database import DOCUMENT_DDL, Base, _try_migrate
pytestmark = pytest.mark.asyncio
async def _boot(tmp_path, times: int = 1):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
for _ in range(times):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
for label, sql in DOCUMENT_DDL:
await _try_migrate(conn, sql, label=label)
return engine
async def _names(engine, kind: str) -> set[str]:
async with engine.begin() as conn:
rows = (
await conn.exec_driver_sql(f"SELECT name FROM sqlite_master WHERE type = '{kind}'")
).fetchall()
return {row[0] for row in rows}
async def test_the_tables_and_the_search_index_are_created(tmp_path):
engine = await _boot(tmp_path)
tables = await _names(engine, "table")
assert {"documents", "document_revisions", "documents_fts"} <= tables
await engine.dispose()
async def test_the_partial_uniques_are_created(tmp_path):
engine = await _boot(tmp_path)
indexes = await _names(engine, "index")
assert {"ux_documents_device", "ux_documents_node", "ux_documents_design"} <= indexes
await engine.dispose()
async def test_a_second_boot_changes_nothing(tmp_path):
engine = await _boot(tmp_path, times=3)
tables = await _names(engine, "table")
assert {"documents", "documents_fts"} <= tables
await engine.dispose()
async def test_two_documents_cannot_claim_the_same_device(tmp_path):
engine = await _boot(tmp_path)
async with engine.begin() as conn:
insert = (
"INSERT INTO documents (id, kind, title, slug, body, device_id, sort_order, "
"frontmatter, tags, starred, created_at, updated_at) "
"VALUES (?, 'device', 'nas', ?, '', 'dev-1', 0, '{}', '[]', 0, "
"'2026-09-05', '2026-09-05')"
)
await conn.exec_driver_sql(insert, ("a", "nas"))
with pytest.raises(IntegrityError):
await conn.exec_driver_sql(insert, ("b", "nas-2"))
await engine.dispose()
async def test_many_documents_may_have_no_device(tmp_path):
engine = await _boot(tmp_path)
async with engine.begin() as conn:
insert = (
"INSERT INTO documents (id, kind, title, slug, body, device_id, sort_order, "
"frontmatter, tags, starred, created_at, updated_at) "
"VALUES (?, 'page', 'p', ?, '', NULL, 0, '{}', '[]', 0, "
"'2026-09-05', '2026-09-05')"
)
await conn.exec_driver_sql(insert, ("a", "p1"))
await conn.exec_driver_sql(insert, ("b", "p2"))
count = (await conn.exec_driver_sql("SELECT COUNT(*) FROM documents")).scalar_one()
assert count == 2
await engine.dispose()