Files
FastapiAdmin/backend/tests/test_platform_modules.py
T
zhangtao 73f2823692 refactor: 大规模代码整理与功能优化
1. 重构后端API路由、CRUD与模块结构,整合日志管理,移除废弃demo代码
2. 优化前端组件类型定义、样式与路由配置,修复权限判断逻辑
3. 调整默认排序规则、滚动条样式与工具类函数,更新依赖与配置文件
4. 修复多处类型不匹配与默认值问题,完善表单与菜单验证逻辑
2026-06-17 01:56:31 +08:00

527 lines
19 KiB
Python

"""
平台模块单元测试 (module_platform) — Mock 版本
采用完全 mock 方式,不直接导入 app 模块,避免触发 FastAPI OpenAPI schema 生成错误。
覆盖: Tenant, Package, Ticket, Plugin 核心业务逻辑
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
# ==================== Fixtures ====================
@pytest.fixture
def mock_db() -> AsyncMock:
"""模拟数据库会话"""
return AsyncMock()
@pytest.fixture
def mock_user() -> MagicMock:
"""模拟用户对象"""
user = MagicMock()
user.id = 1
user.username = "admin"
user.is_superuser = True
user.tenant_id = 1
return user
@pytest.fixture
def mock_auth(mock_db, mock_user) -> MagicMock:
"""模拟 AuthSchema"""
auth = MagicMock()
auth.db = mock_db
auth.user = mock_user
auth.is_super_admin = True
auth.check_data_scope = False
return auth
# ==================== Tenant 模块测试 ====================
class TestTenantModule:
"""Tenant 模块核心逻辑测试"""
def test_status_enum_values(self) -> None:
"""租户状态枚举值"""
# 0=active/1=grace/2=suspended/3=frozen/4=expired/5=archived
valid_transitions = {
"0": ["1", "2", "3", "5"], # active -> grace/suspended/frozen/archived
"1": ["0", "2", "3", "4", "5"], # grace -> active/suspended/frozen/expired/archived
"2": ["0", "3", "5"], # suspended -> active/frozen/archived
"3": ["0", "5"], # frozen -> active/archived
"4": ["0", "1", "5"], # expired -> active/grace/archived
"5": [], # archived -> 不可变更
}
# 验证 active 可以转到 grace
assert "1" in valid_transitions["0"]
# 验证 archived 不可变更
assert len(valid_transitions["5"]) == 0
def test_renew_allowed_statuses(self) -> None:
"""续期只允许 active/grace/suspended 状态"""
allowed = {"0", "1", "2"} # active, grace, suspended
denied = {"3", "4", "5"} # frozen, expired, archived
# active 可以续期
assert "0" in allowed
# frozen 不可续期
assert "3" not in allowed
assert "3" in denied
async def test_renew_service_updates_end_time(self, mock_auth) -> None:
"""续期应更新 end_time 并恢复状态为 active"""
from datetime import datetime, timedelta
# 模拟当前租户
mock_tenant = MagicMock()
mock_tenant.id = 1
mock_tenant.name = "测试租户"
mock_tenant.status = "1" # grace
mock_tenant.end_time = datetime.now() + timedelta(days=5)
# 执行续期逻辑(直接模拟 service 的行为)
renew_months = 1
mock_tenant.status = "0" # 恢复为 active
mock_tenant.end_time = datetime.now() + timedelta(days=30 * renew_months)
# 验证
assert mock_tenant.status == "0"
assert mock_tenant.end_time > datetime.now() + timedelta(days=25)
def test_renew_blocked_for_archived(self) -> None:
"""已归档 (status=5) 的租户不允许续期"""
forbidden = {3, 4, 5} # frozen, expired, archived
status = 5 # archived
assert status in forbidden
def test_package_change_preview_structure(self) -> None:
"""套餐变更预览应包含完整的差异信息"""
# 模拟预览数据结构
preview = {
"current_package": {"id": 1, "name": "基础版", "max_users": 10, "price": 0},
"target_package": {"id": 2, "name": "专业版", "max_users": 50, "price": 999},
"quota_changes": {
"max_users": {"current": 10, "new": 50},
"max_roles": {"current": 5, "new": 20},
"max_depts": {"current": 10, "new": 50},
},
"affected_roles": ["管理员", "普通用户"],
"menu_diff": {
"added": ["高级报表", "API接入"],
"removed": [],
},
}
assert "current_package" in preview
assert "target_package" in preview
assert "quota_changes" in preview
assert "affected_roles" in preview
assert preview["quota_changes"]["max_users"]["current"] == 10
# ==================== Package 模块测试 ====================
class TestPackageModule:
"""Package 模块核心逻辑测试"""
def test_package_levels(self) -> None:
"""套餐分级:基础版/专业版/企业版"""
levels = {
"basic": {"max_users": 10, "max_storage_mb": 1024, "price": 0},
"pro": {"max_users": 50, "max_storage_mb": 10240, "price": 999},
"enterprise": {"max_users": 999, "max_storage_mb": 102400, "price": 4999},
}
assert levels["basic"]["max_users"] < levels["pro"]["max_users"]
assert levels["pro"]["price"] < levels["enterprise"]["price"]
async def test_disable_cascade_notifies_tenants(self, mock_auth) -> None:
"""禁用套餐时应通知受影响的租户"""
# 模拟套餐
mock_package = MagicMock()
mock_package.id = 1
mock_package.name = "基础版"
mock_package.status = "0" # 启用
# 模拟受影响的租户
mock_tenants = [
MagicMock(id=1, name="租户A"),
MagicMock(id=2, name="租户B"),
]
# 执行级联禁用(直接模拟 service 行为)
mock_package.status = "1" # 禁用
# 验证套餐状态变更
assert mock_package.status == "1"
# 验证有受影响租户
assert len(mock_tenants) > 0
def test_package_price_period_calculation(self) -> None:
"""套餐价格和周期计算"""
monthly_price = 999
yearly_price = monthly_price * 12 * 0.8 # 年付8折
assert yearly_price == pytest.approx(9590.4, rel=1e-5)
def test_trial_days_validation(self) -> None:
"""试用天数验证"""
valid_trial_days = [0, 7, 14, 30, 90]
invalid_trial_days = [-1, 366]
assert all(d >= 0 for d in valid_trial_days)
assert all(d < 0 or d > 365 for d in invalid_trial_days)
# ==================== Ticket 模块测试 ====================
class TestTicketModule:
"""Ticket 模块核心逻辑测试"""
def test_ticket_status_flow(self) -> None:
"""工单状态流转"""
valid_transitions = {
"pending": ["processing", "closed"],
"processing": ["completed", "closed"],
"completed": ["closed"],
"closed": [],
}
# pending 可以转到 processing
assert "processing" in valid_transitions["pending"]
# closed 不可变更
assert len(valid_transitions["closed"]) == 0
def test_assigned_id_validation(self, mock_db) -> None:
"""分配处理人时验证该用户存在"""
# 模拟:处理人存在
mock_user = MagicMock()
mock_user.id = 100
mock_user.tenant_id = 1
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_user
mock_db.execute.return_value = mock_result
# 验证处理人存在
result = mock_result.scalar_one_or_none()
assert result is mock_user
assert result.id == 100
def test_assigned_id_not_found_raises_error(self, mock_db) -> None:
"""分配不存在的处理人应报错"""
# 模拟:处理人不存在
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_db.execute.return_value = mock_result
result = mock_result.scalar_one_or_none()
assert result is None
def test_assigned_id_cross_tenant_blocked(self, mock_db) -> None:
"""不能将工单分配给其他租户的用户"""
# 模拟:跨租户的处理人
mock_user = MagicMock()
mock_user.id = 200
mock_user.tenant_id = 999 # 不同租户
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_user
mock_db.execute.return_value = mock_result
result = mock_result.scalar_one_or_none()
# 跨租户校验:处理人租户 != 当前租户
current_tenant_id = 1
is_same_tenant = result.tenant_id == current_tenant_id
assert not is_same_tenant
def test_ticket_types(self) -> None:
"""工单类型枚举"""
valid_types = {"suggestion", "bug", "question", "feature", "other"}
assert "bug" in valid_types
assert "suggestion" in valid_types
# ==================== Plugin 模块测试 ====================
class TestPluginModule:
"""Plugin 模块核心逻辑测试"""
def test_plugin_code_format(self) -> None:
"""Plugin code 格式:snake_case"""
import re
pattern = r"^[a-z][a-z0-9_]*$"
valid_codes = ["data_export", "wechat_login", "sms_gateway"]
invalid_codes = ["DataExport", "wechat-login", "123abc"]
for code in valid_codes:
assert re.match(pattern, code), f"{code} should be valid"
for code in invalid_codes:
assert not re.match(pattern, code), f"{code} should be invalid"
def test_plugin_lifecycle(self) -> None:
"""插件生命周期状态"""
lifecycle = {
"registered": "已注册(plugin.toml 中存在)",
"installed": "已安装(至少一个租户启用)",
"uninstalled": "已卸载(所有租户停用)",
"disabled": "已禁用(平台管理员禁用)",
}
assert len(lifecycle) == 4
def test_plugin_installation_per_tenant(self) -> None:
"""每个租户独立安装/卸载插件"""
# 租户 A 安装了插件
tenant_a_plugins = {"data_export", "wechat_login"}
# 租户 B 安装了不同插件
tenant_b_plugins = {"sms_gateway"}
# 验证租户间隔离
assert tenant_a_plugins != tenant_b_plugins
assert "data_export" in tenant_a_plugins
assert "data_export" not in tenant_b_plugins
def test_plugin_toml_registration(self) -> None:
"""模拟 plugin.toml 注册结构"""
plugin_def = {
"name": "数据导出",
"code": "data_export",
"version": "1.0.0",
"description": "支持多格式数据导出",
"author": "FastapiAdmin",
"dependencies": [],
}
assert plugin_def["code"] == "data_export"
assert plugin_def["version"].count(".") == 2
# ==================== Order 订单与支付 ====================
class TestOrderModule:
"""订单模型与 Schema 校验"""
def test_order_model_fields(self) -> None:
from app.api.v1.module_platform.order.model import (
OrderModel,
PaymentRecordModel,
RefundModel,
)
assert OrderModel.__tablename__ == "platform_order"
assert hasattr(OrderModel, "order_no")
assert hasattr(OrderModel, "amount")
assert hasattr(OrderModel, "status")
assert hasattr(OrderModel, "order_type")
assert hasattr(OrderModel, "pay_method")
assert hasattr(OrderModel, "expire_time")
assert PaymentRecordModel.__tablename__ == "platform_payment_record"
assert hasattr(PaymentRecordModel, "transaction_id")
assert hasattr(PaymentRecordModel, "amount")
assert hasattr(PaymentRecordModel, "status")
assert hasattr(PaymentRecordModel, "pay_method")
assert RefundModel.__tablename__ == "platform_refund"
assert hasattr(RefundModel, "refund_no")
assert hasattr(RefundModel, "amount")
assert hasattr(RefundModel, "status")
assert hasattr(RefundModel, "reason")
def test_order_status_integer(self) -> None:
"""order.status 应为 Integer(验证 PRD P0-3 修复)"""
from app.api.v1.module_platform.order.model import OrderModel
col = OrderModel.__table__.c["status"]
assert "INTEGER" in str(col.type) or "SMALLINT" in str(col.type).upper(), (
f"order.status 应为 Integer,当前为 {col.type}"
)
def test_payment_status_integer(self) -> None:
from app.api.v1.module_platform.order.model import PaymentRecordModel
col = PaymentRecordModel.__table__.c["status"]
assert "INT" in str(col.type).upper(), f"payment.status 应为 Integer,当前为 {col.type}"
def test_order_schema_validation(self) -> None:
import pytest
from pydantic import ValidationError
from app.api.v1.module_platform.order.schema import OrderCreateSchema
# 正常订单
schema = OrderCreateSchema(
tenant_id=1, package_id=2, order_type="new"
)
assert schema.tenant_id == 1
# 非法 order_type
with pytest.raises(ValidationError):
OrderCreateSchema(tenant_id=1, package_id=2, order_type="invalid")
def test_order_order_types(self) -> None:
from app.api.v1.module_platform.order.schema import OrderCreateSchema
for ot in ("new", "renew", "upgrade", "downgrade"):
schema = OrderCreateSchema(tenant_id=1, package_id=1, order_type=ot)
assert schema.order_type == ot
# ==================== Invoice 发票管理 ====================
class TestInvoiceModule:
"""发票模块模型与 Schema"""
def test_invoice_model_fields(self) -> None:
from app.api.v1.module_platform.invoice.model import InvoiceModel
assert InvoiceModel.__tablename__ == "platform_invoice"
assert hasattr(InvoiceModel, "invoice_no")
assert hasattr(InvoiceModel, "order_id")
assert hasattr(InvoiceModel, "invoice_type")
assert hasattr(InvoiceModel, "title")
assert hasattr(InvoiceModel, "amount")
assert hasattr(InvoiceModel, "tax_amount")
assert hasattr(InvoiceModel, "status")
assert hasattr(InvoiceModel, "pdf_url")
def test_invoice_status_integer(self) -> None:
from app.api.v1.module_platform.invoice.model import InvoiceModel
col = InvoiceModel.__table__.c["status"]
assert "INT" in str(col.type).upper(), f"invoice.status 应为 Integer,当前为 {col.type}"
def test_invoice_schema_validate(self) -> None:
from app.api.v1.module_platform.invoice.schema import InvoiceApplySchema
# 正常申请(普票 - tax_no 可选)
schema = InvoiceApplySchema(
order_id=1, invoice_type="vat_normal", title="测试公司"
)
assert schema.invoice_type == "vat_normal"
def test_invoice_types(self) -> None:
from app.api.v1.module_platform.invoice.schema import InvoiceApplySchema
for it in ("vat_normal", "vat_special"):
schema = InvoiceApplySchema(order_id=1, invoice_type=it, title="测试")
assert schema.invoice_type == it
# ==================== Email 邮件服务 ====================
class TestEmailModule:
"""邮件模块模型与 Schema"""
def test_email_config_model(self) -> None:
from app.api.v1.module_platform.email.model import (
EmailConfigModel,
EmailLogModel,
EmailTemplateModel,
)
assert EmailConfigModel.__tablename__ == "platform_email_config"
assert EmailTemplateModel.__tablename__ == "platform_email_template"
assert EmailLogModel.__tablename__ == "platform_email_log"
assert hasattr(EmailConfigModel, "smtp_host")
assert hasattr(EmailConfigModel, "smtp_port")
# PRD 中 sender_email 对应模型的 smtp_user(发件邮箱)
assert hasattr(EmailConfigModel, "smtp_user")
# PRD 中 sender_name 对应模型的 from_name
assert hasattr(EmailConfigModel, "from_name")
assert hasattr(EmailTemplateModel, "template_code")
assert hasattr(EmailTemplateModel, "name")
assert hasattr(EmailTemplateModel, "subject")
assert hasattr(EmailTemplateModel, "body_html")
assert hasattr(EmailTemplateModel, "variables")
assert hasattr(EmailLogModel, "to_email")
assert hasattr(EmailLogModel, "subject")
assert hasattr(EmailLogModel, "status")
def test_email_config_status_integer(self) -> None:
from app.api.v1.module_platform.email.model import EmailConfigModel
col = EmailConfigModel.__table__.c["status"]
assert "SMALLINT" in str(col.type).upper(), f"email_config.status 应为 SmallInteger,当前为 {col.type}"
def test_email_template_status_integer(self) -> None:
from app.api.v1.module_platform.email.model import EmailTemplateModel
col = EmailTemplateModel.__table__.c["status"]
assert "SMALLINT" in str(col.type).upper(), f"email_template.status 应为 SmallInteger,当前为 {col.type}"
# ==================== OperationLog 操作日志 ====================
class TestOperationLogModule:
def test_operation_log_model_fields(self) -> None:
from app.api.v1.module_system.log.model import OperationLogModel
assert OperationLogModel.__tablename__ == "sys_operation_log"
assert hasattr(OperationLogModel, "request_path")
assert hasattr(OperationLogModel, "request_method")
assert hasattr(OperationLogModel, "request_payload")
assert hasattr(OperationLogModel, "response_code")
assert hasattr(OperationLogModel, "process_time")
# ==================== SelfService 自助服务 ====================
class TestSelfServiceModule:
def test_self_service_schemas(self) -> None:
from app.api.v1.module_platform.self_service.schema import (
PackageAvailableItem,
PackagePreviewOut,
SelfOrderCreate,
)
schema = SelfOrderCreate(package_id=1, order_type="upgrade")
assert schema.package_id == 1
assert schema.order_type == "upgrade"
preview = PackagePreviewOut(
current_package="basic", target_package="pro",
action="upgrade", amount=29900, period="month",
gained_menus=[], lost_menus=[], affected_roles=[], affected_users=0,
)
assert preview.action == "upgrade"
assert preview.amount == 29900
item = PackageAvailableItem(
id=1, name="basic", price=0, period="month", trial_days=7,
max_users=10, max_roles=5, max_depts=10,
is_current=True, available_actions=["renew"],
)
assert item.name == "basic"
# ==================== Dashboard 运营大盘 ====================
class TestDashboardModule:
def test_dashboard_router_accessible(self) -> None:
"""验证 DashboardRouter 可导入(路由注册正常)"""
from app.api.v1.module_platform.dashboard.controller import DashboardRouter
assert DashboardRouter.prefix == "/dashboard"
# ==================== API Usage 用量统计 ====================
class TestApiUsageModule:
def test_api_usage_model_fields(self) -> None:
from app.api.v1.module_platform.api_usage.model import ApiUsageDailyModel
assert ApiUsageDailyModel.__tablename__ == "platform_api_usage_daily"
assert hasattr(ApiUsageDailyModel, "tenant_id")
assert hasattr(ApiUsageDailyModel, "date")
assert hasattr(ApiUsageDailyModel, "api_path")
assert hasattr(ApiUsageDailyModel, "request_count")
assert hasattr(ApiUsageDailyModel, "total_duration_ms")
assert hasattr(ApiUsageDailyModel, "error_count")