Files
xiaoxia-saas/tests/unit/test_persist_celery_id_routes_1714.py
T
saas-backend-agent 6a1ec20e68
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m47s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m12s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
AI Code Review / AI Code Review (pull_request) Successful in 6m37s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m41s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 14m45s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m36s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 259h16m47s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 259h16m49s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 259h16m49s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 259h31m14s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 259h31m14s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 259h31m26s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 259h31m14s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 259h31m26s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 259h31m32s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 259h31m32s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 259h31m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 259h31m33s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 259h31m36s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 259h31m40s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 259h51m21s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 259h51m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 260h5m54s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 260h6m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 260h6m9s
test(#1714): 补 mock redis 覆盖率测试,diff coverage 98%
CI runner 无本地 redis,purge/revoke 与路由持久化的真实 redis 测试
全部 skip 导致这些改动零覆盖、diff coverage 45% 未达 60% 门禁:

- 新增 test_orphan_guard_purge_mocked_1714.py(31 测试,全 mock):
  _extract_business_ids 各形态(三元组/裸 args/dict args/坏 JSON/坏
  base64/空 args)、_purge_one_queue(biz/celery id 双匹配命中、未命中
  rpush 保序、全删不 rpush、解析失败保守保留、lrange/重写异常)、
  purge_stale_messages_from_queues(空 ids 早退、redis 未安装、连接失败、
  close 异常)、revoke_and_purge(逐条 revoke、异常不阻断、空 id 跳过)
- 新增 test_persist_celery_id_routes_1714.py(9 测试):ingest_jobs /
  task_center 重试 / upload helper 的 celery_task_id 持久化正常与异常
  吞掉分支、task_enqueue 持久化失败仍入队成功、celery_app 队列配置
  异常不阻断启动(用独立模块对象加载,不 reload 污染 task_enqueue)、
  仓储 update 落库 celery_task_id
- chunked_upload 完成回调去重:改为复用 upload._persist_celery_task_id
2026-09-05 19:52:50 +08:00

260 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""#1714:入队后 celery_task_id 持久化路径覆盖(routes / enqueue / celery_app / 仓储)。
CI 无 redis、不走完整 HTTP 流程,这些 try/except 与早退分支此前覆盖率为 0。
用真实 SQLite 仓储 + monkeypatch celery_app.send_task 直接驱动路由函数:
- routes/ingest_jobs.submit_ingest_job:正常持久化 + 持久化异常吞掉不影响响应
- routes/task_center.retry_project_task(ingest 分支):重试后持久化 + 异常吞掉
- routes/upload._persist_celery_task_id:空 id 早退 + 异常吞掉
- core/task_enqueue.safe_enqueue_generation_task:持久化失败仅 warning,入队仍 True
- core/celery_app:apply_queue_settings 抛异常时 API 启动不炸
- adapters/ingest_job_repository.update:写 celery_task_id 分支落库
"""
from __future__ import annotations
import importlib
import importlib.util
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
API_PATH = str(Path(__file__).resolve().parents[2] / "apps" / "api")
if API_PATH not in sys.path:
sys.path.insert(0, API_PATH)
import pytest # noqa: E402
from app.api.routes import ingest_jobs as ingest_jobs_route # noqa: E402
from app.api.routes import task_center as task_center_route # noqa: E402
from app.api.routes import upload as upload_route # noqa: E402
from app.schemas.ingest_job import SubmitIngestJobRequest # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from packages.adapters.sqlalchemy_impl.ingest_job_repository import ( # noqa: E402
SQLAlchemyIngestJobRepository,
)
from packages.adapters.sqlalchemy_impl.models import Base # noqa: E402
from packages.domain import IngestJob, IngestJobStatus # noqa: E402
def _ingest_repo():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
return SQLAlchemyIngestJobRepository(session), session
def _fake_celery_result(task_id: str = "celery-route-msg-1"):
result = MagicMock()
result.id = task_id
return result
# ── routes/ingest_jobs.submit_ingest_job ────────────────────────────────
def test_submit_ingest_job_persists_celery_task_id(monkeypatch):
repo, session = _ingest_repo()
monkeypatch.setattr(ingest_jobs_route.celery_app, "send_task", MagicMock(return_value=_fake_celery_result()))
request = SubmitIngestJobRequest(project_id="proj-1", library_id="lib-1", storage_key="uploads/x.mov")
response = ingest_jobs_route.submit_ingest_job(request, ingest_job_repository=repo)
assert response.status == "pending"
saved = repo.get(response.id)
assert saved.celery_task_id == "celery-route-msg-1"
def test_submit_ingest_job_persist_failure_swallowed(monkeypatch):
repo, _ = _ingest_repo()
class _BoomRepo:
def __init__(self, inner):
self.inner = inner
def create(self, job):
return self.inner.create(job)
def get(self, job_id):
return self.inner.get(job_id)
def update(self, job): # noqa: ARG002
raise RuntimeError("db write fail")
boom_repo = _BoomRepo(repo)
monkeypatch.setattr(ingest_jobs_route.celery_app, "send_task", MagicMock(return_value=_fake_celery_result()))
request = SubmitIngestJobRequest(project_id="proj-1", library_id="lib-1", storage_key="uploads/y.mov")
# 持久化异常被吞掉,主流程(响应)不受影响
response = ingest_jobs_route.submit_ingest_job(request, ingest_job_repository=boom_repo)
assert response.id
assert response.status == "pending"
# ── routes/task_center.retry_project_task(ingest 分支) ────────────────
def _auth_user():
user = SimpleNamespace(id="user-1")
return SimpleNamespace(user=user, session_id=None, token_type=None)
def test_retry_ingest_job_persists_celery_task_id(monkeypatch):
repo, session = _ingest_repo()
# 造一条 failed 的 ingest job
job = IngestJob.create(project_id="proj-1", library_id="lib-1", storage_key="uploads/z.mov")
job.status = IngestJobStatus.FAILED
repo.create(job)
monkeypatch.setattr(
task_center_route.celery_app, "send_task", MagicMock(return_value=_fake_celery_result("celery-retry-1"))
)
response = task_center_route.retry_project_task(
"ingest", job.id, authenticated_user=_auth_user(), ingest_job_repository=repo
)
assert response.task_type == "ingest"
new_id = response.id.split("ingest:")[1]
retried = repo.get(new_id)
assert retried is not None
assert retried.celery_task_id == "celery-retry-1"
def test_retry_ingest_job_persist_failure_swallowed(monkeypatch):
repo, _ = _ingest_repo()
job = IngestJob.create(project_id="proj-1", library_id="lib-1", storage_key="uploads/w.mov")
job.status = IngestJobStatus.FAILED
repo.create(job)
real_update = repo.update
def _update_that_booms(entity):
# 仅在写 celery_task_id 的那次 update 抛错(新建 job 后路由内的持久化)
if getattr(entity, "celery_task_id", ""):
raise RuntimeError("db write fail")
return real_update(entity)
repo.update = _update_that_booms # type: ignore[method-assign]
monkeypatch.setattr(task_center_route.celery_app, "send_task", MagicMock(return_value=_fake_celery_result()))
# 持久化异常吞掉,重试接口仍正常返回
response = task_center_route.retry_project_task(
"ingest", job.id, authenticated_user=_auth_user(), ingest_job_repository=repo
)
assert response.task_type == "ingest"
# ── routes/upload._persist_celery_task_id ───────────────────────────────
def test_upload_persist_helper_empty_id_early_return():
repo = MagicMock()
job = MagicMock()
upload_route._persist_celery_task_id(repo, job, "")
repo.update.assert_not_called()
upload_route._persist_celery_task_id(repo, job, None) # type: ignore[arg-type]
repo.update.assert_not_called()
def test_upload_persist_helper_exception_swallowed():
repo = MagicMock()
repo.update.side_effect = RuntimeError("db fail")
job = MagicMock()
# 不抛异常
upload_route._persist_celery_task_id(repo, job, "celery-upload-1")
repo.update.assert_called_once()
assert job.celery_task_id == "celery-upload-1"
# ── core/task_enqueue:持久化失败仅 warning ─────────────────────────────
def test_safe_enqueue_persist_failure_still_returns_true(monkeypatch):
from app.core import task_enqueue
class _FakeTask:
def __init__(self):
self.id = "task-enqueue-persist-fail"
self.status = "pending"
self.celery_task_id = ""
def mark_failed(self, msg): # noqa: ARG002
self.status = "failed"
class _FakeRepo:
def count_pending_total(self):
return 0
def count_pending_by_user(self, user_id): # noqa: ARG002
return 0
def update(self, task): # noqa: ARG002
raise RuntimeError("persist celery_task_id failed")
fake_result = MagicMock()
fake_result.id = "celery-enqueue-fail-1"
mock_celery = MagicMock()
mock_celery.send_task.return_value = fake_result
monkeypatch.setattr(task_enqueue, "celery_app", mock_celery)
task = _FakeTask()
repo = _FakeRepo()
ok = task_enqueue.safe_enqueue_generation_task(task, repo, user_id="u1")
# 持久化失败不影响入队结果
assert ok is True
mock_celery.send_task.assert_called_once()
# ── core/celery_app:队列配置失败不阻断 API 启动 ────────────────────────
def test_api_celery_app_survives_queue_settings_failure(monkeypatch):
"""apply_queue_settings 抛异常时 API 启动不炸(core/celery_app.py 的 try/except 分支)。
通过让 `from packages.shared.celery_queues import apply_queue_settings` 本身
抛异常来触发 except 分支;用全新模块名 reload,不替换已被其他模块持有的
app.core.celery_app 模块对象,避免污染 task_enqueue 等导入方。
"""
import builtins
real_import = builtins.__import__
def _failing_import(name, globals=None, locals=None, fromlist=(), level=0): # noqa: A002
if name == "packages.shared.celery_queues" and "apply_queue_settings" in (fromlist or ()):
raise RuntimeError("config boom")
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", _failing_import)
spec = importlib.util.find_spec("app.core.celery_app")
fresh_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fresh_mod) # 异常在模块内被 try/except 吞掉
assert fresh_mod.celery_app is not None
assert fresh_mod.celery_app.main == "xiaoxia-saas-api"
# 已加载的原模块对象不受影响(无 reload 污染)
import app.core.celery_app as api_celery_mod
assert api_celery_mod.celery_app is not None
# ── 仓储:update 写 celery_task_id 落库 ─────────────────────────────────
def test_ingest_repo_update_persists_celery_task_id():
repo, session = _ingest_repo()
job = IngestJob.create(project_id="proj-1", library_id="lib-1", storage_key="uploads/repo.mov")
repo.create(job)
job.celery_task_id = "celery-repo-update-1"
repo.update(job)
session.expire_all()
saved = repo.get(job.id)
assert saved.celery_task_id == "celery-repo-update-1"