fix: 修复会话鉴权「登录后 token 无效」并补齐 NAS 部署流程
- deps.py: _create_redis_client 增加同步 ping 校验,Redis 不可达时正确降级为内存模式 - main.py: lifespan 复用 deps 的 UserStore 单例,避免 admin 与 API 请求实例不一致 - 前端: 强制改密弹窗(must_change_password 用户)、兼容新旧登录返回格式、markPasswordChanged - 新增 NAS SSH 部署脚本与批处理测试;gitignore 前端构建产物
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"""POST /documents/upload-batch、POST /documents/{id}/reingest、GET /documents/tasks 端点测试
|
||||
|
||||
TestClient + FakeManager + FakeQdrant,不真实联网。
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import document as document_module
|
||||
from app.main import app
|
||||
from app.models.document import DocumentInput
|
||||
from app.services.qdrant import QdrantService
|
||||
|
||||
|
||||
class FakeManager:
|
||||
"""假入库任务管理器:支持 submit/get/list_tasks"""
|
||||
|
||||
def __init__(self, task_id: str = "task-1") -> None:
|
||||
self.task_id = task_id
|
||||
self._counter = 0
|
||||
self.submitted: list[DocumentInput] = []
|
||||
self._tasks: list[dict[str, Any]] = []
|
||||
|
||||
async def submit(self, doc: DocumentInput) -> str:
|
||||
self.submitted.append(doc)
|
||||
self._counter += 1
|
||||
tid = f"{self.task_id}-{self._counter}"
|
||||
self._tasks.append(
|
||||
{
|
||||
"task_id": tid,
|
||||
"status": "pending",
|
||||
"filename": doc.metadata.get("original_filename") or doc.title,
|
||||
"created_at": f"2026-01-0{self._counter}T00:00:00+00:00",
|
||||
"updated_at": f"2026-01-0{self._counter}T00:00:00+00:00",
|
||||
"doc_id": None,
|
||||
}
|
||||
)
|
||||
return tid
|
||||
|
||||
async def get(self, task_id: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
async def list_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
return list(self._tasks[:limit])
|
||||
|
||||
|
||||
class FakeQdrant:
|
||||
"""假 Qdrant 服务:可控的 get_l1_metadata/delete_by_doc_id"""
|
||||
|
||||
def __init__(self, meta: dict[str, str] | None = None) -> None:
|
||||
self.meta = meta
|
||||
self.deleted: list[str] = []
|
||||
|
||||
async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None:
|
||||
return self.meta
|
||||
|
||||
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
|
||||
self.deleted.append(doc_id)
|
||||
return {"doc_l1": 1}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, admin_headers: dict[str, str]
|
||||
) -> Iterator[TestClient]:
|
||||
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:
|
||||
test_client.headers.update(admin_headers)
|
||||
yield test_client
|
||||
|
||||
|
||||
def _inject_manager(monkeypatch: pytest.MonkeyPatch, manager: FakeManager) -> None:
|
||||
monkeypatch.setattr(document_module, "_get_task_manager", lambda: manager)
|
||||
|
||||
|
||||
def _inject_qdrant(monkeypatch: pytest.MonkeyPatch, qdrant: FakeQdrant) -> None:
|
||||
monkeypatch.setattr(document_module, "_get_qdrant", lambda: qdrant)
|
||||
|
||||
|
||||
# ----------------------- upload-batch -----------------------
|
||||
|
||||
|
||||
def test_batch_upload_multiple_files_success(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""多文件全部成功:3 文件 → 3 tasks,failed 为空"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[
|
||||
("files", ("a.txt", b"content a", "text/plain")),
|
||||
("files", ("b.md", b"# B", "text/markdown")),
|
||||
("files", ("c.txt", b"content c", "text/plain")),
|
||||
],
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert len(data["tasks"]) == 3
|
||||
assert data["failed"] == []
|
||||
assert len(manager.submitted) == 3
|
||||
filenames = [t["filename"] for t in data["tasks"]]
|
||||
assert filenames == ["a.txt", "b.md", "c.txt"]
|
||||
|
||||
|
||||
def test_batch_upload_partial_failure(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""部分失败:1 个不支持扩展名进 failed,其他成功"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[
|
||||
("files", ("ok.txt", b"good", "text/plain")),
|
||||
("files", ("bad.xlsx", b"binary", "application/octet-stream")),
|
||||
],
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert len(data["tasks"]) == 1
|
||||
assert data["tasks"][0]["filename"] == "ok.txt"
|
||||
assert len(data["failed"]) == 1
|
||||
assert data["failed"][0]["filename"] == "bad.xlsx"
|
||||
assert "不支持" in data["failed"][0]["error"]
|
||||
assert len(manager.submitted) == 1
|
||||
|
||||
|
||||
def test_batch_upload_all_fail(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""全部失败:tasks 空,failed 2 条,未提交任何任务"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[
|
||||
("files", ("a.exe", b"x", "application/octet-stream")),
|
||||
("files", ("b.bin", b"y", "application/octet-stream")),
|
||||
],
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert data["tasks"] == []
|
||||
assert len(data["failed"]) == 2
|
||||
assert len(manager.submitted) == 0
|
||||
|
||||
|
||||
def test_batch_upload_empty_list_returns_1001(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""空文件列表:1001"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/upload-batch", files=[])
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1001
|
||||
assert manager.submitted == []
|
||||
|
||||
|
||||
# ----------------------- reingest -----------------------
|
||||
|
||||
|
||||
def test_reingest_success(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""有原始文件:202 + task_id,删旧数据后提交新任务"""
|
||||
upload_dir = tmp_path / "uploads"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
raw = upload_dir / "raw_report.txt"
|
||||
raw.write_bytes(b"original content")
|
||||
meta = {
|
||||
"raw_file_path": str(raw),
|
||||
"original_filename": "raw_report.txt",
|
||||
}
|
||||
qdrant = FakeQdrant(meta=meta)
|
||||
_inject_qdrant(monkeypatch, qdrant)
|
||||
manager = FakeManager(task_id="reingest-task")
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/some-doc/reingest")
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert data["task_id"] == "reingest-task-1"
|
||||
assert data["status"] == "pending"
|
||||
# 旧数据已删
|
||||
assert qdrant.deleted == ["some-doc"]
|
||||
# 新任务已提交,文本来自原文件
|
||||
assert len(manager.submitted) == 1
|
||||
assert manager.submitted[0].text == "original content"
|
||||
assert manager.submitted[0].title == "raw_report" # 文件名 stem
|
||||
|
||||
|
||||
def test_reingest_no_raw_file_returns_1001(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""无原始文件(meta 无 raw_file_path):1001"""
|
||||
qdrant = FakeQdrant(meta={"some": "metadata"})
|
||||
_inject_qdrant(monkeypatch, qdrant)
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/doc1/reingest")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1001
|
||||
assert "无原始文件" in body["message"]
|
||||
assert len(manager.submitted) == 0
|
||||
|
||||
|
||||
def test_reingest_missing_file_returns_1004(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""文件不存在:1004"""
|
||||
qdrant = FakeQdrant(
|
||||
meta={"raw_file_path": "/nonexistent/path.txt", "original_filename": "path.txt"}
|
||||
)
|
||||
_inject_qdrant(monkeypatch, qdrant)
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/doc1/reingest")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1004
|
||||
assert len(manager.submitted) == 0
|
||||
|
||||
|
||||
def test_reingest_doc_not_found_returns_1004(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""文档不存在(meta 为 None):1004"""
|
||||
qdrant = FakeQdrant(meta=None)
|
||||
_inject_qdrant(monkeypatch, qdrant)
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/nope/reingest")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1004
|
||||
assert len(manager.submitted) == 0
|
||||
|
||||
|
||||
# ----------------------- tasks 列表 -----------------------
|
||||
|
||||
|
||||
def test_list_ingest_tasks_returns_items(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""任务列表:返回近期任务,含 items 与 total"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
manager._tasks = [
|
||||
{
|
||||
"task_id": "t1",
|
||||
"status": "done",
|
||||
"filename": "a.txt",
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
"doc_id": "d1",
|
||||
},
|
||||
{
|
||||
"task_id": "t2",
|
||||
"status": "failed",
|
||||
"filename": "b.txt",
|
||||
"created_at": "2026-01-02T00:00:00+00:00",
|
||||
"updated_at": "2026-01-02T00:00:00+00:00",
|
||||
"doc_id": None,
|
||||
},
|
||||
]
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
assert data["items"][0]["task_id"] == "t1"
|
||||
assert data["items"][0]["doc_id"] == "d1"
|
||||
|
||||
|
||||
def test_list_ingest_tasks_limit(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""limit 截断生效"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
manager._tasks = [
|
||||
{
|
||||
"task_id": f"t{i}",
|
||||
"status": "done",
|
||||
"filename": "f",
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"doc_id": None,
|
||||
}
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks?limit=5")
|
||||
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] == 5
|
||||
assert len(data["items"]) == 5
|
||||
|
||||
|
||||
def test_list_ingest_tasks_requires_auth(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""无 token:1005 未认证"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks", headers={"Authorization": ""})
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1005
|
||||
Reference in New Issue
Block a user