Files
QMDSearch/tests/test_knowledge_stats_api.py
T
kplam 51dc8dc4f6 Initial commit: QMDSearch 分层信息检索服务
- FastAPI + Qdrant + Redis + Ollama 技术栈
- L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合)
- 文档三级总结与 2.5 级回退
- query 解析路由与分类
- /admin 管理页面
2026-07-29 21:24:40 +08:00

176 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识统计 API 测试(GET /api/v1/knowledge/stats
两层覆盖:
1. TestClient + monkeypatch 注入假 Qdrant 服务:响应结构与异常路径
2. 真实内存 Qdrantlocation=":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]:
"""TestClientlifespan 中的 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 篇 L12 篇同类目 + 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 个 chunkdoc-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