2026-10-09 06:46:36 -04:00
|
|
|
|
"""ORM 模型元数据测试。
|
|
|
|
|
|
|
|
|
|
|
|
建表规范(表小写蛇形复数、字段中文 COMMENT、主键 id、索引 uk_/idx_ 前缀)
|
|
|
|
|
|
属于「红线」,靠人工 review 迟早会漏,这里用测试把它固定下来。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from sqlalchemy.dialects import mysql
|
|
|
|
|
|
|
|
|
|
|
|
from app.models import Base
|
|
|
|
|
|
|
2026-10-09 22:44:05 -04:00
|
|
|
|
EXPECTED_TABLES = {
|
|
|
|
|
|
"users",
|
|
|
|
|
|
"roles",
|
|
|
|
|
|
"user_roles",
|
|
|
|
|
|
"permissions",
|
|
|
|
|
|
"role_permissions",
|
|
|
|
|
|
"login_logs",
|
|
|
|
|
|
"operation_logs",
|
2026-10-12 04:13:13 -04:00
|
|
|
|
"business_roles",
|
|
|
|
|
|
"param_definitions",
|
|
|
|
|
|
"param_change_batches",
|
|
|
|
|
|
"param_values",
|
2026-10-09 22:44:05 -04:00
|
|
|
|
}
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ddl(table_name: str) -> str:
|
|
|
|
|
|
"""返回某张表的建表 DDL 文本。"""
|
|
|
|
|
|
from sqlalchemy.schema import CreateTable
|
|
|
|
|
|
|
|
|
|
|
|
table = Base.metadata.tables[table_name]
|
|
|
|
|
|
return str(CreateTable(table).compile(dialect=mysql.dialect()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTableSet:
|
|
|
|
|
|
"""表集合必须完整 —— 漏一张就会让 alembic 生成「删除该表」的迁移。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_表集合与预期一致(self) -> None:
|
|
|
|
|
|
assert set(Base.metadata.tables) == EXPECTED_TABLES
|
|
|
|
|
|
|
|
|
|
|
|
def test_所有表名均为小写蛇形复数(self) -> None:
|
|
|
|
|
|
for name in Base.metadata.tables:
|
|
|
|
|
|
assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形"
|
|
|
|
|
|
assert name.endswith("s"), f"表名 {name} 应为复数"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestNamingConvention:
|
|
|
|
|
|
"""约束命名:规范要求 uk_表_字段 / idx_表_字段。"""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
|
|
|
|
|
def test_主键命名为pk_表名(self, table_name: str) -> None:
|
|
|
|
|
|
pk = Base.metadata.tables[table_name].primary_key
|
|
|
|
|
|
assert pk.name == f"pk_{table_name}"
|
|
|
|
|
|
|
|
|
|
|
|
def test_唯一键以uk_开头(self) -> None:
|
|
|
|
|
|
for table in Base.metadata.tables.values():
|
|
|
|
|
|
for constraint in table.constraints:
|
|
|
|
|
|
if constraint.__class__.__name__ == "UniqueConstraint":
|
|
|
|
|
|
assert constraint.name.startswith(f"uk_{table.name}_"), constraint.name
|
|
|
|
|
|
|
|
|
|
|
|
def test_索引以idx_开头(self) -> None:
|
|
|
|
|
|
for table in Base.metadata.tables.values():
|
|
|
|
|
|
for index in table.indexes:
|
|
|
|
|
|
assert index.name.startswith(f"idx_{table.name}_"), index.name
|
|
|
|
|
|
|
|
|
|
|
|
def test_外键以fk_开头(self) -> None:
|
|
|
|
|
|
for table in Base.metadata.tables.values():
|
|
|
|
|
|
for fk in table.foreign_keys:
|
|
|
|
|
|
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComments:
|
|
|
|
|
|
"""规范红线要求中文 COMMENT。"""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
|
|
|
|
|
def test_表级中文注释存在(self, table_name: str) -> None:
|
|
|
|
|
|
assert Base.metadata.tables[table_name].comment, f"{table_name} 缺少表注释"
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
|
|
|
|
|
def test_每列都有中文注释(self, table_name: str) -> None:
|
|
|
|
|
|
table = Base.metadata.tables[table_name]
|
|
|
|
|
|
missing = [c.name for c in table.columns if not c.comment]
|
|
|
|
|
|
assert not missing, f"{table_name} 缺注释的列:{missing}"
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
|
|
|
|
|
def test_注释确实输出到DDL(self, table_name: str) -> None:
|
|
|
|
|
|
ddl = _ddl(table_name)
|
|
|
|
|
|
assert "COMMENT '" in ddl
|
|
|
|
|
|
assert f"COMMENT='{Base.metadata.tables[table_name].comment}'" in ddl
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMysqlTableOptions:
|
|
|
|
|
|
"""引擎与字符集:不显式指定则跟随服务端,跨环境可能不一致。"""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
|
|
|
|
|
def test_显式指定引擎与字符集(self, table_name: str) -> None:
|
|
|
|
|
|
ddl = _ddl(table_name)
|
|
|
|
|
|
assert "ENGINE=InnoDB" in ddl
|
|
|
|
|
|
assert "CHARSET=utf8mb4" in ddl
|
|
|
|
|
|
assert "utf8mb4_general_ci" in ddl
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
|
|
|
|
|
def test_主键为无符号BIGINT(self, table_name: str) -> None:
|
|
|
|
|
|
assert "id BIGINT UNSIGNED NOT NULL" in _ddl(table_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTimestampColumns:
|
|
|
|
|
|
"""更新时间必须由数据库维护 —— 这是 server_onupdate 做不到的。"""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("table_name", ["users", "roles", "permissions"])
|
|
|
|
|
|
def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None:
|
|
|
|
|
|
assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name)
|
|
|
|
|
|
|
2026-10-09 22:44:05 -04:00
|
|
|
|
@pytest.mark.parametrize("table_name", ["login_logs", "operation_logs"])
|
2026-10-09 06:46:36 -04:00
|
|
|
|
def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None:
|
|
|
|
|
|
"""日志是不可变记录,没有"更新时间"语义。"""
|
|
|
|
|
|
assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name)
|
|
|
|
|
|
|
|
|
|
|
|
def test_关联表保留create_time便于审计(self) -> None:
|
|
|
|
|
|
for table_name in ("user_roles", "role_permissions"):
|
|
|
|
|
|
assert "create_time" in Base.metadata.tables[table_name].columns
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEnumColumns:
|
|
|
|
|
|
"""状态类字段的默认值。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_用户默认启用且令牌版本为1(self) -> None:
|
|
|
|
|
|
users = Base.metadata.tables["users"]
|
|
|
|
|
|
assert users.columns["status"].server_default.arg == "1"
|
|
|
|
|
|
assert users.columns["token_version"].server_default.arg == "1"
|
|
|
|
|
|
|
|
|
|
|
|
def test_角色编码唯一(self) -> None:
|
|
|
|
|
|
ddl = _ddl("roles")
|
|
|
|
|
|
assert "uk_roles_role_code UNIQUE (role_code)" in ddl
|
|
|
|
|
|
|
|
|
|
|
|
def test_用户名唯一(self) -> None:
|
|
|
|
|
|
assert "uk_users_username UNIQUE (username)" in _ddl("users")
|