后端 FastAPI 六层结构(api/services/repositories/models/schemas/core), 统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存 db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、 登出黑名单(TTL=令牌剩余有效期)、登录日志。 前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。 工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。 测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
90 lines
3.7 KiB
Python
90 lines
3.7 KiB
Python
"""DDL 漂移检测。
|
||
|
||
结构定义同时存在于两处:
|
||
- `db/migration/V1__*.sql` —— 事实来源,生产迁移用
|
||
- `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置
|
||
|
||
两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致,
|
||
新人克隆下来建出的库和生产不一样,等到上线才发现。
|
||
本测试把「靠自觉」变成「可自动拦截」,成本极低。
|
||
|
||
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
|
||
"""
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||
MIGRATION_FILE = PROJECT_ROOT / "db" / "migration" / "V1__init_rbac_and_login_logs.sql"
|
||
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
|
||
|
||
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
|
||
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
|
||
# 这些开头的是约束定义,不是列定义
|
||
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
|
||
|
||
|
||
def _parse_tables(sql: str) -> dict[str, set[str]]:
|
||
"""从 SQL 文本中提取 {表名: {列名集合}}。"""
|
||
tables: dict[str, set[str]] = {}
|
||
for table_name, body in _CREATE_TABLE.findall(sql):
|
||
tables[table_name] = {
|
||
column
|
||
for column in _COLUMN_LINE.findall(body)
|
||
if column.upper() not in _NON_COLUMN_KEYWORDS
|
||
}
|
||
return tables
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def migration_tables() -> dict[str, set[str]]:
|
||
return _parse_tables(MIGRATION_FILE.read_text(encoding="utf-8"))
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def init_dev_tables() -> dict[str, set[str]]:
|
||
return _parse_tables(INIT_DEV_FILE.read_text(encoding="utf-8"))
|
||
|
||
|
||
class TestSchemaSync:
|
||
"""两份 DDL 的结构一致性。"""
|
||
|
||
def test_文件存在(self) -> None:
|
||
assert MIGRATION_FILE.is_file(), f"缺少迁移脚本:{MIGRATION_FILE}"
|
||
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
|
||
|
||
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
||
assert set(init_dev_tables) == set(migration_tables), (
|
||
"两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 "
|
||
"db/migration/V{n}__*.sql 与 db/init/init-dev.sql"
|
||
)
|
||
|
||
def test_每张表的列集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
||
mismatched = {
|
||
table: {
|
||
"仅迁移脚本有": sorted(migration_tables[table] - init_dev_tables[table]),
|
||
"仅初始化脚本有": sorted(init_dev_tables[table] - migration_tables[table]),
|
||
}
|
||
for table in set(migration_tables) & set(init_dev_tables)
|
||
if migration_tables[table] != init_dev_tables[table]
|
||
}
|
||
assert not mismatched, f"以下表的列不一致:{mismatched}"
|
||
|
||
|
||
class TestSeedData:
|
||
"""字典数据在两处应保持同步。"""
|
||
|
||
@pytest.mark.parametrize("role_code", ["admin", "user"])
|
||
def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
|
||
migration_sql = MIGRATION_FILE.read_text(encoding="utf-8")
|
||
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
||
assert f"'{role_code}'" in migration_sql
|
||
assert f"'{role_code}'" in init_sql
|
||
|
||
def test_开发脚本标记了迁移版本(self) -> None:
|
||
"""不写 alembic_version 的话,在开发库上执行 upgrade head 会重复建表而失败。"""
|
||
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
||
assert "INSERT INTO alembic_version" in init_sql
|