feat(knowledge-base): KnowledgeBase feature — models, CRUD API, RBAC
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>
This commit is contained in:
147
tests/test_knowledge_base.py
Normal file
147
tests/test_knowledge_base.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user