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
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
260 lines
9.8 KiB
Python
260 lines
9.8 KiB
Python
"""#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"
|