feat: add admin dashboard, pipeline service, lightbox, and update dependencies
This commit is contained in:
+37
-51
@@ -3,8 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -26,11 +25,27 @@ from app.services.pi_client import PiTimeoutError
|
||||
from app.services.schemas import SummarySchema
|
||||
from app.services.summarizer import (
|
||||
_save_files,
|
||||
_save_raw_output_only,
|
||||
_update_summary_in_db,
|
||||
summarize_batch,
|
||||
summarize_one,
|
||||
)
|
||||
from app.utils import utc_now
|
||||
|
||||
|
||||
# ── 共享 fixture ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _summarize_tmp_paths(tmp_path):
|
||||
"""将 data 目录重定向到 tmp_path(供 summarizer 测试使用)。"""
|
||||
with (
|
||||
patch("app.services.summarizer.paper_dir", lambda aid: tmp_path / "papers" / aid),
|
||||
patch("app.services.pdf_downloader.PAPERS_DIR", tmp_path / "papers"),
|
||||
patch("app.services.pdf_downloader.TMP_DIR", tmp_path / "tmp"),
|
||||
patch("app.utils.PAPERS_DIR", tmp_path / "papers"),
|
||||
patch("app.utils.TMP_DIR", tmp_path / "tmp"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -130,7 +145,7 @@ class TestFileOperations:
|
||||
|
||||
def test_save_raw_output_only(self, tmp_path):
|
||||
with patch("app.services.summarizer.paper_dir", lambda aid: tmp_path / aid):
|
||||
_save_raw_output_only("2401.12345", "raw output")
|
||||
_save_files("2401.12345", None, "raw output")
|
||||
paper_dir = tmp_path / "2401.12345"
|
||||
assert (paper_dir / "raw_output.txt").exists()
|
||||
assert not (paper_dir / "summary.json").exists()
|
||||
@@ -157,24 +172,9 @@ class TestFileOperations:
|
||||
class TestSummarizeOneFlow:
|
||||
"""summarize_one 的状态流转(mock pi 和 PDF)。"""
|
||||
|
||||
@pytest.fixture
|
||||
def _patch_paths(self, tmp_path):
|
||||
"""将 data 目录重定向到 tmp_path。"""
|
||||
with (
|
||||
patch(
|
||||
"app.services.summarizer.paper_dir",
|
||||
lambda aid: tmp_path / "papers" / aid,
|
||||
),
|
||||
patch("app.services.pdf_downloader.PAPERS_DIR", tmp_path / "papers"),
|
||||
patch("app.services.pdf_downloader.TMP_DIR", tmp_path / "tmp"),
|
||||
patch("app.utils.PAPERS_DIR", tmp_path / "papers"),
|
||||
patch("app.utils.TMP_DIR", tmp_path / "tmp"),
|
||||
):
|
||||
yield
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_success_path(
|
||||
self, db_session, sample_paper, mock_pi_output, _patch_paths
|
||||
self, db_session, sample_paper, mock_pi_output, _summarize_tmp_paths
|
||||
):
|
||||
"""pending → processing → done 全流程。"""
|
||||
with (
|
||||
@@ -209,7 +209,7 @@ class TestSummarizeOneFlow:
|
||||
assert fts_row[0] == "测试论文中文标题"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pdf_download_failure(self, db_session, sample_paper, _patch_paths):
|
||||
async def test_pdf_download_failure(self, db_session, sample_paper, _summarize_tmp_paths):
|
||||
"""PDF 下载失败 → error_type=pdf_download_failed,tmp 被清理。"""
|
||||
with (
|
||||
patch(
|
||||
@@ -228,7 +228,7 @@ class TestSummarizeOneFlow:
|
||||
assert status.error_type == "pdf_download_failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pi_timeout(self, db_session, sample_paper, _patch_paths):
|
||||
async def test_pi_timeout(self, db_session, sample_paper, _summarize_tmp_paths):
|
||||
"""pi 超时 → timeout 错误,retry_count 递增。"""
|
||||
with (
|
||||
patch("app.services.summarizer.download_pdf", new_callable=AsyncMock),
|
||||
@@ -245,7 +245,7 @@ class TestSummarizeOneFlow:
|
||||
assert result["retry_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_not_found(self, db_session, sample_paper, _patch_paths):
|
||||
async def test_json_not_found(self, db_session, sample_paper, _summarize_tmp_paths):
|
||||
"""pi 输出无 JSON → 验证循环重试 4 次后 ValueError (unknown)。"""
|
||||
with (
|
||||
patch("app.services.summarizer.download_pdf", new_callable=AsyncMock),
|
||||
@@ -262,7 +262,7 @@ class TestSummarizeOneFlow:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_fails_and_retries(
|
||||
self, db_session, sample_paper, _patch_paths
|
||||
self, db_session, sample_paper, _summarize_tmp_paths
|
||||
):
|
||||
"""验证失败(字段不符合要求)→ 重试多次后失败。"""
|
||||
bad_json = json.dumps(
|
||||
@@ -294,7 +294,7 @@ class TestSummarizeOneFlow:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_output_saved_on_failure(
|
||||
self, db_session, sample_paper, tmp_path, _patch_paths
|
||||
self, db_session, sample_paper, tmp_path, _summarize_tmp_paths
|
||||
):
|
||||
"""失败时仍保存 raw_output.txt。"""
|
||||
with (
|
||||
@@ -313,7 +313,7 @@ class TestSummarizeOneFlow:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tmp_cleaned_on_success(
|
||||
self, db_session, sample_paper, mock_pi_output, tmp_path, _patch_paths
|
||||
self, db_session, sample_paper, mock_pi_output, tmp_path, _summarize_tmp_paths
|
||||
):
|
||||
"""成功后清理 tmp 目录。"""
|
||||
with (
|
||||
@@ -331,7 +331,7 @@ class TestSummarizeOneFlow:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tmp_cleaned_on_failure(
|
||||
self, db_session, sample_paper, tmp_path, _patch_paths
|
||||
self, db_session, sample_paper, tmp_path, _summarize_tmp_paths
|
||||
):
|
||||
"""失败后也清理 tmp 目录。"""
|
||||
with (
|
||||
@@ -347,7 +347,7 @@ class TestSummarizeOneFlow:
|
||||
assert not tmp_paper.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_done_paper(self, db_session, sample_paper, _patch_paths):
|
||||
async def test_skips_done_paper(self, db_session, sample_paper, _summarize_tmp_paths):
|
||||
"""已完成的论文跳过。"""
|
||||
sample_paper.summary_status.status = "done"
|
||||
db_session.commit()
|
||||
@@ -364,26 +364,12 @@ class TestSummarizeOneFlow:
|
||||
class TestBatchSummarize:
|
||||
"""批量总结测试。"""
|
||||
|
||||
@pytest.fixture
|
||||
def _patch_paths(self, tmp_path):
|
||||
with (
|
||||
patch(
|
||||
"app.services.summarizer.paper_dir",
|
||||
lambda aid: tmp_path / "papers" / aid,
|
||||
),
|
||||
patch("app.services.pdf_downloader.PAPERS_DIR", tmp_path / "papers"),
|
||||
patch("app.services.pdf_downloader.TMP_DIR", tmp_path / "tmp"),
|
||||
patch("app.utils.PAPERS_DIR", tmp_path / "papers"),
|
||||
patch("app.utils.TMP_DIR", tmp_path / "tmp"),
|
||||
):
|
||||
yield
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_multiple_papers(
|
||||
self, db_session, db_engine, mock_pi_output, _patch_paths
|
||||
self, db_session, db_engine, mock_pi_output, _summarize_tmp_paths
|
||||
):
|
||||
"""批量处理多篇论文。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
now = utc_now()
|
||||
for i in range(3):
|
||||
p = Paper(
|
||||
arxiv_id=f"2401.1234{i}",
|
||||
@@ -426,10 +412,10 @@ class TestBatchSummarize:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_failure_no_block(
|
||||
self, db_session, db_engine, mock_pi_output, _patch_paths
|
||||
self, db_session, db_engine, mock_pi_output, _summarize_tmp_paths
|
||||
):
|
||||
"""一篇失败不阻塞其他。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
now = utc_now()
|
||||
for i in range(2):
|
||||
p = Paper(
|
||||
arxiv_id=f"2401.5678{i}",
|
||||
@@ -451,7 +437,7 @@ class TestBatchSummarize:
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def _mock_call_pi(meta_path, pdf_path):
|
||||
async def _mock_call_pi(meta_path, pdf_path, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
@@ -468,7 +454,7 @@ class TestBatchSummarize:
|
||||
assert result["failed"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_lock_conflict(self, db_session, _patch_paths):
|
||||
async def test_task_lock_conflict(self, db_session, _summarize_tmp_paths):
|
||||
"""TaskLock 防止并发 batch。"""
|
||||
# 先插入一个 running 锁
|
||||
db_session.add(
|
||||
@@ -476,7 +462,7 @@ class TestBatchSummarize:
|
||||
task="summarize",
|
||||
lock_key="batch",
|
||||
status="running",
|
||||
acquired_at=datetime.now(timezone.utc),
|
||||
acquired_at=utc_now(),
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
@@ -486,7 +472,7 @@ class TestBatchSummarize:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_lock_released(
|
||||
self, db_session, db_engine, mock_pi_output, _patch_paths
|
||||
self, db_session, db_engine, mock_pi_output, _summarize_tmp_paths
|
||||
):
|
||||
"""完成后释放 TaskLock。"""
|
||||
from sqlalchemy.orm import sessionmaker as _sm
|
||||
@@ -516,7 +502,7 @@ class TestBatchSummarize:
|
||||
assert lock.released_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_empty(self, db_session, _patch_paths):
|
||||
async def test_batch_empty(self, db_session, _summarize_tmp_paths):
|
||||
"""无 pending 论文时返回空结果。"""
|
||||
result = await summarize_batch(db_session)
|
||||
assert result["status"] == "success"
|
||||
|
||||
Reference in New Issue
Block a user