Files
xiaoxia-saas/tests/unit/test_cover_templates_crud.py
T
xiaoxia 0c6ec8995f
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 51s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m56s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m33s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m9s
CI/CD Pipeline / Integration Tests (push) Successful in 1m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m53s
CI/CD Pipeline / Unit Tests (push) Successful in 8m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m17s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m34s
fix: 封面模板 API 错误处理加固 (#1329)
2026-08-10 10:40:11 +08:00

403 lines
15 KiB
Python

"""封面模板 CRUD 单元测试。
验证:
1. 领域实体:创建系统/用户模板、更新、空名称校验
2. 仓储接口:list_for_user 返回系统+用户模板
3. API 路由:CRUD 权限检查(系统模板不可删/改)
4. Schema:请求/响应序列化
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from packages.domain.cover_template import CoverTemplate
class TestCoverTemplateDomain:
"""测试封面模板领域实体。"""
def test_create_system_template(self):
"""测试创建系统模板。"""
tpl = CoverTemplate.create_system(name="默认模板")
assert tpl.is_system is True
assert tpl.user_id is None
assert tpl.name == "默认模板"
assert tpl.config == {}
def test_create_system_template_with_config(self):
"""测试创建带配置的系统模板。"""
config = {"background_color": "#ef4444", "title_text": "Hello"}
tpl = CoverTemplate.create_system(name="大胆红", config=config)
assert tpl.config == config
assert tpl.name == "大胆红"
def test_create_user_template(self):
"""测试创建用户自定义模板。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="我的模板")
assert tpl.is_system is False
assert tpl.user_id == "user-1"
assert tpl.name == "我的模板"
def test_create_user_template_strips_whitespace(self):
"""测试创建用户模板时自动去除首尾空格。"""
tpl = CoverTemplate.create_user(user_id="user-1", name=" 我的模板 ")
assert tpl.name == "我的模板"
def test_create_user_template_empty_name_raises(self):
"""测试空名称抛出 ValueError。"""
with pytest.raises(ValueError, match="模板名称不能为空"):
CoverTemplate.create_user(user_id="user-1", name="")
with pytest.raises(ValueError, match="模板名称不能为空"):
CoverTemplate.create_user(user_id="user-1", name=" ")
def test_update_name(self):
"""测试更新模板名称。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="旧名称")
old_updated_at = tpl.updated_at
tpl.update(name="新名称")
assert tpl.name == "新名称"
assert tpl.updated_at >= old_updated_at
def test_update_config(self):
"""测试更新模板配置。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
new_config = {"background_color": "#000", "mask_enabled": True}
tpl.update(config=new_config)
assert tpl.config == new_config
def test_update_thumbnail_url(self):
"""测试更新缩略图 URL。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
tpl.update(thumbnail_url="https://example.com/thumb.jpg")
assert tpl.thumbnail_url == "https://example.com/thumb.jpg"
def test_update_empty_name_raises(self):
"""测试更新空名称抛出 ValueError。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
with pytest.raises(ValueError, match="模板名称不能为空"):
tpl.update(name="")
class TestCoverTemplateRepository:
"""测试封面模板仓储(使用 Mock)。"""
@pytest.fixture
def mock_repo(self):
"""创建模拟仓储。"""
from packages.adapters.sqlalchemy_impl.cover_template_repository import (
SQLAlchemyCoverTemplateRepository,
)
mock_session = MagicMock()
return SQLAlchemyCoverTemplateRepository(mock_session)
def test_create_calls_session_add(self, mock_repo):
"""测试 create 方法调用 session.add。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
mock_repo.create(tpl)
mock_repo.session.add.assert_called_once()
mock_repo.session.commit.assert_called_once()
def test_get_returns_none_for_nonexistent(self, mock_repo):
"""测试 get 方法对不存在的模板返回 None。"""
mock_repo.session.query.return_value.filter.return_value.first.return_value = None
result = mock_repo.get("nonexistent-id")
assert result is None
class TestCoverTemplateSchema:
"""测试封面模板 Schema。"""
def test_create_request_validation(self):
"""测试创建请求的字段验证。"""
from app.schemas.cover_template import CreateCoverTemplateRequest
req = CreateCoverTemplateRequest(name="测试模板")
assert req.name == "测试模板"
assert req.thumbnail_url == ""
assert req.config is None
def test_create_request_with_config(self):
"""测试带配置的创建请求。"""
from app.schemas.cover_template import CoverTemplateConfig, CreateCoverTemplateRequest
config = CoverTemplateConfig(
background_enabled=False,
background_color="#ff0000",
title_text="主标题",
)
req = CreateCoverTemplateRequest(name="测试", config=config)
assert req.config.background_enabled is False
assert req.config.background_color == "#ff0000"
assert req.config.title_text == "主标题"
def test_update_request_optional_fields(self):
"""测试更新请求所有字段可选。"""
from app.schemas.cover_template import UpdateCoverTemplateRequest
req = UpdateCoverTemplateRequest()
assert req.name is None
assert req.config is None
assert req.thumbnail_url is None
def test_response_serialization(self):
"""测试响应序列化。"""
from app.schemas.cover_template import CoverTemplateResponse
now = datetime.now(timezone.utc)
resp = CoverTemplateResponse(
id="test-id",
name="测试",
thumbnail_url="",
is_system=False,
created_at=now,
config={"background_color": "#000"},
)
assert resp.id == "test-id"
assert resp.config["background_color"] == "#000"
class TestCoverTemplateAPIPermissions:
"""测试封面模板 API 权限控制。"""
def test_system_template_cannot_be_deleted(self):
"""测试系统模板不可删除。"""
tpl = CoverTemplate.create_system(name="系统模板")
mock_repo = MagicMock()
mock_repo.get.return_value = tpl
# 模拟 API 路由中的权限检查逻辑
template = mock_repo.get("a8b0120fd98e44788f5a6590f983d327")
assert template is not None
assert template.is_system is True
# 权限检查应该阻止删除
with pytest.raises(PermissionError):
if template.is_system:
raise PermissionError("系统模板不可删除")
def test_system_template_cannot_be_updated(self):
"""测试系统模板不可修改。"""
tpl = CoverTemplate.create_system(name="系统模板")
mock_repo = MagicMock()
mock_repo.get.return_value = tpl
template = mock_repo.get("a8b0120fd98e44788f5a6590f983d327")
assert template.is_system is True
with pytest.raises(PermissionError):
if template.is_system:
raise PermissionError("系统模板不可修改")
def test_user_cannot_delete_others_template(self):
"""测试用户不可删除他人的模板。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="别人的模板")
mock_repo = MagicMock()
mock_repo.get.return_value = tpl
template = mock_repo.get(tpl.id)
current_user_id = "user-2"
assert template.user_id != current_user_id
with pytest.raises(PermissionError):
if template.user_id != current_user_id:
raise PermissionError("无权删除该模板")
def test_user_can_delete_own_template(self):
"""测试用户可以删除自己的模板。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="我的模板")
mock_repo = MagicMock()
mock_repo.get.return_value = tpl
template = mock_repo.get(tpl.id)
current_user_id = "user-1"
assert template.user_id == current_user_id
assert not template.is_system
# 权限检查通过,可以删除
mock_repo.delete(template.id)
mock_repo.delete.assert_called_once()
class TestInMemoryCoverTemplateRepository:
"""使用 InMemory 仓储测试完整 CRUD 流程。"""
@pytest.fixture
def repo(self):
from packages.adapters.in_memory.cover_template_repository import (
InMemoryCoverTemplateRepository,
)
return InMemoryCoverTemplateRepository()
def test_create_and_get(self, repo):
"""创建后能查到。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
repo.create(tpl)
found = repo.get(tpl.id)
assert found is not None
assert found.name == "测试"
assert found.user_id == "user-1"
def test_get_nonexistent_returns_none(self, repo):
"""查不到返回 None。"""
assert repo.get("nonexistent") is None
def test_list_for_user_includes_system_and_own(self, repo):
"""list_for_user 返回系统模板 + 用户自己的模板。"""
sys_tpl = CoverTemplate.create_system(name="系统模板")
repo.create(sys_tpl)
user1_tpl = CoverTemplate.create_user(user_id="user-1", name="用户1的")
repo.create(user1_tpl)
user2_tpl = CoverTemplate.create_user(user_id="user-2", name="用户2的")
repo.create(user2_tpl)
# user-1 应该看到系统模板 + 自己的
user1_visible = repo.list_for_user("user-1")
assert len(user1_visible) == 2
names = {t.name for t in user1_visible}
assert "系统模板" in names
assert "用户1的" in names
assert "用户2的" not in names
def test_count_for_user(self, repo):
"""count_for_user 返回正确的数量。"""
repo.create(CoverTemplate.create_system(name="系统1"))
repo.create(CoverTemplate.create_system(name="系统2"))
repo.create(CoverTemplate.create_user(user_id="user-1", name="用户1的"))
assert repo.count_for_user("user-1") == 3
assert repo.count_for_user("user-2") == 2 # 只能看到2个系统模板
def test_update_template(self, repo):
"""更新模板。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="旧名称")
repo.create(tpl)
tpl.update(name="新名称", config={"background_color": "#ff0000"})
repo.update(tpl)
found = repo.get(tpl.id)
assert found.name == "新名称"
assert found.config["background_color"] == "#ff0000"
def test_update_nonexistent_raises(self, repo):
"""更新不存在的模板抛出异常。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
with pytest.raises(ValueError, match="不存在"):
repo.update(tpl)
def test_delete_template(self, repo):
"""删除模板。"""
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
repo.create(tpl)
assert repo.delete(tpl.id) is True
assert repo.get(tpl.id) is None
def test_delete_nonexistent_returns_false(self, repo):
"""删除不存在的模板返回 False。"""
assert repo.delete("nonexistent") is False
def test_list_system_templates(self, repo):
"""list_system_templates 只返回系统模板。"""
repo.create(CoverTemplate.create_system(name="系统1"))
repo.create(CoverTemplate.create_system(name="系统2"))
repo.create(CoverTemplate.create_user(user_id="user-1", name="用户1的"))
system = repo.list_system_templates()
assert len(system) == 2
assert all(t.is_system for t in system)
class TestCoverTemplatesErrorHandling:
"""测试 API 错误处理加固 - 数据库表不存在时降级处理。"""
def test_list_raises_operational_error_when_table_missing(self):
"""当 cover_templates 表不存在时,repository 应抛出 OperationalError。"""
from sqlalchemy.exc import OperationalError
from packages.adapters.sqlalchemy_impl.cover_template_repository import SQLAlchemyCoverTemplateRepository
mock_session = MagicMock()
mock_session.query.side_effect = OperationalError(
"statement", {}, Exception('relation "cover_templates" does not exist')
)
repo = SQLAlchemyCoverTemplateRepository(mock_session)
with pytest.raises(OperationalError):
repo.list_for_user("user-1")
def test_route_catches_operational_error_on_list(self):
"""路由层应捕获 OperationalError 并返回空列表。"""
from app.api.routes.cover_templates import list_cover_templates
from app.schemas.cover_template import ListCoverTemplatesResponse
from sqlalchemy.exc import OperationalError
mock_repo = MagicMock()
mock_repo.list_for_user.side_effect = OperationalError(
"statement", {}, Exception('relation "cover_templates" does not exist')
)
mock_user = MagicMock()
mock_user.user.id = "user-1"
# 调用路由函数,验证它不会抛出异常
result = list_cover_templates(
skip=0,
limit=100,
authenticated_user=mock_user,
repo=mock_repo,
)
assert isinstance(result, ListCoverTemplatesResponse)
assert result.items == []
assert result.total == 0
def test_route_catches_programming_error_on_list(self):
"""路由层应捕获 ProgrammingError 并返回空列表。"""
from app.api.routes.cover_templates import list_cover_templates
from app.schemas.cover_template import ListCoverTemplatesResponse
from sqlalchemy.exc import ProgrammingError
mock_repo = MagicMock()
mock_repo.list_for_user.side_effect = ProgrammingError(
"statement", {}, Exception("no such table: cover_templates")
)
mock_user = MagicMock()
mock_user.user.id = "user-1"
result = list_cover_templates(
skip=0,
limit=100,
authenticated_user=mock_user,
repo=mock_repo,
)
assert isinstance(result, ListCoverTemplatesResponse)
assert result.items == []
assert result.total == 0
def test_create_returns_503_when_table_missing(self):
"""创建模板时,如果表不存在应抛出 HTTPException(503)。"""
from app.api.routes.cover_templates import create_cover_template
from app.schemas.cover_template import CreateCoverTemplateRequest
from fastapi import HTTPException
from sqlalchemy.exc import OperationalError
mock_repo = MagicMock()
mock_repo.create.side_effect = OperationalError(
"statement", {}, Exception('relation "cover_templates" does not exist')
)
mock_user = MagicMock()
mock_user.user.id = "user-1"
request = CreateCoverTemplateRequest(name="test")
with pytest.raises(HTTPException) as exc_info:
create_cover_template(
request=request,
authenticated_user=mock_user,
repo=mock_repo,
)
assert exc_info.value.status_code == 503