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

173 lines
6.1 KiB
Python

"""Search over documents, on both engines.
FTS5 is not guaranteed by the SQLite build the LXC and Docker images ship, so
the LIKE fallback is a supported path, not a safety net — it gets the same
coverage as the index.
"""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Document
from app.services import doc_search
def _doc(**kwargs) -> Document:
base = dict(kind="page", title="T", slug="t", body="", tags=[])
base.update(kwargs)
return Document(**base)
async def _seed(db: AsyncSession) -> list[Document]:
docs = [
_doc(title="NAS", slug="nas", body="The Synology holds every backup.", tags=["storage"]),
_doc(title="Router", slug="router", body="Runs OPNsense on 192.168.1.1.", tags=["network"]),
_doc(title="VLAN plan", slug="vlan-plan", body="Guest traffic is isolated.", tags=["network"]),
]
for doc in docs:
db.add(doc)
await db.flush()
await doc_search.index_document(db, doc)
await db.flush()
return docs
# ── the MATCH expression ────────────────────────────────────────────────────
def test_match_query_quotes_every_term_and_prefixes_it():
assert doc_search.build_match_query("nas backup") == '"nas"* AND "backup"*'
def test_match_query_neutralises_punctuation_a_user_types():
# An IP and a lone quote are data, not FTS5 syntax.
assert doc_search.build_match_query("192.168.1.1") == '"192.168.1.1"*'
assert doc_search.build_match_query('a"b') == '"a""b"*'
def test_match_query_of_whitespace_is_empty():
assert doc_search.build_match_query(" ") == ""
def test_tags_text_flattens_a_list():
assert doc_search.tags_text(["a", "b"]) == "a b"
assert doc_search.tags_text(None) == ""
# ── FTS5 ────────────────────────────────────────────────────────────────────
async def test_fts_is_available_when_the_index_table_exists(db_session: AsyncSession):
assert await doc_search.fts_available(db_session) is True
async def test_search_finds_a_word_that_is_only_in_a_body(db_session: AsyncSession):
await _seed(db_session)
engine, hits = await doc_search.search(db_session, "Synology")
assert engine == "fts5"
assert len(hits) == 1
assert "Synology" in hits[0]["snippet"]
async def test_search_matches_a_prefix(db_session: AsyncSession):
await _seed(db_session)
_, hits = await doc_search.search(db_session, "OPNsen")
assert len(hits) == 1
async def test_search_matches_a_title(db_session: AsyncSession):
await _seed(db_session)
_, hits = await doc_search.search(db_session, "VLAN")
assert len(hits) == 1
async def test_search_matches_a_tag(db_session: AsyncSession):
await _seed(db_session)
_, hits = await doc_search.search(db_session, "network")
assert len(hits) == 2
async def test_search_ands_its_terms(db_session: AsyncSession):
await _seed(db_session)
_, hits = await doc_search.search(db_session, "guest traffic")
assert len(hits) == 1
_, none = await doc_search.search(db_session, "guest Synology")
assert none == []
async def test_search_of_an_empty_query_returns_nothing(db_session: AsyncSession):
await _seed(db_session)
_, hits = await doc_search.search(db_session, " ")
assert hits == []
async def test_reindexing_replaces_the_previous_body(db_session: AsyncSession):
docs = await _seed(db_session)
docs[0].body = "Replaced entirely."
await doc_search.index_document(db_session, docs[0])
_, gone = await doc_search.search(db_session, "Synology")
assert gone == []
_, found = await doc_search.search(db_session, "Replaced")
assert len(found) == 1
async def test_unindexing_removes_the_document(db_session: AsyncSession):
docs = await _seed(db_session)
await doc_search.unindex_document(db_session, docs[0].id)
_, hits = await doc_search.search(db_session, "Synology")
assert hits == []
async def test_the_limit_is_honoured(db_session: AsyncSession):
for i in range(8):
doc = _doc(title=f"D{i}", slug=f"d{i}", body="shared word here")
db_session.add(doc)
await db_session.flush()
await doc_search.index_document(db_session, doc)
_, hits = await doc_search.search(db_session, "shared", limit=3)
assert len(hits) == 3
# ── the LIKE fallback ───────────────────────────────────────────────────────
async def test_search_falls_back_to_like_without_fts5(db_session: AsyncSession):
await _seed(db_session)
await db_session.execute(text("DROP TABLE documents_fts"))
doc_search.reset_availability_cache()
engine, hits = await doc_search.search(db_session, "Synology")
assert engine == "like"
assert len(hits) == 1
assert "Synology" in hits[0]["snippet"]
async def test_indexing_is_a_no_op_without_fts5(db_session: AsyncSession):
await db_session.execute(text("DROP TABLE documents_fts"))
doc_search.reset_availability_cache()
doc = _doc(title="NAS", slug="nas", body="body")
db_session.add(doc)
await db_session.flush()
# Must not raise — writes carry on, only search degrades.
await doc_search.index_document(db_session, doc)
await doc_search.unindex_document(db_session, doc.id)
async def test_like_fallback_is_case_insensitive(db_session: AsyncSession):
await _seed(db_session)
await db_session.execute(text("DROP TABLE documents_fts"))
doc_search.reset_availability_cache()
_, hits = await doc_search.search(db_session, "synology")
assert len(hits) == 1
def test_excerpt_centres_on_the_match():
body = "x" * 200 + "needle" + "y" * 200
excerpt = doc_search._excerpt(body, "needle")
assert "needle" in excerpt
assert excerpt.startswith("…") and excerpt.endswith("…")
def test_excerpt_falls_back_to_the_head_when_there_is_no_match():
assert doc_search._excerpt("short body", "absent") == "short body"