Files
QMDSearch/tests/test_document_admin_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

172 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 测试(QdrantService 为 mock,不真实联网)"""
from collections.abc import Iterator
from typing import Any
import pytest
from fastapi.testclient import TestClient
from app.api.v1 import document as document_module
from app.main import app
from app.services.qdrant import QdrantService
class FakeQdrant:
"""假 QdrantService:返回固定数据或抛出固定异常"""
def __init__(
self,
scroll_result: tuple[list[dict[str, Any]], str | None] | None = None,
detail: dict[str, Any] | None = None,
deleted: dict[str, int] | None = None,
error: Exception | None = None,
) -> None:
self.scroll_result = scroll_result if scroll_result is not None else ([], None)
self.detail = detail
self.deleted = deleted if deleted is not None else {}
self.error = error
async def scroll_l1(self, limit: int = 20, offset: str | None = None) -> tuple[list[dict[str, Any]], str | None]:
if self.error is not None:
raise self.error
return self.scroll_result
async def get_doc_detail(self, doc_id: str) -> dict[str, Any] | None:
if self.error is not None:
raise self.error
return self.detail
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
if self.error is not None:
raise self.error
return self.deleted
@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
def _install_fake(monkeypatch: pytest.MonkeyPatch, fake: FakeQdrant) -> None:
"""将 _get_qdrant 单例替换为假服务"""
monkeypatch.setattr(document_module, "_get_qdrant", lambda: fake)
def _make_item(doc_id: str = "doc-1") -> dict[str, Any]:
"""构造固定的 L1 列表项"""
return {
"doc_id": doc_id,
"title": "标题",
"category": "技术文档",
"tags": ["API"],
"summary": "一句话总结",
}
def test_list_documents_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""列表:正常返回 items 与 next_offset"""
fake = FakeQdrant(scroll_result=([_make_item()], "cursor-2"))
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents", params={"limit": 10, "offset": "cursor-1"})
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 0
data = body["data"]
assert data["items"] == [_make_item()]
assert data["next_offset"] == "cursor-2"
def test_list_documents_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""列表:空库返回 items=[]、next_offset=None"""
fake = FakeQdrant(scroll_result=([], None))
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents")
body = resp.json()
assert body["code"] == 0
assert body["data"] == {"items": [], "next_offset": None}
def test_get_document_detail(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""详情:存在返回 l1/l2_nodes/l3_nodes/chunks_count"""
detail = {
"l1": {"doc_id": "doc-1", "title": "标题", "text": "一句话总结"},
"l2_nodes": [{"doc_id": "doc-1", "section_path": "1", "text": "大纲节点"}],
"l3_nodes": [],
"chunks_count": 3,
}
fake = FakeQdrant(detail=detail)
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1")
body = resp.json()
assert body["code"] == 0
assert body["data"] == detail
def test_get_document_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""详情:不存在返回 code=1004"""
fake = FakeQdrant(detail=None)
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/missing")
body = resp.json()
assert body["code"] == 1004
assert body["message"] == "文档不存在"
assert body["data"] is None
def test_delete_document_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""删除:返回各集合删除数与 deleted_total"""
deleted = {"doc_l1": 1, "doc_l2": 2, "doc_l3": 4, "chunks": 6}
fake = FakeQdrant(deleted=deleted)
_install_fake(monkeypatch, fake)
resp = client.delete("/api/v1/documents/doc-1")
body = resp.json()
assert body["code"] == 0
data = body["data"]
assert data["doc_id"] == "doc-1"
assert data["deleted"] == deleted
assert data["deleted_total"] == 13
def test_delete_document_not_found_idempotent(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""删除:不存在 doc_id 仍 code=0,各集合删除数全 0、deleted_total=0(幂等)"""
deleted = {"doc_l1": 0, "doc_l2": 0, "doc_l3": 0, "chunks": 0}
fake = FakeQdrant(deleted=deleted)
_install_fake(monkeypatch, fake)
resp = client.delete("/api/v1/documents/missing")
body = resp.json()
assert body["code"] == 0
data = body["data"]
assert data["deleted"] == deleted
assert data["deleted_total"] == 0
def test_list_documents_service_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""列表:scroll 抛错返回 code=2000"""
fake = FakeQdrant(error=RuntimeError("boom"))
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents")
body = resp.json()
assert body["code"] == 2000
assert body["data"] is None