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"