feat: add document file download API and related features
1. add `/api/v1/documents/{doc_id}/file` endpoint for downloading original document files
2. add file field to document detail API response based on metadata
3. add comprehensive tests for file download API and metadata integration
4. update API documentation in CLAUDE.md
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""文件链接集成验证
|
||||
|
||||
在真实内存 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
|
||||
Reference in New Issue
Block a user