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
403 lines
15 KiB
Python
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
|