181 lines
7.9 KiB
Python
181 lines
7.9 KiB
Python
"""Ordinary knowledge-base editing and remote-deletion reconciliation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI
|
|
|
|
from deerflow.integrations.weknora.client import WeKnoraClient
|
|
from deerflow.persistence.llmwiki import MemoryLlmWikiStore
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def knowledge_api(monkeypatch: pytest.MonkeyPatch):
|
|
from app.gateway.routers import llmwiki
|
|
|
|
store = MemoryLlmWikiStore()
|
|
row = await store.create_mapping(weknora_id="remote-one", owner_user_id="owner", name="原名称", description="原描述", kb_type="document")
|
|
await store.request_publish(row["id"], "owner", is_admin=False)
|
|
state = SimpleNamespace(
|
|
store=store,
|
|
mapping_id=row["id"],
|
|
remote={"remote-one": {"id": "remote-one", "name": "原名称", "description": "原描述", "knowledge_count": 3}},
|
|
user_id="owner",
|
|
is_admin=False,
|
|
upstream_status=200,
|
|
writes=[],
|
|
)
|
|
|
|
async def upstream(request: httpx.Request) -> httpx.Response:
|
|
if state.upstream_status != 200:
|
|
return httpx.Response(state.upstream_status, json={"message": "知识服务暂不可用"})
|
|
if request.url.path == "/api/v1/knowledge-bases":
|
|
assert request.method == "GET"
|
|
return httpx.Response(200, json={"data": list(state.remote.values())})
|
|
remote_id = request.url.path.rsplit("/", 1)[-1]
|
|
if remote_id not in state.remote:
|
|
return httpx.Response(404)
|
|
if request.method == "PUT":
|
|
changes = json.loads(request.content)
|
|
state.writes.append(changes)
|
|
state.remote[remote_id].update(changes)
|
|
else:
|
|
assert request.method == "GET"
|
|
return httpx.Response(200, json={"data": state.remote[remote_id]})
|
|
|
|
async def actor(_: object) -> tuple[str, bool]:
|
|
return state.user_id, state.is_admin
|
|
|
|
app = FastAPI()
|
|
app.include_router(llmwiki.router)
|
|
monkeypatch.setattr(llmwiki, "_actor", actor)
|
|
monkeypatch.setattr(llmwiki, "_store", lambda _: store)
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(upstream)) as remote_client:
|
|
client = WeKnoraClient(base_url="http://weknora.test", http_client=remote_client)
|
|
monkeypatch.setattr(llmwiki, "_client_or_503", lambda: client)
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://gateway.test") as api:
|
|
state.api = api
|
|
yield state
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("scope", ["personal", "public", "all"])
|
|
@pytest.mark.parametrize("is_admin", [False, True])
|
|
async def test_lists_hide_remote_deleted_bases_without_deleting_mappings(knowledge_api, scope: str, is_admin: bool) -> None:
|
|
state = knowledge_api
|
|
state.is_admin = is_admin
|
|
live = await state.store.create_mapping(weknora_id="remote-live", owner_user_id="owner", name="保留知识库", description="", kb_type="document")
|
|
await state.store.request_publish(live["id"], "owner", is_admin=False)
|
|
state.remote["remote-live"] = {"id": "remote-live", "name": "保留知识库"}
|
|
deleted = state.remote.pop("remote-one")
|
|
|
|
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": scope})
|
|
|
|
assert response.status_code == 200
|
|
assert [item["id"] for item in response.json()["knowledge_bases"]] == [live["id"]]
|
|
assert await state.store.get_by_weknora_id("remote-one") is not None
|
|
|
|
state.remote.clear()
|
|
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": scope})
|
|
assert response.json()["knowledge_bases"] == []
|
|
|
|
state.remote["remote-one"] = deleted
|
|
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": scope})
|
|
assert [item["id"] for item in response.json()["knowledge_bases"]] == [state.mapping_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status", [502, 504])
|
|
async def test_list_upstream_failure_is_not_treated_as_remote_deletion(knowledge_api, status: int) -> None:
|
|
state = knowledge_api
|
|
state.upstream_status = status
|
|
|
|
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": "personal"})
|
|
|
|
assert response.status_code >= 500
|
|
assert await state.store.get_by_weknora_id("remote-one") is not None
|
|
state.upstream_status = 200
|
|
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": "personal"})
|
|
assert [item["id"] for item in response.json()["knowledge_bases"]] == [state.mapping_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("changes", "expected_name", "expected_description"),
|
|
[
|
|
({"name": " 新名称 ", "description": "新描述"}, "新名称", "新描述"),
|
|
({"name": "仅修改名称"}, "仅修改名称", "原描述"),
|
|
({"description": "仅修改描述"}, "原名称", "仅修改描述"),
|
|
({"name": "清空描述", "description": ""}, "清空描述", ""),
|
|
],
|
|
)
|
|
async def test_edit_synchronizes_remote_and_local_metadata(knowledge_api, changes, expected_name: str, expected_description: str) -> None:
|
|
state = knowledge_api
|
|
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json=changes)
|
|
|
|
assert response.status_code == 200
|
|
item = response.json()
|
|
assert (item["name"], item["description"]) == (expected_name, expected_description)
|
|
assert item["remote_status"] == "available"
|
|
assert item["knowledge_count"] == 3
|
|
assert item["publication_status"] == "published"
|
|
local = await state.store.get_by_weknora_id("remote-one")
|
|
assert (local["name"], local["description"]) == (expected_name, expected_description)
|
|
assert state.writes == [{**changes, "name": expected_name}]
|
|
listing = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": "personal"})
|
|
assert listing.json()["knowledge_bases"][0]["name"] == expected_name
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("name", ["", " \t ", "x" * 256, "对话沉淀", " 对话沉淀 "])
|
|
async def test_edit_rejects_invalid_or_reserved_names(knowledge_api, name: str) -> None:
|
|
state = knowledge_api
|
|
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json={"name": name})
|
|
|
|
assert response.status_code in {409, 422}
|
|
assert state.writes == []
|
|
assert (await state.store.get_by_weknora_id("remote-one"))["name"] == "原名称"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("is_admin", [False, True])
|
|
async def test_only_owner_or_admin_can_edit_a_public_base(knowledge_api, is_admin: bool) -> None:
|
|
state = knowledge_api
|
|
state.user_id = "other-user"
|
|
state.is_admin = is_admin
|
|
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json={"name": "新名称"})
|
|
|
|
assert response.status_code == (200 if is_admin else 404)
|
|
assert len(state.writes) == (1 if is_admin else 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_cannot_edit_system_conversation_deposit(knowledge_api) -> None:
|
|
state = knowledge_api
|
|
state.is_admin = True
|
|
await state.store.adopt_conversation_deposit_mapping(state.mapping_id, name="对话沉淀", description="系统知识库")
|
|
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json={"name": "新名称"})
|
|
|
|
assert response.status_code == 403
|
|
assert state.writes == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status", [404, 502, 504])
|
|
async def test_failed_remote_edit_leaves_local_metadata_unchanged(knowledge_api, status: int) -> None:
|
|
state = knowledge_api
|
|
state.upstream_status = status
|
|
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json={"name": "新名称", "description": "新描述"})
|
|
|
|
if status == 404:
|
|
assert response.status_code == 404
|
|
else:
|
|
assert response.status_code >= 500
|
|
local = await state.store.get_by_weknora_id("remote-one")
|
|
assert (local["name"], local["description"]) == ("原名称", "原描述")
|