Files
homelable/backend/tests/test_doc_backlinks.py
T
Pouzor 761a1a526c feat(docs): reach a document's history, and see what links to it
Two things the documentation section stored and never showed.

**History.** Up to 50 revisions per document have been written since the
section shipped — on every save, restore, regenerate, scaffold and the notes
migration — and nothing reached them. RegenerateDocModal went as far as
telling the user the old body "is in its history", which was true and useless:
there was no way there. The header now carries a history button; the rail lists
each version by what caused it, selecting one replaces the body in place, and
Changes turns it into a line diff against the current body. Restore snapshots
the body it replaces first, so a restore is itself undoable.

A revision's body is fetched only when it is opened — the list carries a size,
not the text, which is what RevisionSummary was shaped for.

The diff is a plain LCS over lines after the common head and tail are trimmed,
with unchanged runs collapsed to gaps: no library, and a pathological pair of
long unrelated bodies degrades to "replaced wholesale" rather than building a
quarter-million-cell matrix.

**Backlinks.** A wiki-link only says where it goes. Every document now lists
what points at it, with the line the link was written on and the label it was
written as; repeated links from one document collapse into a count.

The inversion is answered by the server, because the browser holds no body but
the open one — the list endpoint is metadata-only so the tree can badge without
downloading the space. doc_backlinks.py therefore mirrors the resolution rules
in wikilinks.ts, the way the generator's service_url mirrors serviceUrl.ts, and
its tests pin them. The inventory is loaded only when a body actually carries a
[[device:…]].

The client-side backlinkIndex goes with it. It was written, tested and never
rendered; keeping a second copy of the resolution rules on the side that cannot
see every body is only drift.

ha-relevant: no
2026-09-07 15:22:23 +02:00

255 lines
9.0 KiB
Python

