a57a26cb67
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Staging E2E Tests (push) Failing after 107h15m3s
Deploy / Deploy Staging (push) Failing after 107h16m41s
CI/CD Pipeline / Frontend Lint (push) Failing after 107h17m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 107h17m18s
根因:test_subscription_api._install_mocks() 在模块导入时替换了 packages.domain / packages.ports 等命名空间包,导致后续测试文件 无法导入真实的 GeneratedVideo 等实体类。 修复:移除所有 packages.* 命名空间包的 fake module 创建,仅保留 必要的叶子 mock(redis/smtp 适配器、app.config/auth/dependencies)。 让 packages.* 模块从磁盘正常加载。 同时包含: - P2: asset_diagnosis 单素材诊断支持 - P2: edit_plans/edit_templates 管理员权限校验 - P2: edit_plans project 归属鉴权 - P3: test_error_scenarios 限流计数器重置 - P3: test_asset_diagnosis/test_edit_templates 测试修复 - P3: Alembic 迁移 024_add_user_is_admin
427 lines
17 KiB
Python
427 lines
17 KiB
Python
"""模板管理 API 单元测试 — Phase 8 任务 2.03.
|
||
|
||
覆盖 5 个端点:
|
||
GET /api/v1/edit-templates — 列表(分页 + 筛选)
|
||
GET /api/v1/edit-templates/{id} — 详情
|
||
POST /api/v1/edit-templates — 创建
|
||
PUT /api/v1/edit-templates/{id} — 更新
|
||
DELETE /api/v1/edit-templates/{id} — 软删除
|
||
|
||
使用 FastAPI TestClient + Stub Repository + dependency_overrides.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Optional
|
||
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")
|
||
|
||
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.config_schemas import normalize_template_config
|
||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||
|
||
# ── Stub Repository ───────────────────────────────────────────────────────────
|
||
|
||
|
||
class StubEditTemplateRepository:
|
||
"""内存中模拟 EditTemplate 仓储"""
|
||
|
||
def __init__(self) -> None:
|
||
self._store: dict[str, EditTemplate] = {}
|
||
|
||
def list_all(
|
||
self,
|
||
*,
|
||
template_type: Optional[str] = None,
|
||
status: Optional[EditTemplateStatus] = None,
|
||
skip: int = 0,
|
||
limit: int = 50,
|
||
) -> list[EditTemplate]:
|
||
items = list(self._store.values())
|
||
if template_type:
|
||
items = [t for t in items if t.template_type == template_type]
|
||
if status:
|
||
items = [t for t in items if t.status == status]
|
||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||
return items[skip : skip + limit]
|
||
|
||
def list_active(
|
||
self,
|
||
*,
|
||
template_type: Optional[str] = None,
|
||
skip: int = 0,
|
||
limit: int = 50,
|
||
) -> list[EditTemplate]:
|
||
return self.list_all(template_type=template_type, status=EditTemplateStatus.ACTIVE, skip=skip, limit=limit)
|
||
|
||
def get(self, template_id: str) -> Optional[EditTemplate]:
|
||
return self._store.get(template_id)
|
||
|
||
def create(self, template: EditTemplate) -> EditTemplate:
|
||
self._store[template.id] = template
|
||
return template
|
||
|
||
def update(self, template: EditTemplate) -> EditTemplate:
|
||
if template.id not in self._store:
|
||
raise ValueError(f"EditTemplate {template.id} not found")
|
||
self._store[template.id] = template
|
||
return template
|
||
|
||
def delete(self, template_id: str) -> bool:
|
||
if template_id in self._store:
|
||
del self._store[template_id]
|
||
return True
|
||
return False
|
||
|
||
def count(
|
||
self,
|
||
*,
|
||
template_type: Optional[str] = None,
|
||
status: Optional[EditTemplateStatus] = None,
|
||
) -> int:
|
||
items = list(self._store.values())
|
||
if template_type:
|
||
items = [t for t in items if t.template_type == template_type]
|
||
if status:
|
||
items = [t for t in items if t.status == status]
|
||
return len(items)
|
||
|
||
|
||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass
|
||
class FakeUser:
|
||
id: str = "user-001"
|
||
email: str = "test@example.com"
|
||
is_admin: bool = True
|
||
|
||
|
||
@dataclass
|
||
class FakeAuthenticatedUser:
|
||
user: FakeUser = field(default_factory=FakeUser)
|
||
session_id: str | None = None
|
||
token_type: str | None = None
|
||
|
||
|
||
@pytest.fixture
|
||
def stub_repo() -> StubEditTemplateRepository:
|
||
return StubEditTemplateRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def app(stub_repo: StubEditTemplateRepository) -> FastAPI:
|
||
"""构建测试 FastAPI 应用,注入 Stub Repository"""
|
||
import app.services.edit_template_service as service_module
|
||
from app.api.routes.edit_templates import router
|
||
from app.auth import get_current_user
|
||
from app.dependencies import get_db_session
|
||
|
||
# 替换服务模块中的 Repository 类
|
||
original_template_repo_cls = service_module.SQLAlchemyEditTemplateRepository
|
||
original_clip_config_repo_cls = service_module.SQLAlchemyTemplateClipConfigRepository
|
||
service_module.SQLAlchemyEditTemplateRepository = lambda session: stub_repo
|
||
service_module.SQLAlchemyTemplateClipConfigRepository = lambda session: stub_repo
|
||
|
||
test_app = FastAPI()
|
||
test_app.include_router(router, prefix="/api/v1/edit-templates")
|
||
|
||
# 覆盖依赖
|
||
def override_get_db_session():
|
||
yield MagicMock()
|
||
|
||
def override_get_current_user():
|
||
return FakeAuthenticatedUser()
|
||
|
||
test_app.dependency_overrides[get_db_session] = override_get_db_session
|
||
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||
|
||
yield test_app
|
||
|
||
# 恢复
|
||
service_module.SQLAlchemyEditTemplateRepository = original_template_repo_cls
|
||
service_module.SQLAlchemyTemplateClipConfigRepository = original_clip_config_repo_cls
|
||
test_app.dependency_overrides.clear()
|
||
|
||
|
||
@pytest.fixture
|
||
def client(app: FastAPI) -> TestClient:
|
||
return TestClient(app)
|
||
|
||
|
||
def _make_template(name: str = "测试模板", **kwargs: Any) -> EditTemplate:
|
||
return EditTemplate.create(name=name, **kwargs)
|
||
|
||
|
||
# ── GET /api/v1/edit-templates (列表) ─────────────────────────────────────────
|
||
|
||
|
||
class TestListTemplates:
|
||
def test_empty_list(self, client: TestClient) -> None:
|
||
resp = client.get("/api/v1/edit-templates")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["items"] == []
|
||
assert data["total"] == 0
|
||
assert data["page"] == 1
|
||
|
||
def test_list_with_items(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
for i in range(3):
|
||
stub_repo.create(_make_template(f"模板{i}"))
|
||
resp = client.get("/api/v1/edit-templates")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["total"] == 3
|
||
assert len(data["items"]) == 3
|
||
|
||
def test_pagination(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
for i in range(5):
|
||
stub_repo.create(_make_template(f"模板{i}"))
|
||
resp = client.get("/api/v1/edit-templates?page=1&page_size=2")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert len(data["items"]) == 2
|
||
assert data["total"] == 5
|
||
assert data["page"] == 1
|
||
assert data["page_size"] == 2
|
||
|
||
def test_filter_by_type(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
stub_repo.create(_make_template("Vlog模板", template_type="vlog"))
|
||
stub_repo.create(_make_template("短视频模板", template_type="short"))
|
||
stub_repo.create(_make_template("另一个Vlog", template_type="vlog"))
|
||
resp = client.get("/api/v1/edit-templates?template_type=vlog")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["total"] == 2
|
||
assert all(item["template_type"] == "vlog" for item in data["items"])
|
||
|
||
def test_filter_by_status(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t1 = _make_template("活跃模板")
|
||
stub_repo.create(t1)
|
||
t2 = _make_template("停用模板", status=EditTemplateStatus.INACTIVE)
|
||
stub_repo.create(t2)
|
||
resp = client.get("/api/v1/edit-templates?status=active")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["total"] == 1
|
||
assert data["items"][0]["name"] == "活跃模板"
|
||
|
||
def test_invalid_status_filter(self, client: TestClient) -> None:
|
||
resp = client.get("/api/v1/edit-templates?status=invalid")
|
||
assert resp.status_code == 400
|
||
|
||
def test_invalid_page(self, client: TestClient) -> None:
|
||
resp = client.get("/api/v1/edit-templates?page=0")
|
||
assert resp.status_code == 422
|
||
|
||
|
||
# ── GET /api/v1/edit-templates/{id} (详情) ────────────────────────────────────
|
||
|
||
|
||
class TestGetTemplate:
|
||
def test_get_existing(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("详情模板", description="这是描述", template_type="vlog")
|
||
stub_repo.create(t)
|
||
resp = client.get(f"/api/v1/edit-templates/{t.id}")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["id"] == t.id
|
||
assert data["name"] == "详情模板"
|
||
assert data["description"] == "这是描述"
|
||
assert data["template_type"] == "vlog"
|
||
assert data["status"] == "active"
|
||
|
||
def test_get_not_found(self, client: TestClient) -> None:
|
||
resp = client.get("/api/v1/edit-templates/nonexistent-id")
|
||
assert resp.status_code == 404
|
||
assert "不存在" in resp.json()["detail"]
|
||
|
||
|
||
# ── POST /api/v1/edit-templates (创建) ────────────────────────────────────────
|
||
|
||
|
||
class TestCreateTemplate:
|
||
def test_create_basic(self, client: TestClient) -> None:
|
||
resp = client.post("/api/v1/edit-templates", json={"name": "新模板"})
|
||
assert resp.status_code == 201
|
||
data = resp.json()
|
||
assert data["name"] == "新模板"
|
||
assert data["description"] == ""
|
||
assert data["template_type"] == "default"
|
||
assert data["status"] == "active"
|
||
assert data["sort_weight"] == 0
|
||
assert "id" in data
|
||
|
||
def test_create_with_all_fields(self, client: TestClient) -> None:
|
||
body = {
|
||
"name": "完整模板",
|
||
"description": "完整描述",
|
||
"template_type": "vlog",
|
||
"config": {"key": "value"},
|
||
"preview_url": "https://example.com/preview.mp4",
|
||
"sort_weight": 10,
|
||
}
|
||
resp = client.post("/api/v1/edit-templates", json=body)
|
||
assert resp.status_code == 201
|
||
data = resp.json()
|
||
assert data["name"] == "完整模板"
|
||
assert data["description"] == "完整描述"
|
||
assert data["template_type"] == "vlog"
|
||
assert data["config"] == normalize_template_config({"key": "value"})
|
||
assert data["preview_url"] == "https://example.com/preview.mp4"
|
||
assert data["sort_weight"] == 10
|
||
|
||
def test_create_empty_name(self, client: TestClient) -> None:
|
||
resp = client.post("/api/v1/edit-templates", json={"name": ""})
|
||
assert resp.status_code == 422 # Pydantic min_length=1
|
||
|
||
def test_create_whitespace_name(self, client: TestClient) -> None:
|
||
resp = client.post("/api/v1/edit-templates", json={"name": " "})
|
||
assert resp.status_code == 400 # domain validation
|
||
|
||
def test_create_missing_name(self, client: TestClient) -> None:
|
||
resp = client.post("/api/v1/edit-templates", json={})
|
||
assert resp.status_code == 422
|
||
|
||
def test_create_negative_sort_weight(self, client: TestClient) -> None:
|
||
resp = client.post("/api/v1/edit-templates", json={"name": "模板", "sort_weight": -1})
|
||
assert resp.status_code == 422
|
||
|
||
|
||
# ── PUT /api/v1/edit-templates/{id} (更新) ────────────────────────────────────
|
||
|
||
|
||
class TestUpdateTemplate:
|
||
def test_update_name(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("旧名称")
|
||
stub_repo.create(t)
|
||
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"name": "新名称"})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["name"] == "新名称"
|
||
|
||
def test_update_multiple_fields(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("模板")
|
||
stub_repo.create(t)
|
||
body = {"name": "更新后", "description": "新描述", "sort_weight": 5}
|
||
resp = client.put(f"/api/v1/edit-templates/{t.id}", json=body)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["name"] == "更新后"
|
||
assert data["description"] == "新描述"
|
||
assert data["sort_weight"] == 5
|
||
|
||
def test_update_status(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("模板")
|
||
stub_repo.create(t)
|
||
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"status": "inactive"})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["status"] == "inactive"
|
||
|
||
def test_update_invalid_status(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("模板")
|
||
stub_repo.create(t)
|
||
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"status": "bogus"})
|
||
assert resp.status_code == 400
|
||
|
||
def test_update_not_found(self, client: TestClient) -> None:
|
||
resp = client.put("/api/v1/edit-templates/nonexistent", json={"name": "x"})
|
||
assert resp.status_code == 404
|
||
|
||
def test_partial_update_preserves_others(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("原名", description="原描述", template_type="vlog")
|
||
stub_repo.create(t)
|
||
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"name": "新名"})
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["name"] == "新名"
|
||
assert data["description"] == "原描述"
|
||
assert data["template_type"] == "vlog"
|
||
|
||
def test_update_empty_body(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("模板")
|
||
stub_repo.create(t)
|
||
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["name"] == "模板"
|
||
|
||
|
||
# ── DELETE /api/v1/edit-templates/{id} (软删除) ───────────────────────────────
|
||
|
||
|
||
class TestDeleteTemplate:
|
||
def test_soft_delete(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("待删除")
|
||
stub_repo.create(t)
|
||
resp = client.delete(f"/api/v1/edit-templates/{t.id}")
|
||
assert resp.status_code == 204
|
||
# 软删除后仍存在,但状态为 inactive
|
||
updated = stub_repo.get(t.id)
|
||
assert updated is not None
|
||
assert updated.status == EditTemplateStatus.INACTIVE
|
||
|
||
def test_soft_delete_not_found(self, client: TestClient) -> None:
|
||
resp = client.delete("/api/v1/edit-templates/nonexistent")
|
||
assert resp.status_code == 404
|
||
|
||
def test_soft_delete_idempotent(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("模板")
|
||
stub_repo.create(t)
|
||
# 第一次删除
|
||
resp1 = client.delete(f"/api/v1/edit-templates/{t.id}")
|
||
assert resp1.status_code == 204
|
||
# 第二次删除(已经是 inactive,但仍可再次设为 inactive)
|
||
resp2 = client.delete(f"/api/v1/edit-templates/{t.id}")
|
||
assert resp2.status_code == 204
|
||
|
||
def test_deleted_not_in_active_list(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("模板")
|
||
stub_repo.create(t)
|
||
client.delete(f"/api/v1/edit-templates/{t.id}")
|
||
resp = client.get("/api/v1/edit-templates?status=active")
|
||
data = resp.json()
|
||
assert data["total"] == 0
|
||
|
||
|
||
# ── Response Schema 验证 ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestResponseSchema:
|
||
def test_response_has_all_fields(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
|
||
t = _make_template("模板", description="描述", template_type="vlog")
|
||
stub_repo.create(t)
|
||
resp = client.get(f"/api/v1/edit-templates/{t.id}")
|
||
data = resp.json()
|
||
expected_keys = {
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"template_type",
|
||
"config",
|
||
"preview_url",
|
||
"sort_weight",
|
||
"status",
|
||
"created_at",
|
||
"updated_at",
|
||
}
|
||
assert set(data.keys()) == expected_keys
|
||
|
||
def test_list_response_structure(self, client: TestClient) -> None:
|
||
resp = client.get("/api/v1/edit-templates")
|
||
data = resp.json()
|
||
assert "items" in data
|
||
assert "total" in data
|
||
assert "page" in data
|
||
assert "page_size" in data
|
||
assert isinstance(data["items"], list)
|