b2d4589949
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m6s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m51s
AI Code Review / AI Code Review (pull_request) Successful in 3m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m21s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m9s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m27s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 24s
297 lines
10 KiB
Python
297 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""测试预览任务自动关联 edit_plan(generation_preview.py 增量覆盖率补充)。
|
||
|
||
覆盖 generation_preview.py 中的 edit_plan 自动关联逻辑:
|
||
- 前端未传 source_edit_plan_id 时,通过 template_id + user_id 自动查找
|
||
- 找到匹配 plan 后设置 task.source_edit_plan_id 并持久化
|
||
- 查找失败时不影响主流程
|
||
- 前端已传 source_edit_plan_id 时跳过自动关联
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from types import SimpleNamespace
|
||
from typing import Any, Optional
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||
|
||
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
|
||
|
||
# ── Stub Repository ──────────────────────────────────────────────────────────
|
||
|
||
|
||
class StubGenerationTaskRepository:
|
||
"""内存中模拟 GenerationTask 仓储"""
|
||
|
||
def __init__(self) -> None:
|
||
self._store: dict[str, Any] = {}
|
||
|
||
def create(self, task: Any) -> Any:
|
||
self._store[task.id] = task
|
||
return task
|
||
|
||
def get(self, task_id: str) -> Optional[Any]:
|
||
return self._store.get(task_id)
|
||
|
||
def update(self, task: Any) -> Any:
|
||
if task.id not in self._store:
|
||
raise ValueError(f"GenerationTask {task.id} not found")
|
||
self._store[task.id] = task
|
||
return task
|
||
|
||
def count_pending_by_user(self, user_id: str) -> int:
|
||
return 0
|
||
|
||
def count_pending_total(self) -> int:
|
||
return 0
|
||
|
||
|
||
# ── Fake Edit Plan ──────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass
|
||
class FakeEditPlan:
|
||
id: str = "plan-001"
|
||
created_by_user_id: str = "user-001"
|
||
template_id: str = "tpl-001"
|
||
|
||
|
||
class FakeEditPlanRepository:
|
||
def __init__(self, plans: list[FakeEditPlan] | None = None):
|
||
self._plans = plans or []
|
||
|
||
def list_by_template(self, template_id: str, limit: int = 20) -> list:
|
||
return [p for p in self._plans if p.template_id == template_id]
|
||
|
||
|
||
# ── Auth Fakes ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass
|
||
class FakeUser:
|
||
id: str = "user-001"
|
||
email: str = "test@example.com"
|
||
|
||
|
||
@dataclass
|
||
class FakeAuthenticatedUser:
|
||
user: FakeUser = field(default_factory=FakeUser)
|
||
session_id: str | None = None
|
||
token_type: str | None = None
|
||
|
||
|
||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.fixture
|
||
def gen_task_repo() -> StubGenerationTaskRepository:
|
||
return StubGenerationTaskRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_db() -> MagicMock:
|
||
return MagicMock()
|
||
|
||
|
||
@pytest.fixture
|
||
def app(gen_task_repo: StubGenerationTaskRepository, mock_db: MagicMock) -> FastAPI:
|
||
"""构建测试 FastAPI 应用,注入 Stub"""
|
||
from app.api.routes.generation_preview import router
|
||
from app.auth import get_current_user
|
||
from app.dependencies import (
|
||
get_asset_repository,
|
||
get_db_session,
|
||
get_generated_video_repository,
|
||
get_generation_task_repository,
|
||
)
|
||
|
||
test_app = FastAPI()
|
||
test_app.include_router(router, prefix="/api/v1/generation")
|
||
|
||
test_app.dependency_overrides[get_current_user] = lambda: FakeAuthenticatedUser()
|
||
test_app.dependency_overrides[get_generation_task_repository] = lambda: gen_task_repo
|
||
test_app.dependency_overrides[get_db_session] = lambda: mock_db
|
||
test_app.dependency_overrides[get_asset_repository] = lambda: MagicMock()
|
||
test_app.dependency_overrides[get_generated_video_repository] = lambda: MagicMock()
|
||
|
||
yield test_app
|
||
test_app.dependency_overrides.clear()
|
||
|
||
|
||
@pytest.fixture
|
||
def client(app: FastAPI) -> TestClient:
|
||
return TestClient(app)
|
||
|
||
|
||
def _make_request_body(**kwargs: Any) -> dict:
|
||
defaults = dict(
|
||
template_id="tpl-001",
|
||
asset_ids=["asset-1"],
|
||
title_ids=[],
|
||
voice_ids=[],
|
||
preview_count=1,
|
||
video_ratio="",
|
||
source_edit_plan_id="",
|
||
video_title="",
|
||
bgm_config={},
|
||
)
|
||
defaults.update(kwargs)
|
||
return defaults
|
||
|
||
|
||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestPreviewEditPlanAutoAssociation:
|
||
"""预览任务创建后自动关联 edit_plan"""
|
||
|
||
@patch(
|
||
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
|
||
return_value="one_take",
|
||
)
|
||
@patch(
|
||
"app.api.routes.generation_preview._infer_video_ratio_from_template",
|
||
return_value="9:16",
|
||
)
|
||
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
|
||
def test_auto_associate_when_source_plan_empty(
|
||
self,
|
||
mock_enqueue,
|
||
mock_ratio,
|
||
mock_strategy,
|
||
client: TestClient,
|
||
gen_task_repo: StubGenerationTaskRepository,
|
||
):
|
||
"""前端未传 source_edit_plan_id 时,应通过 template_id+user_id 自动查找并关联"""
|
||
fake_plan = FakeEditPlan(id="plan-auto-001", created_by_user_id="user-001", template_id="tpl-001")
|
||
fake_plan_repo = FakeEditPlanRepository(plans=[fake_plan])
|
||
|
||
with patch(
|
||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
|
||
return_value=fake_plan_repo,
|
||
):
|
||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = SimpleNamespace(id="plan-clone-001")
|
||
resp = client.post(
|
||
"/api/v1/generation/preview",
|
||
json=_make_request_body(source_edit_plan_id=""),
|
||
)
|
||
|
||
assert resp.status_code == 201
|
||
# 找到 store 中的 task 并验证 source_edit_plan_id 被设置(自动关联后再克隆为独立 plan)
|
||
tasks = list(gen_task_repo._store.values())
|
||
assert len(tasks) == 1
|
||
task = tasks[0]
|
||
assert task.source_edit_plan_id == "plan-clone-001"
|
||
|
||
@patch(
|
||
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
|
||
return_value="one_take",
|
||
)
|
||
@patch(
|
||
"app.api.routes.generation_preview._infer_video_ratio_from_template",
|
||
return_value="9:16",
|
||
)
|
||
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
|
||
def test_skip_associate_when_source_plan_provided(
|
||
self,
|
||
mock_enqueue,
|
||
mock_ratio,
|
||
mock_strategy,
|
||
client: TestClient,
|
||
gen_task_repo: StubGenerationTaskRepository,
|
||
):
|
||
"""前端已传 source_edit_plan_id 时,不触发自动关联,但仍克隆独立变体 plan"""
|
||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = SimpleNamespace(id="plan-clone-explicit")
|
||
resp = client.post(
|
||
"/api/v1/generation/preview",
|
||
json=_make_request_body(source_edit_plan_id="plan-explicit-001"),
|
||
)
|
||
|
||
assert resp.status_code == 201
|
||
tasks = list(gen_task_repo._store.values())
|
||
assert len(tasks) == 1
|
||
assert tasks[0].source_edit_plan_id == "plan-clone-explicit"
|
||
|
||
@patch(
|
||
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
|
||
return_value="one_take",
|
||
)
|
||
@patch(
|
||
"app.api.routes.generation_preview._infer_video_ratio_from_template",
|
||
return_value="9:16",
|
||
)
|
||
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
|
||
def test_association_failure_does_not_break_main_flow(
|
||
self,
|
||
mock_enqueue,
|
||
mock_ratio,
|
||
mock_strategy,
|
||
client: TestClient,
|
||
gen_task_repo: StubGenerationTaskRepository,
|
||
):
|
||
"""edit_plan 查找异常时不影响任务创建和入队"""
|
||
with patch(
|
||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
|
||
side_effect=RuntimeError("DB connection lost"),
|
||
):
|
||
resp = client.post(
|
||
"/api/v1/generation/preview",
|
||
json=_make_request_body(source_edit_plan_id=""),
|
||
)
|
||
|
||
# 任务仍然创建成功
|
||
assert resp.status_code == 201
|
||
tasks = list(gen_task_repo._store.values())
|
||
assert len(tasks) == 1
|
||
# source_edit_plan_id 保持为空(关联失败)
|
||
assert tasks[0].source_edit_plan_id == ""
|
||
|
||
@patch(
|
||
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
|
||
return_value="one_take",
|
||
)
|
||
@patch(
|
||
"app.api.routes.generation_preview._infer_video_ratio_from_template",
|
||
return_value="9:16",
|
||
)
|
||
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
|
||
def test_auto_associate_skips_when_no_matching_user(
|
||
self,
|
||
mock_enqueue,
|
||
mock_ratio,
|
||
mock_strategy,
|
||
client: TestClient,
|
||
gen_task_repo: StubGenerationTaskRepository,
|
||
):
|
||
"""模板下有 plan 但 created_by_user_id 不匹配时,不关联"""
|
||
fake_plan = FakeEditPlan(id="plan-other-user", created_by_user_id="user-999", template_id="tpl-001")
|
||
fake_plan_repo = FakeEditPlanRepository(plans=[fake_plan])
|
||
|
||
with patch(
|
||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
|
||
return_value=fake_plan_repo,
|
||
):
|
||
resp = client.post(
|
||
"/api/v1/generation/preview",
|
||
json=_make_request_body(source_edit_plan_id=""),
|
||
)
|
||
|
||
assert resp.status_code == 201
|
||
tasks = list(gen_task_repo._store.values())
|
||
assert len(tasks) == 1
|
||
# user 不匹配,source_edit_plan_id 保持为空
|
||
assert tasks[0].source_edit_plan_id == ""
|