"""文件链接集成验证 在真实内存 Qdrant + FakeOllama + 真实临时 upload_dir 环境下,验证文件上传到下载的全链路闭环: POST /documents/upload → 入库 wait_done → GET /documents/{id}(file 字段)→ GET /documents/{id}/file; 并覆盖纯文本入库无文件关联的对照路径(详情 file=None、下载端点 1004)。 """ from pathlib import Path from urllib.parse import unquote import pytest from fastapi.testclient import TestClient from qdrant_client import AsyncQdrantClient from app.api.v1 import document as document_module from app.config import Settings from app.core.chunker import Chunker from app.core.classifier import Classifier from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus from app.core.ingestion import Ingester from app.core.sparse import SparseEncoder from app.core.summarizer import Summarizer from app.main import app from app.models.knowledge import load_taxonomy from app.services.qdrant import QdrantService from tests.test_e2e_integration import ( FAKE_CATEGORY, DeterministicEmbedding, FakeOllama, ) async def _make_env() -> tuple[QdrantService, Ingester]: """构建集成环境:真实组件 + 内存 Qdrant + FakeOllama + 确定性向量""" qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:")) await qdrant.ensure_collections() ollama = FakeOllama() taxonomy = load_taxonomy() embedding = DeterministicEmbedding() ingester = Ingester( summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type] classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type] chunker=Chunker(), embedding=embedding, sparse=SparseEncoder(), qdrant=qdrant, ) return qdrant, ingester def _patch_app( monkeypatch: pytest.MonkeyPatch, manager: IngestTaskManager, qdrant: QdrantService, upload_dir: str, ) -> None: """替换模块级单例:任务管理器 / Qdrant / upload_dir,lifespan 建集合改空操作""" async def _noop_ensure_collections(self: QdrantService) -> None: return None monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) monkeypatch.setattr(document_module, "_task_manager", manager) monkeypatch.setattr(document_module, "_qdrant", qdrant) monkeypatch.setattr(document_module.settings, "upload_dir", upload_dir) class TestDocumentFileIntegration: """文件链接集成:上传闭环 + 文本入库无文件对照""" async def test_upload_full_loop( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, admin_headers: dict[str, str], ) -> None: """文件上传闭环:upload → wait_done done → 详情含 file → 下载返回原文""" qdrant, ingester = await _make_env() manager = IngestTaskManager(ingester, None, Settings()) _patch_app(monkeypatch, manager, qdrant, str(tmp_path / "uploads")) filename = "上传测试.md" original_content = "上传原文测试内容" with TestClient(app) as client: # 1. multipart 上传 .md 文件(变更类端点需 admin 认证头) resp_upload = client.post( "/api/v1/documents/upload", files={"file": (filename, original_content.encode("utf-8"), "text/markdown")}, headers=admin_headers, ) assert resp_upload.status_code == 202 body_upload = resp_upload.json() assert body_upload["code"] == 0 assert body_upload["data"]["status"] == "pending" task_id = body_upload["data"]["task_id"] assert task_id # 2. 等待入库终态:done + 结果完整 final = await manager.wait_done(task_id) assert final["status"] == IngestTaskStatus.DONE result = final["result"] document_id = result["document_id"] assert document_id assert result["category"] == FAKE_CATEGORY # 3. 文档详情:file 字段非空,含原文件名 / url / size resp_detail = client.get(f"/api/v1/documents/{document_id}") body_detail = resp_detail.json() assert body_detail["code"] == 0 file_info = body_detail["data"]["file"] assert file_info is not None assert filename in file_info["filename"] assert file_info["url"] == f"/api/v1/documents/{document_id}/file" assert file_info["size_bytes"] > 0 # 4. 下载:200 + 响应体含上传原文 + Content-Disposition 含原文件名(中文按 RFC 5987 百分号编码,需 unquote) resp_file = client.get(f"/api/v1/documents/{document_id}/file") assert resp_file.status_code == 200 assert original_content in resp_file.content.decode("utf-8") disposition = resp_file.headers.get("content-disposition", "") assert "attachment" in disposition assert filename in unquote(disposition) async def test_text_ingest_no_file( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, admin_headers: dict[str, str], ) -> None: """纯文本入库(无 metadata):详情 file=None,下载端点返回 1004""" qdrant, ingester = await _make_env() manager = IngestTaskManager(ingester, None, Settings()) _patch_app(monkeypatch, manager, qdrant, str(tmp_path / "uploads")) with TestClient(app) as client: # 1. 纯文本入库(无 metadata) resp_post = client.post( "/api/v1/documents", json={"text": "纯文本入库无附件的对照内容", "title": "无文件文档"}, headers=admin_headers, ) assert resp_post.status_code == 202 body_post = resp_post.json() assert body_post["code"] == 0 task_id = body_post["data"]["task_id"] # 2. 等待终态 done final = await manager.wait_done(task_id) assert final["status"] == IngestTaskStatus.DONE document_id = final["result"]["document_id"] # 3. 详情:file 为 None resp_detail = client.get(f"/api/v1/documents/{document_id}") body_detail = resp_detail.json() assert body_detail["code"] == 0 assert body_detail["data"]["file"] is None # 4. 下载端点:文档未关联文件 → 1004 resp_file = client.get(f"/api/v1/documents/{document_id}/file") body_file = resp_file.json() assert body_file["code"] == 1004