bdd30f0a88
- deps.py: _create_redis_client 增加同步 ping 校验,Redis 不可达时正确降级为内存模式 - main.py: lifespan 复用 deps 的 UserStore 单例,避免 admin 与 API 请求实例不一致 - 前端: 强制改密弹窗(must_change_password 用户)、兼容新旧登录返回格式、markPasswordChanged - 新增 NAS SSH 部署脚本与批处理测试;gitignore 前端构建产物
336 lines
10 KiB
Python
336 lines
10 KiB
Python
"""POST /documents/upload-batch、POST /documents/{id}/reingest、GET /documents/tasks 端点测试
|
||
|
||
TestClient + FakeManager + FakeQdrant,不真实联网。
|
||
"""
|
||
|
||
from collections.abc import Iterator
|
||
from pathlib import Path
|
||
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.models.document import DocumentInput
|
||
from app.services.qdrant import QdrantService
|
||
|
||
|
||
class FakeManager:
|
||
"""假入库任务管理器:支持 submit/get/list_tasks"""
|
||
|
||
def __init__(self, task_id: str = "task-1") -> None:
|
||
self.task_id = task_id
|
||
self._counter = 0
|
||
self.submitted: list[DocumentInput] = []
|
||
self._tasks: list[dict[str, Any]] = []
|
||
|
||
async def submit(self, doc: DocumentInput) -> str:
|
||
self.submitted.append(doc)
|
||
self._counter += 1
|
||
tid = f"{self.task_id}-{self._counter}"
|
||
self._tasks.append(
|
||
{
|
||
"task_id": tid,
|
||
"status": "pending",
|
||
"filename": doc.metadata.get("original_filename") or doc.title,
|
||
"created_at": f"2026-01-0{self._counter}T00:00:00+00:00",
|
||
"updated_at": f"2026-01-0{self._counter}T00:00:00+00:00",
|
||
"doc_id": None,
|
||
}
|
||
)
|
||
return tid
|
||
|
||
async def get(self, task_id: str) -> dict[str, Any] | None:
|
||
return None
|
||
|
||
async def list_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
|
||
return list(self._tasks[:limit])
|
||
|
||
|
||
class FakeQdrant:
|
||
"""假 Qdrant 服务:可控的 get_l1_metadata/delete_by_doc_id"""
|
||
|
||
def __init__(self, meta: dict[str, str] | None = None) -> None:
|
||
self.meta = meta
|
||
self.deleted: list[str] = []
|
||
|
||
async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None:
|
||
return self.meta
|
||
|
||
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
|
||
self.deleted.append(doc_id)
|
||
return {"doc_l1": 1}
|
||
|
||
|
||
@pytest.fixture
|
||
def client(
|
||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, admin_headers: dict[str, str]
|
||
) -> Iterator[TestClient]:
|
||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||
return None
|
||
|
||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||
monkeypatch.setattr(document_module.settings, "upload_dir", str(tmp_path / "uploads"))
|
||
with TestClient(app) as test_client:
|
||
test_client.headers.update(admin_headers)
|
||
yield test_client
|
||
|
||
|
||
def _inject_manager(monkeypatch: pytest.MonkeyPatch, manager: FakeManager) -> None:
|
||
monkeypatch.setattr(document_module, "_get_task_manager", lambda: manager)
|
||
|
||
|
||
def _inject_qdrant(monkeypatch: pytest.MonkeyPatch, qdrant: FakeQdrant) -> None:
|
||
monkeypatch.setattr(document_module, "_get_qdrant", lambda: qdrant)
|
||
|
||
|
||
# ----------------------- upload-batch -----------------------
|
||
|
||
|
||
def test_batch_upload_multiple_files_success(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""多文件全部成功:3 文件 → 3 tasks,failed 为空"""
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
|
||
resp = client.post(
|
||
"/api/v1/documents/upload-batch",
|
||
files=[
|
||
("files", ("a.txt", b"content a", "text/plain")),
|
||
("files", ("b.md", b"# B", "text/markdown")),
|
||
("files", ("c.txt", b"content c", "text/plain")),
|
||
],
|
||
)
|
||
|
||
assert resp.status_code == 202
|
||
data = resp.json()["data"]
|
||
assert len(data["tasks"]) == 3
|
||
assert data["failed"] == []
|
||
assert len(manager.submitted) == 3
|
||
filenames = [t["filename"] for t in data["tasks"]]
|
||
assert filenames == ["a.txt", "b.md", "c.txt"]
|
||
|
||
|
||
def test_batch_upload_partial_failure(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""部分失败:1 个不支持扩展名进 failed,其他成功"""
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
|
||
resp = client.post(
|
||
"/api/v1/documents/upload-batch",
|
||
files=[
|
||
("files", ("ok.txt", b"good", "text/plain")),
|
||
("files", ("bad.xlsx", b"binary", "application/octet-stream")),
|
||
],
|
||
)
|
||
|
||
assert resp.status_code == 202
|
||
data = resp.json()["data"]
|
||
assert len(data["tasks"]) == 1
|
||
assert data["tasks"][0]["filename"] == "ok.txt"
|
||
assert len(data["failed"]) == 1
|
||
assert data["failed"][0]["filename"] == "bad.xlsx"
|
||
assert "不支持" in data["failed"][0]["error"]
|
||
assert len(manager.submitted) == 1
|
||
|
||
|
||
def test_batch_upload_all_fail(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""全部失败:tasks 空,failed 2 条,未提交任何任务"""
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
|
||
resp = client.post(
|
||
"/api/v1/documents/upload-batch",
|
||
files=[
|
||
("files", ("a.exe", b"x", "application/octet-stream")),
|
||
("files", ("b.bin", b"y", "application/octet-stream")),
|
||
],
|
||
)
|
||
|
||
assert resp.status_code == 202
|
||
data = resp.json()["data"]
|
||
assert data["tasks"] == []
|
||
assert len(data["failed"]) == 2
|
||
assert len(manager.submitted) == 0
|
||
|
||
|
||
def test_batch_upload_empty_list_returns_1001(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""空文件列表:1001"""
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
|
||
resp = client.post("/api/v1/documents/upload-batch", files=[])
|
||
|
||
body = resp.json()
|
||
assert body["code"] == 1001
|
||
assert manager.submitted == []
|
||
|
||
|
||
# ----------------------- reingest -----------------------
|
||
|
||
|
||
def test_reingest_success(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||
) -> None:
|
||
"""有原始文件:202 + task_id,删旧数据后提交新任务"""
|
||
upload_dir = tmp_path / "uploads"
|
||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||
raw = upload_dir / "raw_report.txt"
|
||
raw.write_bytes(b"original content")
|
||
meta = {
|
||
"raw_file_path": str(raw),
|
||
"original_filename": "raw_report.txt",
|
||
}
|
||
qdrant = FakeQdrant(meta=meta)
|
||
_inject_qdrant(monkeypatch, qdrant)
|
||
manager = FakeManager(task_id="reingest-task")
|
||
_inject_manager(monkeypatch, manager)
|
||
|
||
resp = client.post("/api/v1/documents/some-doc/reingest")
|
||
|
||
assert resp.status_code == 202
|
||
data = resp.json()["data"]
|
||
assert data["task_id"] == "reingest-task-1"
|
||
assert data["status"] == "pending"
|
||
# 旧数据已删
|
||
assert qdrant.deleted == ["some-doc"]
|
||
# 新任务已提交,文本来自原文件
|
||
assert len(manager.submitted) == 1
|
||
assert manager.submitted[0].text == "original content"
|
||
assert manager.submitted[0].title == "raw_report" # 文件名 stem
|
||
|
||
|
||
def test_reingest_no_raw_file_returns_1001(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""无原始文件(meta 无 raw_file_path):1001"""
|
||
qdrant = FakeQdrant(meta={"some": "metadata"})
|
||
_inject_qdrant(monkeypatch, qdrant)
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
|
||
resp = client.post("/api/v1/documents/doc1/reingest")
|
||
|
||
body = resp.json()
|
||
assert body["code"] == 1001
|
||
assert "无原始文件" in body["message"]
|
||
assert len(manager.submitted) == 0
|
||
|
||
|
||
def test_reingest_missing_file_returns_1004(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""文件不存在:1004"""
|
||
qdrant = FakeQdrant(
|
||
meta={"raw_file_path": "/nonexistent/path.txt", "original_filename": "path.txt"}
|
||
)
|
||
_inject_qdrant(monkeypatch, qdrant)
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
|
||
resp = client.post("/api/v1/documents/doc1/reingest")
|
||
|
||
body = resp.json()
|
||
assert body["code"] == 1004
|
||
assert len(manager.submitted) == 0
|
||
|
||
|
||
def test_reingest_doc_not_found_returns_1004(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""文档不存在(meta 为 None):1004"""
|
||
qdrant = FakeQdrant(meta=None)
|
||
_inject_qdrant(monkeypatch, qdrant)
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
|
||
resp = client.post("/api/v1/documents/nope/reingest")
|
||
|
||
body = resp.json()
|
||
assert body["code"] == 1004
|
||
assert len(manager.submitted) == 0
|
||
|
||
|
||
# ----------------------- tasks 列表 -----------------------
|
||
|
||
|
||
def test_list_ingest_tasks_returns_items(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""任务列表:返回近期任务,含 items 与 total"""
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
manager._tasks = [
|
||
{
|
||
"task_id": "t1",
|
||
"status": "done",
|
||
"filename": "a.txt",
|
||
"created_at": "2026-01-01T00:00:00+00:00",
|
||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||
"doc_id": "d1",
|
||
},
|
||
{
|
||
"task_id": "t2",
|
||
"status": "failed",
|
||
"filename": "b.txt",
|
||
"created_at": "2026-01-02T00:00:00+00:00",
|
||
"updated_at": "2026-01-02T00:00:00+00:00",
|
||
"doc_id": None,
|
||
},
|
||
]
|
||
|
||
resp = client.get("/api/v1/documents/tasks")
|
||
|
||
assert resp.status_code == 200
|
||
data = resp.json()["data"]
|
||
assert data["total"] == 2
|
||
assert len(data["items"]) == 2
|
||
assert data["items"][0]["task_id"] == "t1"
|
||
assert data["items"][0]["doc_id"] == "d1"
|
||
|
||
|
||
def test_list_ingest_tasks_limit(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""limit 截断生效"""
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
manager._tasks = [
|
||
{
|
||
"task_id": f"t{i}",
|
||
"status": "done",
|
||
"filename": "f",
|
||
"created_at": "",
|
||
"updated_at": "",
|
||
"doc_id": None,
|
||
}
|
||
for i in range(10)
|
||
]
|
||
|
||
resp = client.get("/api/v1/documents/tasks?limit=5")
|
||
|
||
data = resp.json()["data"]
|
||
assert data["total"] == 5
|
||
assert len(data["items"]) == 5
|
||
|
||
|
||
def test_list_ingest_tasks_requires_auth(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""无 token:1005 未认证"""
|
||
manager = FakeManager()
|
||
_inject_manager(monkeypatch, manager)
|
||
|
||
resp = client.get("/api/v1/documents/tasks", headers={"Authorization": ""})
|
||
|
||
body = resp.json()
|
||
assert body["code"] == 1005
|