"""知识统计 API 测试(GET /api/v1/knowledge/stats) 两层覆盖: 1. TestClient + monkeypatch 注入假 Qdrant 服务:响应结构与异常路径 2. 真实内存 Qdrant(location=":memory:")集成:stats 数值与实际写入一致 """ from collections.abc import Iterator from typing import Any import pytest from fastapi.testclient import TestClient from qdrant_client import AsyncQdrantClient from app.api.v1 import knowledge as knowledge_module from app.config import settings from app.main import app from app.services.qdrant import ( COLLECTION_CHUNKS, COLLECTION_L1, COLLECTION_L2, COLLECTION_L3, QdrantService, ) DIM = settings.embedding_dimension def _dense(seed: float) -> list[float]: """构造确定性 dense 向量""" vec = [0.0] * DIM vec[0] = seed vec[1] = 1.0 - seed vec[2] = seed * 0.5 vec[3] = 0.1 return vec @pytest.fixture def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: """TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作""" async def _noop_ensure_collections(self: QdrantService) -> None: return None monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) with TestClient(app) as test_client: yield test_client class _FakeQdrantService: """假 Qdrant 服务:固定集合计数,scroll_l1 分 2 页返回类目""" COUNTS = {COLLECTION_L1: 3, COLLECTION_L2: 6, COLLECTION_L3: 4, COLLECTION_CHUNKS: 9} PAGES = [ ([{"category": "技术文档"}, {"category": "技术文档"}], "cursor-1"), ([{"category": "uncategorized"}], None), ] async def count(self, collection: str) -> int: return self.COUNTS[collection] async def scroll_l1( self, limit: int = 100, offset: str | None = None ) -> tuple[list[dict[str, Any]], str | None]: assert limit == 100 return self.PAGES[0] if offset is None else self.PAGES[1] class _FailingQdrantService: """count 即抛异常的假服务,用于验证错误包装""" async def count(self, collection: str) -> int: raise RuntimeError("qdrant 连接失败") async def scroll_l1( self, limit: int = 100, offset: str | None = None ) -> tuple[list[dict[str, Any]], str | None]: raise RuntimeError("qdrant 连接失败") def test_stats_ok(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """正常返回:四字段结构完整,数值来自假服务,类目跨页聚合""" monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FakeQdrantService()) resp = client.get("/api/v1/knowledge/stats") assert resp.status_code == 200 body = resp.json() assert body["code"] == 0 data = body["data"] assert set(data.keys()) == {"collections", "categories", "uncategorized_count", "documents_total"} assert data["collections"] == {"doc_l1": 3, "doc_l2": 6, "doc_l3": 4, "chunks": 9} assert data["categories"] == {"技术文档": 2, "uncategorized": 1} assert data["uncategorized_count"] == 1 assert data["documents_total"] == 3 def test_stats_qdrant_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """Qdrant 异常 → code 2000 统一错误响应""" monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FailingQdrantService()) resp = client.get("/api/v1/knowledge/stats") assert resp.status_code == 200 body = resp.json() assert body["code"] == 2000 assert body["data"] is None assert "获取知识库统计失败" in body["message"] @pytest.fixture async def memory_service() -> QdrantService: """真实内存 Qdrant 服务,集合并写入 3 篇文档与若干 chunk""" service = QdrantService(client=AsyncQdrantClient(location=":memory:")) await service.ensure_collections() # 3 篇 L1:2 篇同类目 + 1 篇 uncategorized await service.upsert_l1( doc_id="doc-a", title="标题A", summary="总结A", category="技术文档", tags=["api"], dense_vector=_dense(0.9), ) await service.upsert_l1( doc_id="doc-b", title="标题B", summary="总结B", category="技术文档", tags=["sdk"], dense_vector=_dense(0.8), ) await service.upsert_l1( doc_id="doc-c", title="标题C", summary="总结C", category="uncategorized", tags=[], dense_vector=_dense(0.7), ) # 5 个 chunk(doc-a 3 个、doc-c 2 个) chunks = [ { "doc_id": doc_id, "chunk_index": i, "text": f"chunk{i}-{doc_id}", "section_path": "1", "title": f"标题-{doc_id}", "category": category, "tags": [], "dense_vector": _dense(0.5 + i * 0.1), } for doc_id, category, n in (("doc-a", "技术文档", 3), ("doc-c", "uncategorized", 2)) for i in range(n) ] await service.upsert_chunks(chunks) return service async def test_stats_with_real_memory_qdrant( memory_service: QdrantService, monkeypatch: pytest.MonkeyPatch ) -> None: """stats 数值与真实内存 Qdrant 写入一致(直接调路由处理函数)""" monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: memory_service) body = await knowledge_module.knowledge_stats() assert body["code"] == 0 data = body["data"] assert data["collections"] == {"doc_l1": 3, "doc_l2": 0, "doc_l3": 0, "chunks": 5} assert data["documents_total"] == 3 assert data["categories"] == {"技术文档": 2, "uncategorized": 1} assert data["uncategorized_count"] == 1