"""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