"""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"]) == ("原名称", "原描述")