签入版本
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
[
|
||||
"sys:article:add",
|
||||
"sys:article:batchDelete",
|
||||
"sys:article:delete",
|
||||
"sys:article:detail",
|
||||
"sys:article:page",
|
||||
"sys:article:status",
|
||||
"sys:article:update",
|
||||
"sys:category:add",
|
||||
"sys:category:delete",
|
||||
"sys:category:detail",
|
||||
"sys:category:page",
|
||||
"sys:category:update",
|
||||
"sys:city:add",
|
||||
"sys:city:batchDelete",
|
||||
"sys:city:delete",
|
||||
"sys:city:detail",
|
||||
"sys:city:list",
|
||||
"sys:city:update",
|
||||
"sys:config:add",
|
||||
"sys:config:batchDelete",
|
||||
"sys:config:delete",
|
||||
"sys:config:detail",
|
||||
"sys:config:page",
|
||||
"sys:config:status",
|
||||
"sys:config:update",
|
||||
"sys:configWeb:data",
|
||||
"sys:configWeb:save",
|
||||
"sys:dept:add",
|
||||
"sys:dept:data",
|
||||
"sys:dept:delete",
|
||||
"sys:dept:detail",
|
||||
"sys:dept:list",
|
||||
"sys:dept:update",
|
||||
"sys:dict:add",
|
||||
"sys:dict:batchDelete",
|
||||
"sys:dict:delete",
|
||||
"sys:dict:detail",
|
||||
"sys:dict:page",
|
||||
"sys:dict:update",
|
||||
"sys:example:add",
|
||||
"sys:example:batchDelete",
|
||||
"sys:example:delete",
|
||||
"sys:example:detail",
|
||||
"sys:example:page",
|
||||
"sys:example:status",
|
||||
"sys:example:update",
|
||||
"sys:fileTemplate:add",
|
||||
"sys:fileTemplate:batchDelete",
|
||||
"sys:fileTemplate:delete",
|
||||
"sys:fileTemplate:detail",
|
||||
"sys:fileTemplate:page",
|
||||
"sys:fileTemplate:update",
|
||||
"sys:generator:batchGenerate",
|
||||
"sys:generator:detail",
|
||||
"sys:generator:generate",
|
||||
"sys:generator:page",
|
||||
"sys:job:add",
|
||||
"sys:job:delete",
|
||||
"sys:job:detail",
|
||||
"sys:job:page",
|
||||
"sys:job:pause",
|
||||
"sys:job:reload",
|
||||
"sys:job:resume",
|
||||
"sys:job:run_once",
|
||||
"sys:job:status",
|
||||
"sys:job:update",
|
||||
"sys:job_log:batchDelete",
|
||||
"sys:job_log:delete",
|
||||
"sys:job_log:detail",
|
||||
"sys:job_log:page",
|
||||
"sys:level:add",
|
||||
"sys:level:batchDelete",
|
||||
"sys:level:delete",
|
||||
"sys:level:detail",
|
||||
"sys:level:export",
|
||||
"sys:level:import",
|
||||
"sys:level:list",
|
||||
"sys:level:page",
|
||||
"sys:level:status",
|
||||
"sys:level:update",
|
||||
"sys:link:add",
|
||||
"sys:link:batchDelete",
|
||||
"sys:link:delete",
|
||||
"sys:link:detail",
|
||||
"sys:link:page",
|
||||
"sys:link:status",
|
||||
"sys:link:update",
|
||||
"sys:loginLog:batchDelete",
|
||||
"sys:loginLog:delete",
|
||||
"sys:loginLog:detail",
|
||||
"sys:loginLog:page",
|
||||
"sys:menu:add",
|
||||
"sys:menu:delete",
|
||||
"sys:menu:detail",
|
||||
"sys:menu:page",
|
||||
"sys:menu:update",
|
||||
"sys:notice:add",
|
||||
"sys:notice:batchDelete",
|
||||
"sys:notice:delete",
|
||||
"sys:notice:detail",
|
||||
"sys:notice:page",
|
||||
"sys:notice:update",
|
||||
"sys:operationLog:batchDelete",
|
||||
"sys:operationLog:delete",
|
||||
"sys:operationLog:detail",
|
||||
"sys:operationLog:page",
|
||||
"sys:param:add",
|
||||
"sys:param:batchDelete",
|
||||
"sys:param:delete",
|
||||
"sys:param:detail",
|
||||
"sys:param:page",
|
||||
"sys:param:update",
|
||||
"sys:position:add",
|
||||
"sys:position:batchDelete",
|
||||
"sys:position:delete",
|
||||
"sys:position:detail",
|
||||
"sys:position:list",
|
||||
"sys:position:page",
|
||||
"sys:position:status",
|
||||
"sys:position:update",
|
||||
"sys:profile:updatePassword",
|
||||
"sys:role:add",
|
||||
"sys:role:batchDelete",
|
||||
"sys:role:delete",
|
||||
"sys:role:detail",
|
||||
"sys:role:getPermission",
|
||||
"sys:role:list",
|
||||
"sys:role:page",
|
||||
"sys:role:savePermission",
|
||||
"sys:role:update",
|
||||
"sys:upload:file",
|
||||
"sys:user:add",
|
||||
"sys:user:batchDelete",
|
||||
"sys:user:delete",
|
||||
"sys:user:detail",
|
||||
"sys:user:export",
|
||||
"sys:user:import",
|
||||
"sys:user:page",
|
||||
"sys:user:resetPwd",
|
||||
"sys:user:status",
|
||||
"sys:user:update",
|
||||
"sys:user_role:clear",
|
||||
"sys:user_role:delete",
|
||||
"sys:user_role:list",
|
||||
"sys:user_role:set"
|
||||
]
|
||||
@@ -0,0 +1,170 @@
|
||||
[
|
||||
"DELETE /api/v1/article/batchDelete",
|
||||
"DELETE /api/v1/article/delete/{id}",
|
||||
"DELETE /api/v1/category/delete/{id}",
|
||||
"DELETE /api/v1/city/batchDelete",
|
||||
"DELETE /api/v1/city/delete/{id}",
|
||||
"DELETE /api/v1/config/batchDelete",
|
||||
"DELETE /api/v1/config/delete/{id}",
|
||||
"DELETE /api/v1/config/item/batchDelete",
|
||||
"DELETE /api/v1/config/item/delete/{id}",
|
||||
"DELETE /api/v1/dept/delete/{id}",
|
||||
"DELETE /api/v1/dict/batchDelete",
|
||||
"DELETE /api/v1/dict/delete/{id}",
|
||||
"DELETE /api/v1/dict/item/batchDelete",
|
||||
"DELETE /api/v1/dict/item/delete/{id}",
|
||||
"DELETE /api/v1/example/batchDelete",
|
||||
"DELETE /api/v1/example/delete/{id}",
|
||||
"DELETE /api/v1/file/template/batchDelete",
|
||||
"DELETE /api/v1/file/template/delete/{id}",
|
||||
"DELETE /api/v1/job/delete/{id}",
|
||||
"DELETE /api/v1/job/log/batchDelete",
|
||||
"DELETE /api/v1/job/log/delete/{id}",
|
||||
"DELETE /api/v1/level/batchDelete",
|
||||
"DELETE /api/v1/level/delete/{id}",
|
||||
"DELETE /api/v1/link/batchDelete",
|
||||
"DELETE /api/v1/link/delete/{id}",
|
||||
"DELETE /api/v1/login/log/batchDelete",
|
||||
"DELETE /api/v1/login/log/delete/{id}",
|
||||
"DELETE /api/v1/menu/delete/{id}",
|
||||
"DELETE /api/v1/notice/batchDelete",
|
||||
"DELETE /api/v1/notice/delete/{id}",
|
||||
"DELETE /api/v1/oper/log/batchDelete",
|
||||
"DELETE /api/v1/oper/log/delete/{id}",
|
||||
"DELETE /api/v1/param/batchDelete",
|
||||
"DELETE /api/v1/param/delete/{id}",
|
||||
"DELETE /api/v1/position/batchDelete",
|
||||
"DELETE /api/v1/position/delete/{id}",
|
||||
"DELETE /api/v1/role/batchDelete",
|
||||
"DELETE /api/v1/role/delete/{id}",
|
||||
"DELETE /api/v1/user/batchDelete",
|
||||
"DELETE /api/v1/user/delete/{id}",
|
||||
"DELETE /api/v1/user/role/clear/{userId}",
|
||||
"DELETE /api/v1/user/role/delete/{userId}/{roleId}",
|
||||
"GET /api/v1/article/detail/{id}",
|
||||
"GET /api/v1/article/page",
|
||||
"GET /api/v1/captcha",
|
||||
"GET /api/v1/category/detail/{id}",
|
||||
"GET /api/v1/category/getCategoryData",
|
||||
"GET /api/v1/category/list",
|
||||
"GET /api/v1/city/detail/{id}",
|
||||
"GET /api/v1/city/getCityList/{city_code}",
|
||||
"GET /api/v1/city/list",
|
||||
"GET /api/v1/config/detail/{id}",
|
||||
"GET /api/v1/config/item/detail/{id}",
|
||||
"GET /api/v1/config/item/page",
|
||||
"GET /api/v1/config/page",
|
||||
"GET /api/v1/config/web/index",
|
||||
"GET /api/v1/dept/detail/{id}",
|
||||
"GET /api/v1/dept/getDeptList",
|
||||
"GET /api/v1/dept/list",
|
||||
"GET /api/v1/dict/data/{code}",
|
||||
"GET /api/v1/dict/detail/{id}",
|
||||
"GET /api/v1/dict/item/detail/{id}",
|
||||
"GET /api/v1/dict/item/page",
|
||||
"GET /api/v1/dict/page",
|
||||
"GET /api/v1/example/detail/{id}",
|
||||
"GET /api/v1/example/page",
|
||||
"GET /api/v1/file/template/detail/{id}",
|
||||
"GET /api/v1/file/template/getTemplateByCode/{code}",
|
||||
"GET /api/v1/file/template/page",
|
||||
"GET /api/v1/generator/columns/{table_name}",
|
||||
"GET /api/v1/generator/detail/{table_name}",
|
||||
"GET /api/v1/generator/page",
|
||||
"GET /api/v1/index/getMenus",
|
||||
"GET /api/v1/index/getUser",
|
||||
"GET /api/v1/job/detail/{id}",
|
||||
"GET /api/v1/job/log/detail/{id}",
|
||||
"GET /api/v1/job/log/page",
|
||||
"GET /api/v1/job/page",
|
||||
"GET /api/v1/level/detail/{id}",
|
||||
"GET /api/v1/level/export",
|
||||
"GET /api/v1/level/list",
|
||||
"GET /api/v1/level/page",
|
||||
"GET /api/v1/link/detail/{id}",
|
||||
"GET /api/v1/link/page",
|
||||
"GET /api/v1/login/log/detail/{id}",
|
||||
"GET /api/v1/login/log/page",
|
||||
"GET /api/v1/logout",
|
||||
"GET /api/v1/menu/detail/{id}",
|
||||
"GET /api/v1/menu/list",
|
||||
"GET /api/v1/notice/detail/{id}",
|
||||
"GET /api/v1/notice/page",
|
||||
"GET /api/v1/oper/log/detail/{id}",
|
||||
"GET /api/v1/oper/log/page",
|
||||
"GET /api/v1/param/detail/{id}",
|
||||
"GET /api/v1/param/page",
|
||||
"GET /api/v1/position/detail/{id}",
|
||||
"GET /api/v1/position/list",
|
||||
"GET /api/v1/position/page",
|
||||
"GET /api/v1/role/detail/{id}",
|
||||
"GET /api/v1/role/list",
|
||||
"GET /api/v1/role/menu/list/{role_id}",
|
||||
"GET /api/v1/role/page",
|
||||
"GET /api/v1/user/detail/{id}",
|
||||
"GET /api/v1/user/export",
|
||||
"GET /api/v1/user/page",
|
||||
"GET /api/v1/user/role/list/{userId}",
|
||||
"POST /api/v1/article/add",
|
||||
"POST /api/v1/category/add",
|
||||
"POST /api/v1/city/add",
|
||||
"POST /api/v1/config/add",
|
||||
"POST /api/v1/config/item/add",
|
||||
"POST /api/v1/config/web/save",
|
||||
"POST /api/v1/dept/add",
|
||||
"POST /api/v1/dict/add",
|
||||
"POST /api/v1/dict/item/add",
|
||||
"POST /api/v1/example/add",
|
||||
"POST /api/v1/file/template/add",
|
||||
"POST /api/v1/generator/batchGenerate",
|
||||
"POST /api/v1/generator/generate",
|
||||
"POST /api/v1/job/add",
|
||||
"POST /api/v1/job/reload",
|
||||
"POST /api/v1/job/runOnce/{id}",
|
||||
"POST /api/v1/job/status",
|
||||
"POST /api/v1/level/add",
|
||||
"POST /api/v1/level/import",
|
||||
"POST /api/v1/link/add",
|
||||
"POST /api/v1/login",
|
||||
"POST /api/v1/menu/add",
|
||||
"POST /api/v1/notice/add",
|
||||
"POST /api/v1/param/add",
|
||||
"POST /api/v1/position/add",
|
||||
"POST /api/v1/role/add",
|
||||
"POST /api/v1/role/menu/save",
|
||||
"POST /api/v1/upload/uploadFile",
|
||||
"POST /api/v1/user/add",
|
||||
"POST /api/v1/user/import",
|
||||
"PUT /api/v1/article/status",
|
||||
"PUT /api/v1/article/update",
|
||||
"PUT /api/v1/category/update",
|
||||
"PUT /api/v1/city/update",
|
||||
"PUT /api/v1/config/item/status",
|
||||
"PUT /api/v1/config/item/update",
|
||||
"PUT /api/v1/config/update",
|
||||
"PUT /api/v1/dept/update",
|
||||
"PUT /api/v1/dict/item/update",
|
||||
"PUT /api/v1/dict/update",
|
||||
"PUT /api/v1/example/status",
|
||||
"PUT /api/v1/example/update",
|
||||
"PUT /api/v1/file/template/update",
|
||||
"PUT /api/v1/index/updatePassword",
|
||||
"PUT /api/v1/index/updateProfile",
|
||||
"PUT /api/v1/job/pause/{id}",
|
||||
"PUT /api/v1/job/resume/{id}",
|
||||
"PUT /api/v1/job/update",
|
||||
"PUT /api/v1/level/status",
|
||||
"PUT /api/v1/level/update",
|
||||
"PUT /api/v1/link/status",
|
||||
"PUT /api/v1/link/update",
|
||||
"PUT /api/v1/menu/update",
|
||||
"PUT /api/v1/notice/update",
|
||||
"PUT /api/v1/param/update",
|
||||
"PUT /api/v1/position/status",
|
||||
"PUT /api/v1/position/update",
|
||||
"PUT /api/v1/role/update",
|
||||
"PUT /api/v1/user/resetPwd",
|
||||
"PUT /api/v1/user/role/set",
|
||||
"PUT /api/v1/user/status",
|
||||
"PUT /api/v1/user/update"
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
[
|
||||
"fastapi_article",
|
||||
"fastapi_category",
|
||||
"fastapi_city",
|
||||
"fastapi_config",
|
||||
"fastapi_config_item",
|
||||
"fastapi_dept",
|
||||
"fastapi_dict",
|
||||
"fastapi_dict_item",
|
||||
"fastapi_file_template",
|
||||
"fastapi_job",
|
||||
"fastapi_job_log",
|
||||
"fastapi_level",
|
||||
"fastapi_link",
|
||||
"fastapi_login_log",
|
||||
"fastapi_menu",
|
||||
"fastapi_notice",
|
||||
"fastapi_operation_log",
|
||||
"fastapi_param",
|
||||
"fastapi_position",
|
||||
"fastapi_role",
|
||||
"fastapi_role_menu",
|
||||
"fastapi_user",
|
||||
"fastapi_user_role"
|
||||
]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""契约快照测试公共工具。
|
||||
|
||||
契约 = 前端/数据依赖的硬不变量:
|
||||
- 路由 (METHOD /path)
|
||||
- 权限字符串 (@permission_required("sys:..."))
|
||||
- 数据库表名
|
||||
|
||||
用法:设置环境变量 UPDATE_GOLDEN=1 运行一次以重新生成 golden。
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Date, Float, Integer, String, Text
|
||||
|
||||
GOLDEN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden")
|
||||
|
||||
PERMISSION_RE = re.compile(r'@permission_required\(\s*["\'](sys:[^"\']+)["\']')
|
||||
|
||||
UPDATE_GOLDEN = os.getenv("UPDATE_GOLDEN") == "1"
|
||||
|
||||
|
||||
def collect_routes():
|
||||
"""通过 openapi schema 收集全部 (METHOD /path),去重排序。"""
|
||||
from core.app import create_app
|
||||
|
||||
app = create_app()
|
||||
spec = app.openapi()
|
||||
return sorted(
|
||||
f"{method.upper()} {path}"
|
||||
for path, methods in spec["paths"].items()
|
||||
for method in methods
|
||||
)
|
||||
|
||||
|
||||
def collect_permissions():
|
||||
"""静态扫描源码中的 @permission_required("sys:...") 权限串。"""
|
||||
perms = set()
|
||||
for root in ("src/api/v1/endpoints", "src/modules"):
|
||||
for path in glob.glob(os.path.join(root, "**", "*.py"), recursive=True):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
perms.update(PERMISSION_RE.findall(f.read()))
|
||||
return sorted(perms)
|
||||
|
||||
|
||||
def collect_tables():
|
||||
"""从 SQLAlchemy metadata 收集全部表名。"""
|
||||
from core.database import Base
|
||||
|
||||
return sorted(Base.metadata.tables.keys())
|
||||
|
||||
|
||||
def compare_snapshot(name: str, actual: list):
|
||||
"""与 golden 对比;UPDATE_GOLDEN=1 时重新生成并跳过断言。"""
|
||||
path = os.path.join(GOLDEN_DIR, name)
|
||||
if UPDATE_GOLDEN:
|
||||
os.makedirs(GOLDEN_DIR, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(actual, f, ensure_ascii=False, indent=2)
|
||||
return
|
||||
with open(path, encoding="utf-8") as f:
|
||||
golden = json.load(f)
|
||||
assert actual == golden, (
|
||||
f"契约快照 [{name}] 不一致!\n"
|
||||
f"新增: {sorted(set(actual) - set(golden))}\n"
|
||||
f"缺失: {sorted(set(golden) - set(actual))}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 模型 ↔ SQL dump 建表结构一致性
|
||||
# ============================================================
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
SQL_SCHEMA_PATH = os.path.join(PROJECT_ROOT, "document", "djangoadmin.fastapi.naivevue.sql")
|
||||
|
||||
|
||||
def parse_sql_type(type_tail: str):
|
||||
"""从列定义中解析 SQL 类型,归一化到与模型一致的 (类型族, 长度)。
|
||||
|
||||
SQL 方言与归一化规则:
|
||||
int/int(0)/tinyint(1)/smallint/mediumint/bit(1) -> "int"
|
||||
bigint -> "bigint"
|
||||
datetime(0)/datetime(6)/timestamp -> "datetime"
|
||||
date -> "date"
|
||||
varchar(n)/char(n) -> ("varchar", n)
|
||||
text/tinytext/mediumtext/longtext -> "text"
|
||||
float/double/decimal -> "float"
|
||||
"""
|
||||
m = re.match(r"([a-z]+)(?:\((\d+)\))?", type_tail.strip().lower())
|
||||
if not m:
|
||||
raise ValueError(f"无法解析 SQL 类型: {type_tail!r}")
|
||||
name, length = m.group(1), m.group(2)
|
||||
if name in ("int", "tinyint", "smallint", "mediumint", "bit"):
|
||||
return ("int", None)
|
||||
if name == "bigint":
|
||||
return ("bigint", None)
|
||||
if name in ("datetime", "timestamp"):
|
||||
return ("datetime", None)
|
||||
if name == "date":
|
||||
return ("date", None)
|
||||
if name in ("varchar", "char"):
|
||||
return ("varchar", int(length))
|
||||
if name in ("text", "tinytext", "mediumtext", "longtext"):
|
||||
return ("text", None)
|
||||
if name in ("float", "double", "decimal"):
|
||||
return ("float", None)
|
||||
raise ValueError(f"无法解析 SQL 类型: {type_tail!r}")
|
||||
|
||||
|
||||
def collect_sql_schema():
|
||||
"""解析 SQL dump 中的 CREATE TABLE,产出 {表名: {列名: (类型族, 长度, 可空)}} 与 {表名: 主键集}。
|
||||
|
||||
兼容 Navicat 导出(Style A:`` `col` int(0) NULL DEFAULT NULL ``)与
|
||||
手写(Style B:`` `col` int NOT NULL ``)两种格式。
|
||||
"""
|
||||
with open(SQL_SCHEMA_PATH, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
schema, pk_map = {}, {}
|
||||
for m in re.finditer(r"CREATE TABLE\s+`([^`]+)`\s*\((.*?)\)\s*ENGINE", content, re.S):
|
||||
table = m.group(1)
|
||||
columns, pks = {}, set()
|
||||
for line in m.group(2).splitlines():
|
||||
s = line.strip().rstrip(",")
|
||||
if not s or s.startswith("PRIMARY KEY"):
|
||||
if s.startswith("PRIMARY KEY"):
|
||||
pk_m = re.search(r"`([^`]+)`", s)
|
||||
if pk_m:
|
||||
pks.add(pk_m.group(1))
|
||||
continue
|
||||
if s.startswith(("KEY ", "UNIQUE KEY", "INDEX ", "FULLTEXT ")):
|
||||
continue
|
||||
col_m = re.match(r"`([^`]+)`\s+(.+)$", s)
|
||||
if not col_m:
|
||||
continue
|
||||
col, type_tail = col_m.group(1), col_m.group(2)
|
||||
# 忽略 COMMENT 子句后再判断可空,避免注释文本误含 NOT NULL
|
||||
pre = type_tail.split("COMMENT")[0]
|
||||
nullable = "NOT NULL" not in pre.upper()
|
||||
columns[col] = (*parse_sql_type(type_tail), nullable)
|
||||
schema[table] = columns
|
||||
pk_map[table] = pks
|
||||
return schema, pk_map
|
||||
|
||||
|
||||
def model_type(col):
|
||||
"""将 SQLAlchemy 列类型映射到与 parse_sql_type 相同的 (类型族, 长度)。"""
|
||||
t = col.type
|
||||
if isinstance(t, BigInteger):
|
||||
return ("bigint", None)
|
||||
if isinstance(t, Integer):
|
||||
return ("int", None)
|
||||
if isinstance(t, DateTime):
|
||||
return ("datetime", None)
|
||||
if isinstance(t, Date):
|
||||
return ("date", None)
|
||||
# 注意:Text 是 String 的子类,必须先判断 Text
|
||||
if isinstance(t, Text):
|
||||
return ("text", None)
|
||||
if isinstance(t, String):
|
||||
return ("varchar", t.length)
|
||||
if isinstance(t, Float):
|
||||
return ("float", None)
|
||||
raise ValueError(f"无法映射模型列类型: {col} ({t!r})")
|
||||
|
||||
|
||||
def collect_model_schema():
|
||||
"""通过 create_app 强制加载全部模型,从 Base.metadata 产出与 collect_sql_schema 同构的数据。"""
|
||||
from core.app import create_app
|
||||
|
||||
create_app()
|
||||
|
||||
from core.database import Base
|
||||
|
||||
schema, pk_map = {}, {}
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
columns, pks = {}, set()
|
||||
for col in table.columns:
|
||||
columns[col.name] = (*model_type(col), col.nullable)
|
||||
if col.primary_key:
|
||||
pks.add(col.name)
|
||||
schema[table_name] = columns
|
||||
pk_map[table_name] = pks
|
||||
return schema, pk_map
|
||||
@@ -0,0 +1,10 @@
|
||||
"""权限字符串契约快照:守护全部 RBAC 权限节点不变。"""
|
||||
import pytest
|
||||
|
||||
from tests.contract.helpers import collect_permissions, compare_snapshot
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_permissions_snapshot():
|
||||
compare_snapshot("permissions.json", collect_permissions())
|
||||
@@ -0,0 +1,10 @@
|
||||
"""路由契约快照:守护全部 API 路径与 HTTP 方法不变。"""
|
||||
import pytest
|
||||
|
||||
from tests.contract.helpers import collect_routes, compare_snapshot
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_routes_snapshot():
|
||||
compare_snapshot("routes.json", collect_routes())
|
||||
@@ -0,0 +1,46 @@
|
||||
"""模型 ↔ SQL dump 建表结构一致性契约测试。
|
||||
|
||||
document/djangoadmin.fastapi.naivevue.sql 是建表事实来源
|
||||
(scripts/init_db.py 以 SQL 为准、模型仅 backfill),因此 SQLAlchemy 模型
|
||||
metadata 必须与 SQL 建表语句保持一致,否则 ORM 读写与建表脚本会产生隐蔽漂移。
|
||||
|
||||
对比维度:表集合、列名集合、每列 (类型族, 长度, 可空)、每表主键集合。
|
||||
忽略:default/server_default、字符集、排序规则、显示宽度、索引、自增起始值。
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tests.contract.helpers import collect_model_schema, collect_sql_schema
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_table_sets_match():
|
||||
sql_schema, _ = collect_sql_schema()
|
||||
model_schema, _ = collect_model_schema()
|
||||
assert set(sql_schema) == set(model_schema), (
|
||||
f"表集合不一致!\n"
|
||||
f"仅SQL存在: {sorted(set(sql_schema) - set(model_schema))}\n"
|
||||
f"仅模型存在: {sorted(set(model_schema) - set(sql_schema))}"
|
||||
)
|
||||
|
||||
|
||||
def test_columns_and_types_match():
|
||||
sql_schema, sql_pk = collect_sql_schema()
|
||||
model_schema, model_pk = collect_model_schema()
|
||||
assert set(sql_schema) == set(model_schema), "表集合不一致,先修复表集合再对比列"
|
||||
for table in sorted(sql_schema):
|
||||
sql_cols = set(sql_schema[table])
|
||||
model_cols = set(model_schema[table])
|
||||
assert sql_cols == model_cols, (
|
||||
f"[{table}] 列名不一致!\n"
|
||||
f"仅SQL存在: {sorted(sql_cols - model_cols)}\n"
|
||||
f"仅模型存在: {sorted(model_cols - sql_cols)}"
|
||||
)
|
||||
for col in sorted(sql_cols):
|
||||
assert sql_schema[table][col] == model_schema[table][col], (
|
||||
f"[{table}.{col}] 定义不一致: SQL={sql_schema[table][col]} "
|
||||
f"模型={model_schema[table][col]}"
|
||||
)
|
||||
assert sql_pk[table] == model_pk[table], (
|
||||
f"[{table}] 主键不一致: SQL={sorted(sql_pk[table])} 模型={sorted(model_pk[table])}"
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""表名契约快照:守护全部数据库表名(DB_PREFIX + 模块)不变。"""
|
||||
import pytest
|
||||
|
||||
from tests.contract.helpers import collect_tables, compare_snapshot
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_tablenames_snapshot():
|
||||
compare_snapshot("tables.json", collect_tables())
|
||||
Reference in New Issue
Block a user