"""Backlinks — the inverse of a wiki-link.
Two halves. The pure resolution rules, which must stay identical to
`frontend/src/documentation/wikilinks.ts`, and the route, which has to find a
target document that carries no links of its own and must not pay for the
inventory when nothing links to a device.
"""
from dataclasses import dataclass
from httpx import AsyncClient
from app.services import doc_backlinks
@dataclass
class FakeDoc:
id: str
slug: str = ""
title: str = ""
body: str = ""
device_id: str | None = None
node_id: str | None = None
@dataclass
class FakeDevice:
id: str
label: str | None = None
friendly_name: str | None = None
hostname: str | None = None
ip: str | None = None
# ── parsing ─────────────────────────────────────────────────────────────────
def test_a_bare_link_is_a_document_link():
link = doc_backlinks.parse_link("VLAN plan")
assert link is not None
assert (link.target, link.key, link.label) == ("doc", "VLAN plan", "VLAN plan")
def test_a_prefix_picks_the_target():
assert doc_backlinks.parse_link("device:nas-01").target == "device"
assert doc_backlinks.parse_link("NODE:abc").target == "node"
assert doc_backlinks.parse_link("doc:vlan-plan").target == "doc"
def test_an_unknown_prefix_stays_part_of_the_key():
link = doc_backlinks.parse_link("http://nas.lan")
assert link is not None
assert (link.target, link.key) == ("doc", "http://nas.lan")
def test_a_pipe_sets_the_label():
link = doc_backlinks.parse_link("device:nas-01|the big NAS")
assert link is not None
assert (link.key, link.label) == ("nas-01", "the big NAS")
def test_an_empty_link_is_not_a_link():
assert doc_backlinks.parse_link("") is None
assert doc_backlinks.parse_link("device:") is None
def test_links_are_collected_with_their_line():
body = "intro\nsee [[a]] and [[b]]\n"
found = list(doc_backlinks.iter_links(body))
assert [link.key for _, link in found] == ["a", "b"]
assert {line for line, _ in found} == {"see [[a]] and [[b]]"}
# ── resolution ──────────────────────────────────────────────────────────────
def _resolve(inner: str, docs: list[FakeDoc], devices: list[FakeDevice] | None = None):
link = doc_backlinks.parse_link(inner)
assert link is not None
return doc_backlinks.resolve(link, docs, devices or [])
def test_a_document_resolves_by_id_then_slug_then_title():
docs = [
FakeDoc(id="d1", slug="vlan-plan", title="VLAN plan"),
FakeDoc(id="d2", slug="other", title="Other"),
]
assert _resolve("d1", docs) == "d1"
assert _resolve("vlan-plan", docs) == "d1"
assert _resolve("VLAN PLAN", docs) == "d1"
def test_an_unmatched_document_link_resolves_to_nothing():
assert _resolve("nowhere", [FakeDoc(id="d1", slug="s", title="t")]) is None
def test_a_device_link_resolves_by_id_or_label():
docs = [FakeDoc(id="d1", device_id="dev1")]
devices = [FakeDevice(id="dev1", label="nas-01")]
assert _resolve("device:dev1", docs, devices) == "d1"
assert _resolve("device:NAS-01", docs, devices) == "d1"
def test_a_device_label_falls_back_the_way_the_tree_does():
assert doc_backlinks.device_label(FakeDevice(id="x", hostname="h")) == "h"
assert doc_backlinks.device_label(FakeDevice(id="x", ip="10.0.0.1")) == "10.0.0.1"
assert doc_backlinks.device_label(FakeDevice(id="x")) == "Unnamed device"
def test_a_device_with_no_document_resolves_to_nothing():
assert _resolve("device:dev1", [], [FakeDevice(id="dev1", label="nas")]) is None
def test_a_node_link_resolves_through_node_id():
docs = [FakeDoc(id="d1", node_id="n1")]
assert _resolve("node:n1", docs) == "d1"
assert _resolve("node:n2", docs) is None
# ── inversion ───────────────────────────────────────────────────────────────
def test_a_document_that_links_here_is_a_backlink():
docs = [
FakeDoc(id="d1", slug="a", title="A", body="see [[B]]"),
FakeDoc(id="d2", slug="b", title="B", body="no links"),
]
hits = doc_backlinks.backlinks_for("d2", docs, [])
assert [h.doc_id for h in hits] == ["d1"]
assert hits[0].context == "see [[B]]"
def test_a_document_does_not_link_to_itself():
docs = [FakeDoc(id="d1", slug="a", title="A", body="[[A]] again")]
assert doc_backlinks.backlinks_for("d1", docs, []) == []
def test_two_links_from_one_document_are_one_backlink_with_a_count():
docs = [
FakeDoc(id="d1", slug="a", title="A", body="[[B]] and later [[b]]"),
FakeDoc(id="d2", slug="b", title="B"),
]
hits = doc_backlinks.backlinks_for("d2", docs, [])
assert len(hits) == 1
assert hits[0].count == 2
def test_the_written_label_is_kept():
docs = [
FakeDoc(id="d1", slug="a", title="A", body="[[B|the other one]]"),
FakeDoc(id="d2", slug="b", title="B"),
]
assert doc_backlinks.backlinks_for("d2", docs, [])[0].label == "the other one"
def test_a_long_line_is_windowed_around_the_link():
filler = "word " * 60
docs = [
FakeDoc(id="d1", slug="a", title="A", body=f"{filler}[[B]]{filler}"),
FakeDoc(id="d2", slug="b", title="B"),
]
context = doc_backlinks.backlinks_for("d2", docs, [])[0].context
assert "[[B]]" in context
assert len(context) < 200
def test_has_device_link_only_fires_on_a_device_link():
assert doc_backlinks.has_device_link([FakeDoc(id="d", body="[[device:x]]")])
assert not doc_backlinks.has_device_link([FakeDoc(id="d", body="[[plain]]")])
# ── route ───────────────────────────────────────────────────────────────────
async def test_backlinks_requires_auth(client: AsyncClient):
assert (await client.get("/api/v1/documents/x/backlinks")).status_code == 401
async def test_backlinks_404_on_an_unknown_document(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/documents/nope/backlinks", headers=headers)
assert res.status_code == 404
async def test_the_route_finds_a_target_that_carries_no_links(client: AsyncClient, headers: dict):
"""The target's own body has no `[[`, so it is only ever a resolution target."""
target = (
await client.post("/api/v1/documents", json={"title": "VLAN plan"}, headers=headers)
).json()
source = (
await client.post("/api/v1/documents", json={"title": "Runbook"}, headers=headers)
).json()
await client.patch(
f"/api/v1/documents/{source['id']}",
json={"body": "Read the [[VLAN plan]] first."},
headers=headers,
)
res = await client.get(f"/api/v1/documents/{target['id']}/backlinks", headers=headers)
assert res.status_code == 200
hits = res.json()
assert [h["doc_id"] for h in hits] == [source["id"]]
assert hits[0]["title"] == "Runbook"
assert hits[0]["label"] == "VLAN plan"
assert hits[0]["context"] == "Read the [[VLAN plan]] first."
async def test_a_device_document_is_reachable_by_its_device_link(client: AsyncClient, headers: dict):
res = await client.post(
"/api/v1/scan/pending",
json={
"label": "nas-01",
"hostname": "nas-01.lan",
"ip": "192.168.1.20",
"discovery_source": "manual",
},
headers=headers,
)
assert res.status_code in (200, 201), res.text
device = res.json()
doc = (
await client.post(
"/api/v1/documents",
json={"title": "nas-01", "kind": "device", "device_id": device["id"]},
headers=headers,
)
).json()
source = (
await client.post("/api/v1/documents", json={"title": "Backups"}, headers=headers)
).json()
await client.patch(
f"/api/v1/documents/{source['id']}",
json={"body": "Runs on [[device:nas-01]]."},
headers=headers,
)
hits = (await client.get(f"/api/v1/documents/{doc['id']}/backlinks", headers=headers)).json()
assert [h["doc_id"] for h in hits] == [source["id"]]
assert hits[0]["device_id"] is None # the *source* is a page, not a device
async def test_an_unresolved_link_is_not_a_backlink(client: AsyncClient, headers: dict):
target = (
await client.post("/api/v1/documents", json={"title": "Target"}, headers=headers)
).json()
source = (
await client.post("/api/v1/documents", json={"title": "Source"}, headers=headers)
).json()
await client.patch(
f"/api/v1/documents/{source['id']}",
json={"body": "Points at [[Something else]]."},
headers=headers,
)
hits = (await client.get(f"/api/v1/documents/{target['id']}/backlinks", headers=headers)).json()
assert hits == []