From f92eff6f65c8336c35d07cdbe7eb76d10675758a Mon Sep 17 00:00:00 2001 From: kplam Date: Fri, 31 Jul 2026 22:46:50 +0800 Subject: [PATCH] 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 --- CLAUDE.md | 1 + tests/test_admin_integration.py | 6 +- tests/test_admin_page.py | 16 +++ tests/test_document_file_api.py | 169 ++++++++++++++++++++++++ tests/test_document_file_integration.py | 161 ++++++++++++++++++++++ tests/test_ingestion.py | 84 +++++++++++- tests/test_qdrant.py | 149 +++++++++++++++++++++ 7 files changed, 580 insertions(+), 6 deletions(-) create mode 100644 tests/test_document_file_api.py create mode 100644 tests/test_document_file_integration.py diff --git a/CLAUDE.md b/CLAUDE.md index c48e270..c035633 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ QMDSearch/ | GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) | 免登录 | | GET | `/api/v1/documents` | 文档列表(limit/offset 分页) | 免登录 | | GET | `/api/v1/documents/{doc_id}` | 文档详情 | 免登录 | +| GET | `/api/v1/documents/{doc_id}/file` | 下载关联的原始文件(免登录) | 免登录 | | DELETE | `/api/v1/documents/{doc_id}` | 删除文档(幂等) | Bearer | | POST | `/api/v1/auth/login` | 用户名密码登录,签发 session token(TTL 12h) | 免登录 | | POST | `/api/v1/auth/logout` | 退出登录(删除当前 session) | Bearer | diff --git a/tests/test_admin_integration.py b/tests/test_admin_integration.py index f9bdce4..9f52027 100644 --- a/tests/test_admin_integration.py +++ b/tests/test_admin_integration.py @@ -204,11 +204,13 @@ class TestAdminClosedLoop: assert item_b["category"] == DOC_B_CATEGORY assert item_b["tags"] == [] - # 2. 详情:l1/l2_nodes/l3_nodes/chunks_count 与写入一致 + # 2. 详情:l1/l2_nodes/l3_nodes/chunks_count/file 与写入一致 resp = client.get(f"/api/v1/documents/{DOC_A}") assert resp.status_code == 200 data = resp.json()["data"] - assert set(data.keys()) == {"l1", "l2_nodes", "l3_nodes", "chunks_count"} + assert set(data.keys()) == {"l1", "l2_nodes", "l3_nodes", "chunks_count", "file"} + # 文本入库无 metadata → file=None + assert data["file"] is None l1 = data["l1"] assert l1["doc_id"] == DOC_A assert l1["title"] == DOC_A_TITLE diff --git a/tests/test_admin_page.py b/tests/test_admin_page.py index c4ad31a..eafc0a6 100644 --- a/tests/test_admin_page.py +++ b/tests/test_admin_page.py @@ -231,6 +231,22 @@ def test_admin_page_auth_interceptor(admin_html: str) -> None: assert "1006" in admin_html +def test_admin_page_doc_detail_file_link(admin_html: str) -> None: + """文档详情区:有关联文件时展示原始文件行与下载链接(textContent 防 XSS)""" + # 条件判断与字段访问 + assert "data.file" in admin_html + assert "file.filename" in admin_html + assert "file.size_bytes" in admin_html + # 大小格式化辅助 + assert "formatFileSize" in admin_html + # 下载链接用 el("a") 创建,设 href 与 download 属性,不 innerHTML + assert 'el("a"' in admin_html + assert "download" in admin_html + # 行标签文案 + assert "原始文件" in admin_html + assert "下载" in admin_html + + def test_admin_page_api_guide_section(admin_html: str) -> None: """API 指南区块:导航按钮、section、静态清单与鉴权标注(user 角色也可见)""" assert 'data-target="section-api-guide"' in admin_html diff --git a/tests/test_document_file_api.py b/tests/test_document_file_api.py new file mode 100644 index 0000000..c23589e --- /dev/null +++ b/tests/test_document_file_api.py @@ -0,0 +1,169 @@ +"""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", "") diff --git a/tests/test_document_file_integration.py b/tests/test_document_file_integration.py new file mode 100644 index 0000000..b7613ac --- /dev/null +++ b/tests/test_document_file_integration.py @@ -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 diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py index 30dc96c..9bdce31 100644 --- a/tests/test_ingestion.py +++ b/tests/test_ingestion.py @@ -3,13 +3,27 @@ from typing import Any import pytest +from qdrant_client import AsyncQdrantClient +from app.config import settings from app.core.chunker import Chunker from app.core.ingestion import Ingester, IngestionError from app.core.sparse import SparseEncoder from app.models.document import DocumentInput, DocumentSummary, SummaryLevel from app.models.knowledge import CategoryResult -from app.services.qdrant import COLLECTION_L2, COLLECTION_L3 +from app.services.qdrant import COLLECTION_L2, COLLECTION_L3, QdrantService + +_DIM = settings.embedding_dimension + + +def _fake_vec(index: int, dim: int) -> list[float]: + """构造确定性伪向量:第 0 维放 index 标识,其余补 0,长度对齐 dim""" + vec = [0.0] * dim + if dim > 0: + vec[0] = float(index) + if dim > 1: + vec[1] = 1.0 + return vec class FakeSummarizer: @@ -37,14 +51,18 @@ class FakeClassifier: class FakeEmbedding: - """按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本""" + """按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本 - def __init__(self) -> None: + dim 默认 2(FakeQdrant 不校验维度);接真实 Qdrant 时需传 settings.embedding_dimension。 + """ + + def __init__(self, dim: int = 2) -> None: + self.dim = dim self.calls: list[list[str]] = [] async def embed(self, texts: list[str]) -> list[list[float]]: self.calls.append(list(texts)) - return [[float(i), 1.0] for i in range(len(texts))] + return [_fake_vec(i, self.dim) for i in range(len(texts))] class FakeQdrant: @@ -65,6 +83,7 @@ class FakeQdrant: tags: list[str], dense_vector: list[float], sparse_vector: Any = None, + metadata: dict[str, str] | None = None, ) -> None: if self.fail_on == "l1": raise RuntimeError("qdrant down") @@ -77,6 +96,7 @@ class FakeQdrant: "tags": tags, "dense_vector": dense_vector, "sparse_vector": sparse_vector, + "metadata": metadata, } ) @@ -271,3 +291,59 @@ class TestQdrantFailure: assert len(qdrant.l1_calls) == 1 assert {c for c, _ in qdrant.nodes_calls} == {COLLECTION_L2, COLLECTION_L3} assert qdrant.chunks_calls == [] + + +class TestMetadataIntegration: + """L1 metadata 端到端:真实内存 Qdrant + 假总结/分类/向量化,验证 metadata 透传与 get_doc_detail.file""" + + @pytest.fixture + async def real_service(self) -> QdrantService: + svc = QdrantService(client=AsyncQdrantClient(location=":memory:")) + await svc.ensure_collections() + return svc + + def _make_real_ingester(self, service: QdrantService, summary: DocumentSummary) -> Ingester: + return Ingester( + summarizer=FakeSummarizer(summary), # type: ignore[arg-type] + classifier=FakeClassifier(_category()), # type: ignore[arg-type] + chunker=Chunker(), + embedding=FakeEmbedding(dim=_DIM), # type: ignore[arg-type] + sparse=SparseEncoder(), + qdrant=service, + ) + + async def test_ingest_with_metadata_populates_file_field(self, real_service: QdrantService) -> None: + """DocumentInput 带 metadata 入库后,get_doc_detail.file 含正确信息""" + doc = DocumentInput( + text="这是一段用于测试 metadata 透传的正文内容。" * 5, + title="带文件元数据的文档", + metadata={ + "raw_file_path": "/data/uploads/spec.md", + "original_filename": "spec.md", + "original_size_bytes": "5120", + }, + ) + ingester = self._make_real_ingester(real_service, _structured_summary()) + result = await ingester.ingest(doc) + + # FakeQdrant 已被真实 service 取代:l1_calls 不再可用,直接查 Qdrant + detail = await real_service.get_doc_detail(result.document_id) + assert detail is not None + assert detail["file"] == { + "filename": "spec.md", + "size_bytes": 5120, + "url": f"/api/v1/documents/{result.document_id}/file", + } + # L1 payload 也应带 metadata 字段 + assert detail["l1"]["metadata"] == doc.metadata + + async def test_ingest_without_metadata_file_is_none(self, real_service: QdrantService) -> None: + """文本入库(metadata 为空 dict)→ get_doc_detail.file=None""" + doc = DocumentInput(text="纯文本入库,无文件元数据。" * 5, title="纯文本") + ingester = self._make_real_ingester(real_service, _structured_summary()) + result = await ingester.ingest(doc) + + detail = await real_service.get_doc_detail(result.document_id) + assert detail is not None + assert detail["file"] is None + assert detail["l1"]["metadata"] == {} diff --git a/tests/test_qdrant.py b/tests/test_qdrant.py index f66a847..38a2eb4 100644 --- a/tests/test_qdrant.py +++ b/tests/test_qdrant.py @@ -234,3 +234,152 @@ async def test_search_hybrid_rrf(service: QdrantService) -> None: assert len(filtered) == 1 assert filtered[0].payload is not None assert filtered[0].payload["doc_id"] == "doc-h2" + + +# ---------- L1 metadata 存储与读取 ---------- + + +async def test_upsert_l1_metadata_roundtrip(service: QdrantService) -> None: + """upsert_l1 写入 metadata 后,get_l1_metadata 返回写入的 dict""" + meta = { + "raw_file_path": "/data/uploads/report.pdf", + "original_filename": "年报.pdf", + "original_size_bytes": "2048", + } + await service.upsert_l1( + doc_id="doc-meta", + title="带元数据文档", + summary="总结", + category="tech", + tags=["x"], + dense_vector=_dense(0.9), + metadata=meta, + ) + assert await service.get_l1_metadata("doc-meta") == meta + + +async def test_upsert_l1_without_metadata_stores_empty_dict(service: QdrantService) -> None: + """upsert_l1 不传 metadata(旧签名)→ payload 存空 dict,get_l1_metadata 返回 {}""" + await service.upsert_l1( + doc_id="doc-no-meta", + title="无元数据文档", + summary="总结", + category="tech", + tags=["x"], + dense_vector=_dense(0.9), + ) + assert await service.get_l1_metadata("doc-no-meta") == {} + + +async def test_get_l1_metadata_doc_not_found(service: QdrantService) -> None: + """文档不存在 → get_l1_metadata 返回 None""" + assert await service.get_l1_metadata("doc-missing") is None + + +async def test_get_l1_metadata_legacy_payload_without_field(service: QdrantService) -> None: + """存量旧文档:payload 完全没有 metadata 字段(绕过 upsert_l1 直接写点)→ 返回 None 不报错""" + import uuid + + from qdrant_client import models + + point = models.PointStruct( + id=str(uuid.uuid5(uuid.NAMESPACE_URL, "legacy-doc:l1")), + vector={"dense": _dense(0.4)}, + payload={"doc_id": "legacy-doc", "title": "旧文档", "category": "tech", "tags": [], "text": "旧总结"}, + ) + await service.client.upsert(collection_name=COLLECTION_L1, points=[point]) + assert await service.get_l1_metadata("legacy-doc") is None + + +# ---------- get_doc_detail file 字段 ---------- + + +async def test_get_doc_detail_file_field_with_raw_path(service: QdrantService) -> None: + """L1 metadata 含 raw_file_path → file 字段含 filename/size_bytes/url""" + await service.upsert_l1( + doc_id="doc-file", + title="文件文档", + summary="总结", + category="tech", + tags=["x"], + dense_vector=_dense(0.9), + metadata={ + "raw_file_path": "/data/uploads/report.pdf", + "original_filename": "年报.pdf", + "original_size_bytes": "4096", + }, + ) + detail = await service.get_doc_detail("doc-file") + assert detail is not None + file_info = detail["file"] + assert file_info == { + "filename": "年报.pdf", + "size_bytes": 4096, + "url": "/api/v1/documents/doc-file/file", + } + + +async def test_get_doc_detail_file_field_filename_fallback_to_path_name(service: QdrantService) -> None: + """metadata 缺 original_filename 时,filename 回退为 raw_file_path 的 Path.name""" + await service.upsert_l1( + doc_id="doc-file-fb", + title="文件文档", + summary="总结", + category="tech", + tags=["x"], + dense_vector=_dense(0.9), + metadata={"raw_file_path": "/data/uploads/notes.md", "original_size_bytes": "100"}, + ) + detail = await service.get_doc_detail("doc-file-fb") + assert detail is not None + assert detail["file"]["filename"] == "notes.md" + assert detail["file"]["size_bytes"] == 100 + + +async def test_get_doc_detail_file_none_when_no_metadata(service: QdrantService) -> None: + """无 metadata(文本入库,存空 dict)→ file=None""" + await service.upsert_l1( + doc_id="doc-text", + title="纯文本文档", + summary="总结", + category="tech", + tags=["x"], + dense_vector=_dense(0.9), + ) + detail = await service.get_doc_detail("doc-text") + assert detail is not None + assert detail["file"] is None + + +async def test_get_doc_detail_file_none_when_no_raw_path(service: QdrantService) -> None: + """metadata 无 raw_file_path → file=None""" + await service.upsert_l1( + doc_id="doc-meta-no-path", + title="文档", + summary="总结", + category="tech", + tags=["x"], + dense_vector=_dense(0.9), + metadata={"original_filename": "x.pdf", "original_size_bytes": "10"}, + ) + detail = await service.get_doc_detail("doc-meta-no-path") + assert detail is not None + assert detail["file"] is None + + +async def test_get_doc_detail_file_none_for_legacy_doc(service: QdrantService) -> None: + """存量旧文档(payload 无 metadata 字段,绕过 upsert_l1 直接写点)→ file=None 不报错""" + import uuid + + from qdrant_client import models + + point = models.PointStruct( + id=str(uuid.uuid5(uuid.NAMESPACE_URL, "legacy-detail:l1")), + vector={"dense": _dense(0.3)}, + payload={"doc_id": "legacy-detail", "title": "旧文档", "category": "tech", "tags": [], "text": "旧总结"}, + ) + await service.client.upsert(collection_name=COLLECTION_L1, points=[point]) + detail = await service.get_doc_detail("legacy-detail") + assert detail is not None + assert detail["file"] is None + assert detail["l1"]["doc_id"] == "legacy-detail"