New entities mirroring the Project shape: - knowledge_bases (human code, title, description, created_by, timestamps) - knowledge_topics (UNIQUE(topic, knowledge_base_id)) - knowledge_categories (self-referential parent; UNIQUE(topic_id, parent, name), with an app-level check for the NULL-parent case MySQL can't enforce) - knowledge_facts (category_id NULL → fact lives directly on the topic) - project_knowledge_bases (M2M project ↔ knowledge base) Adds full CRUD for KB/topic/category/fact, a nested /tree aggregate, project link/unlink/list, KB-code generation (same algorithm as project codes), and category cycle-prevention. Four global permissions (knowledge-base.create/read/update/delete) seeded in init_bootstrap and granted to admin/mgr/dev/general-agent/guest as appropriate. New tables auto-create via Base.metadata.create_all; router wired in main.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
7.4 KiB
Python
148 lines
7.4 KiB
Python
"""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
|