"""Knowledge Base API tests — CRUD, hierarchy, uniqueness, tree, links, RBAC.""" from tests.conftest import auth_header def _create_kb(client, token, title="Infra Runbook", description="ops notes"): r = client.post( "/knowledge-bases", json={"title": title, "description": description}, headers=auth_header(token), ) assert r.status_code == 201, r.text return r.json() class TestKnowledgeBaseCRUD: def test_create_generates_code(self, client, seed): kb = _create_kb(client, seed["admin_token"], title="Infra Runbook") assert kb["title"] == "Infra Runbook" assert kb["knowledge_base_code"] # auto-generated, non-empty assert kb["created_by"] == seed["admin_user"].id def test_create_requires_permission(self, client, seed): # dev role has no knowledge-base.create r = client.post( "/knowledge-bases", json={"title": "Nope"}, headers=auth_header(seed["dev_token"]), ) assert r.status_code == 403 def test_get_by_id_and_code(self, client, seed): kb = _create_kb(client, seed["admin_token"]) by_id = client.get(f"/knowledge-bases/{kb['id']}", headers=auth_header(seed["admin_token"])) by_code = client.get(f"/knowledge-bases/{kb['knowledge_base_code']}", headers=auth_header(seed["admin_token"])) assert by_id.status_code == 200 and by_code.status_code == 200 assert by_id.json()["id"] == by_code.json()["id"] == kb["id"] def test_update_and_list(self, client, seed): kb = _create_kb(client, seed["admin_token"]) r = client.patch( f"/knowledge-bases/{kb['id']}", json={"description": "updated"}, headers=auth_header(seed["admin_token"]), ) assert r.status_code == 200 and r.json()["description"] == "updated" lst = client.get("/knowledge-bases", headers=auth_header(seed["admin_token"])) assert lst.status_code == 200 and any(k["id"] == kb["id"] for k in lst.json()) def test_delete_cascades(self, client, seed): token = seed["admin_token"] kb = _create_kb(client, token) topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Net"}, headers=auth_header(token)).json() client.post("/knowledge-facts", json={"topic_id": topic["id"], "fact": "x"}, headers=auth_header(token)) r = client.delete(f"/knowledge-bases/{kb['id']}", headers=auth_header(token)) assert r.status_code == 204 assert client.get(f"/knowledge-bases/{kb['id']}", headers=auth_header(token)).status_code == 404 assert client.get(f"/knowledge-topics/{topic['id']}", headers=auth_header(token)).status_code == 404 class TestHierarchy: def test_topic_unique_per_kb(self, client, seed): token = seed["admin_token"] kb = _create_kb(client, token) r1 = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Routing"}, headers=auth_header(token)) r2 = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Routing"}, headers=auth_header(token)) assert r1.status_code == 201 and r2.status_code == 400 def test_category_triple_unique_and_nesting(self, client, seed): token = seed["admin_token"] kb = _create_kb(client, token) topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json() # top-level category c1 = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "DNS"}, headers=auth_header(token)) assert c1.status_code == 201 # duplicate top-level (parent NULL) rejected at app level dup = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "DNS"}, headers=auth_header(token)) assert dup.status_code == 400 # nested category with same name under different parent is allowed child = client.post( "/knowledge-categories", json={"topic_id": topic["id"], "name": "DNS", "parent": c1.json()["id"]}, headers=auth_header(token), ) assert child.status_code == 201 def test_no_cycle_on_reparent(self, client, seed): token = seed["admin_token"] kb = _create_kb(client, token) topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json() a = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "A"}, headers=auth_header(token)).json() b = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "B", "parent": a["id"]}, headers=auth_header(token)).json() # try to move A under its descendant B -> rejected r = client.patch(f"/knowledge-categories/{a['id']}", json={"parent": b["id"]}, headers=auth_header(token)) assert r.status_code == 400 def test_tree_shape(self, client, seed): token = seed["admin_token"] kb = _create_kb(client, token) topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json() cat = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "C"}, headers=auth_header(token)).json() # fact directly on topic client.post("/knowledge-facts", json={"topic_id": topic["id"], "fact": "topic-fact"}, headers=auth_header(token)) # fact under category client.post("/knowledge-facts", json={"topic_id": topic["id"], "category_id": cat["id"], "fact": "cat-fact"}, headers=auth_header(token)) tree = client.get(f"/knowledge-bases/{kb['id']}/tree", headers=auth_header(token)).json() assert len(tree["topics"]) == 1 t = tree["topics"][0] assert [f["fact"] for f in t["facts"]] == ["topic-fact"] assert len(t["categories"]) == 1 assert [f["fact"] for f in t["categories"][0]["facts"]] == ["cat-fact"] class TestProjectLinks: def test_link_unlink(self, client, seed): token = seed["admin_token"] kb = _create_kb(client, token) # link by code r = client.post( "/projects/TPRJ/knowledge-bases", json={"knowledge_base": kb["knowledge_base_code"]}, headers=auth_header(token), ) assert r.status_code == 201 linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json() assert any(k["id"] == kb["id"] for k in linked) # filter list by project filtered = client.get(f"/knowledge-bases?project=TPRJ", headers=auth_header(token)).json() assert any(k["id"] == kb["id"] for k in filtered) # unlink r = client.delete(f"/projects/TPRJ/knowledge-bases/{kb['id']}", headers=auth_header(token)) assert r.status_code == 204 linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json() assert not any(k["id"] == kb["id"] for k in linked) def test_link_is_idempotent(self, client, seed): token = seed["admin_token"] kb = _create_kb(client, token) for _ in range(2): r = client.post( "/projects/TPRJ/knowledge-bases", json={"knowledge_base": str(kb["id"])}, headers=auth_header(token), ) assert r.status_code == 201 linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json() assert sum(1 for k in linked if k["id"] == kb["id"]) == 1