f92eff6f65
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
170 lines
5.7 KiB
Python
170 lines
5.7 KiB
Python
"""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]:
|
||
"""TestClient,Qdrant 集合初始化空操作;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", "")
|