"""模板管理 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)