Files
QMDSearch/tests/test_document_file_api.py
kplam f92eff6f65 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
2026-07-31 22:46:50 +08:00

170 lines
5.7 KiB
Python
Raw Permalink 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.
"""GET /api/v1/documents/{doc_id}/file 文件下载端点测试(TestClient + FakeQdrant,不真实联网)
覆盖分支:成功下载、文档不存在、无关联文件、文件已从磁盘删除、路径越界、免登录访问。
"""
from collections.abc import Iterator
from pathlib import Path
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:仅实现 get_l1_metadata,按 doc_id 返回固定 metadata 或 None"""
def __init__(self, meta: dict[str, str] | None = None) -> None:
self.meta = meta
async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None:
return self.meta
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[TestClient]:
"""TestClientQdrant 集合初始化空操作;upload_dir 指向临时目录
不挂 admin_headers:下载端点免登录,验证无 token 可访问。
"""
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:
yield test_client
def _install_fake(monkeypatch: pytest.MonkeyPatch, fake: FakeQdrant) -> None:
"""将 _get_qdrant 单例替换为假服务"""
monkeypatch.setattr(document_module, "_get_qdrant", lambda: fake)
def test_download_file_success(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""文档存在且有关联文件 → 200 + 文件内容正确 + content-disposition 含文件名"""
upload_dir = tmp_path / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
target = upload_dir / "doc-1_notes.txt"
content = b"hello world file content"
target.write_bytes(content)
fake = FakeQdrant(
meta={
"raw_file_path": str(target),
"original_filename": "notes.txt",
"original_size_bytes": str(len(content)),
}
)
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
assert resp.status_code == 200
assert resp.content == content
disposition = resp.headers.get("content-disposition", "")
assert "attachment" in disposition
assert "notes.txt" in disposition
def test_download_file_doc_not_found(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""文档不存在(metadata 为 None)→ 1004"""
fake = FakeQdrant(meta=None)
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/missing/file")
body = resp.json()
assert body["code"] == 1004
assert "文件" in body["message"]
def test_download_file_no_associated_file(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""文档存在但 raw_file_path 为空 → 1004"""
fake = FakeQdrant(meta={"raw_file_path": "", "original_filename": "x.txt"})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
body = resp.json()
assert body["code"] == 1004
assert "未关联文件" in body["message"]
def test_download_file_missing_on_disk(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""文件已从磁盘删除(path.is_file() False)→ 1004"""
missing = tmp_path / "uploads" / "gone.txt"
fake = FakeQdrant(meta={"raw_file_path": str(missing), "original_filename": "gone.txt"})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
body = resp.json()
assert body["code"] == 1004
assert "文件不存在" in body["message"]
def test_download_file_path_traversal_rejected(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""raw_file_path 指向 upload_dir 之外 → 1004(路径越界校验)"""
outside = tmp_path / "secret.txt"
outside.write_bytes(b"secret")
fake = FakeQdrant(meta={"raw_file_path": str(outside), "original_filename": "secret.txt"})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
body = resp.json()
assert body["code"] == 1004
assert "文件不存在" in body["message"]
def test_download_file_no_auth_required(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""免登录:不携带 Authorization 头可正常下载(端点不挂鉴权依赖)"""
upload_dir = tmp_path / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
target = upload_dir / "doc-1_open.txt"
target.write_bytes(b"open content")
fake = FakeQdrant(meta={"raw_file_path": str(target), "original_filename": "open.txt"})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
assert resp.status_code == 200
assert resp.content == b"open content"
def test_download_file_uses_original_filename_when_missing(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""metadata 缺少 original_filename 时回退到 path.name 作为下载文件名"""
upload_dir = tmp_path / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
target = upload_dir / "fallback_name.txt"
target.write_bytes(b"fb")
fake = FakeQdrant(meta={"raw_file_path": str(target)})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
assert resp.status_code == 200
assert "fallback_name.txt" in resp.headers.get("content-disposition", "")