pbl_domain_ext/tests/test_models_contract.py
2026-09-19 00:05:32 +08:00

463 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""机械契约测试models/pbl_domain_ref.json ↔ pbl_domain_ext 代码列名/类型一致性。
覆盖 QC 退回意见 #1/#2/#5/#6 的回归防护:
#1 代码读写的 7 个列ref_code/ref_name/bind_state/bind_at/is_deleted/creator_id/updater_id
必须全部出现在 models fields 中,且审计列名不得写成 created_by/updated_by
#2 ref_id/blueprint_id 必须是 str 类型(与基表 world/scene/entity 主键 str(32) 一致),
禁止 long/bigint
#5 init/data.json 必须是合法 JSON且按 Format B 注入 appcodes 两组
pbl_domain_ref_type / pbl_bind_stateparentid 与 models codes cond 对得上;
#6 主键生成必须走 appPublic.uniqueID.getID禁止 uuid4
"""
import json
import os
import re
import sys
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT) # 允许 python3 tests/test_models_contract.py 直接跑
MODEL_PATH = os.path.join(ROOT, "models", "pbl_domain_ref.json")
CRUD_PATH = os.path.join(ROOT, "json", "pbl_domain_ref.json")
INIT_DATA_PATH = os.path.join(ROOT, "init", "data.json")
SQL_PATH = os.path.join(ROOT, "sql", "pbl_domain_ext.sql")
#: api.py / db.py / base.py 实际读写的列QC#1 清单)
CODE_USED_COLUMNS = [
"id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name",
"blueprint_id", "class_id", "team_id", "bind_state", "bind_at",
"ext_json", "is_deleted", "creator_id", "created_at", "updater_id", "updated_at",
]
FORBIDDEN_AUDIT_NAMES = ["created_by", "updated_by", "creator", "updater", "ext"]
def _load(path):
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
class TestModelContract(unittest.TestCase):
"""models/pbl_domain_ref.json 与代码契约同名同型。"""
@classmethod
def setUpClass(cls):
cls.model = _load(MODEL_PATH)
cls.fields = {f["name"]: f for f in cls.model["fields"]}
def test_summary_shape(self):
"""summary 必须恰一条、primary 必须是数组 ["id"]table-definition-spec"""
self.assertEqual(len(self.model["summary"]), 1)
s = self.model["summary"][0]
self.assertEqual(s["name"], "pbl_domain_ref")
self.assertIsInstance(s["primary"], list)
self.assertEqual(s["primary"], ["id"])
def test_only_one_table(self):
"""Q-OPEN-3 薄扩展models/ 下只允许 pbl_domain_ref 一张表,
禁止把 world/scene/entity 基表重新定义进来。"""
names = sorted(f for f in os.listdir(os.path.join(ROOT, "models"))
if f.endswith(".json"))
self.assertEqual(names, ["pbl_domain_ref.json"])
def test_code_columns_all_declared(self):
"""QC#1代码读写的列必须全部在 fields 里声明。"""
missing = [c for c in CODE_USED_COLUMNS if c not in self.fields]
self.assertEqual(missing, [], "models 缺列: %s" % missing)
def test_no_forbidden_column_names(self):
"""QC#1审计列名统一 creator_id/updater_id扩展列统一 ext_json。"""
bad = [n for n in self.fields if n in FORBIDDEN_AUDIT_NAMES]
self.assertEqual(bad, [], "存在旧名/禁用列名: %s" % bad)
def test_ref_id_and_blueprint_id_are_str(self):
"""QC#2ref_id / blueprint_id 必须 str长度 >= 32禁止 long/bigint。"""
for col, minlen in (("ref_id", 32), ("blueprint_id", 32), ("id", 32)):
f = self.fields[col]
self.assertEqual(f["type"], "str", "%s 类型应为 str" % col)
self.assertIsInstance(f["length"], int)
self.assertGreaterEqual(f["length"], minlen)
for col in self.fields:
self.assertNotIn(self.fields[col]["type"], ("long", "bigint"),
"%s 不得使用 bigint" % col)
def test_str_fields_have_int_length(self):
"""str/char 必须带正整数 length禁止 "15,2" 之类字符串写法)。"""
for name, f in self.fields.items():
if f["type"] in ("str", "char"):
self.assertIsInstance(f.get("length"), int, "%s 缺 int length" % name)
self.assertGreater(f["length"], 0)
if f["type"] in ("float", "double", "ddouble", "decimal"):
self.assertIsInstance(f.get("length"), int)
self.assertIsInstance(f.get("dec"), int)
def test_indexes_shape(self):
"""索引必须用 idxfields 数组;唯一键 = (tenant_id, ref_type, ref_id)。"""
names = set()
for idx in self.model["indexes"]:
self.assertIn("idxtype", idx)
self.assertIsInstance(idx["idxfields"], list)
self.assertNotIn(idx["name"], names)
names.add(idx["name"])
uniq = [i for i in self.model["indexes"] if i["idxtype"] == "unique"]
self.assertEqual(len(uniq), 1)
self.assertEqual(uniq[0]["idxfields"], ["tenant_id", "ref_type", "ref_id"])
def test_codes_use_parentid(self):
"""codes 引用 appcodes_kv 必须 cond parentid=,禁止 id=;禁止 module.table 点号。"""
for c in self.model["codes"]:
self.assertNotIn(".", c["table"])
if c["table"] == "appcodes_kv":
self.assertTrue(c["cond"].startswith("parentid="),
"codes cond 必须 parentid= : %s" % c)
fields = [c["field"] for c in self.model["codes"]]
self.assertEqual(len(fields), len(set(fields)), "codes 存在重复 field")
def test_list_fields_match_model(self):
"""base.LIST_FIELDS 必须与 models fields 完全一致(无多无少)。"""
from pbl_domain_ext.base import LIST_FIELDS, ALL_COLUMNS, AUDIT_FIELDS
self.assertEqual(sorted(LIST_FIELDS), sorted(self.fields.keys()))
self.assertEqual(sorted(ALL_COLUMNS), sorted(self.fields.keys()))
for a in AUDIT_FIELDS:
self.assertIn(a, self.fields)
def test_sql_ddl_columns_match_model(self):
"""sql/pbl_domain_ext.sql 的建表列必须覆盖 models 全部列。"""
if not os.path.exists(SQL_PATH):
self.skipTest("sql/pbl_domain_ext.sql 不存在(由 json2ddl 生成)")
with open(SQL_PATH, "r", encoding="utf-8") as fh:
raw_ddl = fh.read()
# 剥离 -- 行注释与 /* */ 块注释:迁移说明注释里合法提到旧列名,
# 只有**生效语句**里的列名才是契约。
ddl = re.sub(r"/\*.*?\*/", "", raw_ddl, flags=re.S)
ddl = "\n".join(ln for ln in ddl.splitlines()
if not ln.strip().startswith("--"))
for col in self.fields:
self.assertRegex(ddl, r"`%s`" % col, "DDL 缺列 %s" % col)
for bad in FORBIDDEN_AUDIT_NAMES:
self.assertNotRegex(ddl, r"`%s`" % bad, "DDL 含禁用旧列名 %s" % bad)
# 生效建表语句必须恰为 1 张表Q-OPEN-3 薄扩展:只新增 pbl_domain_ref
created = re.findall(r"CREATE TABLE(?: IF NOT EXISTS)?\s+`?(\w+)`?", ddl, re.I)
self.assertEqual(created, ["pbl_domain_ref"],
"薄扩展只允许建 1 张 pbl_domain_ref实得 %s" % created)
class TestCrudJsonContract(unittest.TestCase):
"""json/pbl_domain_ref.json 符合 crud-definition-specQC#4并与 models 字段名一致。"""
#: crud-definition-spec 里 params 不允许出现的自创键
FORBIDDEN_PARAM_KEYS = ("filters", "sortname", "sortorder", "rows", "fields",
"columns", "grid", "form", "components", "select_fields")
@classmethod
def setUpClass(cls):
cls.crud = _load(CRUD_PATH)
cls.model_fields = {f["name"] for f in _load(MODEL_PATH)["fields"]}
cls.params = cls.crud["params"]
def test_tblname_and_editable(self):
self.assertEqual(self.crud["tblname"], "pbl_domain_ref")
for key in ("new_data_url", "update_data_url", "delete_data_url"):
self.assertIn(key, self.params, "params 顶层缺 %s" % key)
self.assertIn("entire_url", self.params[key])
self.assertIn("editable", self.params)
def test_no_forbidden_root_keys(self):
"""根键只允许 tblname/alias/title/params禁止 tablename/grid/form/name/type。"""
self.assertNotIn("tablename", self.crud)
for bad in ("tablename", "grid", "form", "name", "type", "components"):
self.assertNotIn(bad, self.crud)
self.assertNotIn(bad, self.params)
def test_no_self_invented_param_keys(self):
"""QC#4filters/sortname/sortorder/rows 是自创键,规范用 sortby + data_filter。"""
for bad in self.FORBIDDEN_PARAM_KEYS:
self.assertNotIn(bad, self.params, "params 含自创键 %s" % bad)
self.assertIsInstance(self.params.get("sortby"), list, "排序必须用 sortby 数组")
self.assertIn("data_filter", self.params, "筛选必须用 data_filter")
def test_data_filter_shape(self):
"""data_filter 必须是 {AND:[{field,op,var|const}]},且 field 都在 models 中。"""
df = self.params["data_filter"]
self.assertIn("AND", df)
self.assertGreaterEqual(len(df["AND"]), 2, "AND 数组长度须 >= 2")
for cond in df["AND"]:
self.assertIn(cond["field"], self.model_fields,
"data_filter 引用未声明列 %s" % cond["field"])
self.assertIn(cond["op"], ("=", "!=", ">", ">=", "<", "<=", "IN",
"NOT IN", "LIKE", "NOT LIKE",
"IS NULL", "IS NOT NULL"))
self.assertTrue("var" in cond or "const" in cond,
"条件必须带 var 或 const: %s" % cond)
def test_browserfields_structure(self):
"""QC#4browserfields 结构为 {exclouded:[...], alters:{field:{uitype,...}}}
不是平铺字段定义。"""
bf = self.params["browserfields"]
self.assertIsInstance(bf, dict)
self.assertIn("alters", bf, "browserfields 缺 alters 段(平铺写法不符合规范)")
self.assertIn("exclouded", bf, "browserfields 缺 exclouded 段")
self.assertIsInstance(bf["exclouded"], list)
for col in bf["exclouded"]:
self.assertIn(col, self.model_fields, "exclouded 列 %s 未在 models 声明" % col)
for col, spec in bf["alters"].items():
self.assertIn(col, self.model_fields, "alters 列 %s 未在 models 声明" % col)
self.assertIsInstance(spec, dict)
self.assertIn("uitype", spec, "alters.%s 缺 uitype" % col)
if spec["uitype"] == "code":
self.assertTrue(spec.get("dataurl") or spec.get("data"),
"code 型字段 %s 必须给 dataurl 或 data" % col)
if spec.get("dataurl"):
self.assertIn("entire_url", spec["dataurl"])
def test_exclouded_not_at_params_top(self):
"""QC#4exclouded 属于 browserfields不得放在 params 顶层。"""
self.assertNotIn("exclouded", self.params)
def test_editexclouded_known_columns(self):
for col in self.params.get("editexclouded", []):
self.assertIn(col, self.model_fields,
"editexclouded 列 %s 未在 models 声明" % col)
def test_editexclouded_covers_not_null_defaults(self):
"""NOT NULL 且非用户可编辑的列必须进 editexclouded否则提交报 cannot be null。"""
model = _load(MODEL_PATH)
auto = {"id", "tenant_id", "creator_id", "created_at", "updater_id",
"updated_at", "is_deleted", "bind_at", "ref_type", "ref_id",
"ref_code", "ref_name"}
edit = set(self.params.get("editexclouded", []))
for f in model["fields"]:
if f.get("nullable") == "no" and f["name"] in auto:
self.assertIn(f["name"], edit,
"NOT NULL 列 %s 未 editexclouded" % f["name"])
def test_data_url_not_duplicated_in_editable(self):
"""QC#12三个写 URL 只在 params 顶层一份,禁止在 editable 内重复定义。
重复定义会让 xls2crud 模板读取歧义(历史上 discount 模块因嵌套导致
自定义 create 逻辑被绕过。editable 只允许保留 get_data_url。
"""
editable = self.params.get("editable", {})
for key in ("new_data_url", "update_data_url", "delete_data_url"):
self.assertNotIn(key, editable,
"params.editable 内不得重复定义 %s(权威位置=params 顶层)" % key)
self.assertIn("get_data_url", editable,
"editable 缺 get_data_url列表取数地址")
def test_field_maxlen_matches_model(self):
"""QC#23base.FIELD_MAXLEN 必须与 models/*.json 的 length 单一来源一致。"""
import sys
sys.path.insert(0, os.path.join(ROOT, "pbl_domain_ext"))
from pbl_domain_ext import base
for f in _load(MODEL_PATH)["fields"]:
name = f["name"]
self.assertIn(name, base.FIELD_MAXLEN, "FIELD_MAXLEN 缺列 %s" % name)
if f.get("type") in ("str", "char") and f.get("length"):
self.assertEqual(base.FIELD_MAXLEN[name], f["length"],
"FIELD_MAXLEN[%s]=%s != model length=%s"
% (name, base.FIELD_MAXLEN[name], f["length"]))
else:
self.assertIsNone(base.FIELD_MAXLEN[name],
"非字符列 %s 不应有截断宽度" % name)
def test_clip_respects_column_width(self):
"""QC#23clip() 按列宽裁剪——blueprint_id=32、ref_code=64、ref_name=128。"""
from pbl_domain_ext import base
self.assertEqual(len(base.clip("blueprint_id", "b" * 80)), 32)
self.assertEqual(len(base.clip("ref_code", "c" * 80)), 64)
self.assertEqual(len(base.clip("class_id", "x" * 80)), 64)
self.assertEqual(len(base.clip("ref_name", "n" * 200)), 128)
self.assertEqual(base.clip("ref_code", " w1 "), "w1")
# 非字符列text/timestamp不截断
self.assertEqual(base.clip("ext_json", "e" * 5000)[:5], "eeeee")
def test_no_new_data_url_query_params(self):
"""new_data_url 不得带 query 参数(避免与 editexclouded 合并成 list"""
url = self.params["new_data_url"]
self.assertNotIn("?", url)
def test_no_jinja_control_blocks(self):
"""CRUD JSON 禁止 Jinja2 控制块,只允许 {{entire_url(...)}} 插值。"""
with open(CRUD_PATH, "r", encoding="utf-8") as fh:
raw = fh.read()
self.assertIsNone(re.search(r"\{%\s*(if|for|each|end)", raw),
"CRUD JSON 含 Jinja2 控制块")
def test_editable_urls_have_dspy_endpoints(self):
"""每个 editable/data_url 指向的 .dspy 必须真实存在于 wwwroot/api/。"""
names = []
for key in ("new_data_url", "update_data_url", "delete_data_url"):
names.append(self.params[key])
for spec in self.params["browserfields"]["alters"].values():
du = spec.get("dataurl", "")
if "pbl_domain_ext" in du:
names.append(du)
api_dir = os.path.join(ROOT, "wwwroot", "api")
existing = set(os.listdir(api_dir))
for url in names:
m = re.search(r"entire_url\('([^']+)'\)", url)
self.assertIsNotNone(m, "URL 未用 entire_url 包裹: %s" % url)
fn = m.group(1).rstrip("/").split("/")[-1]
if "." not in fn: # CRUD alias 目录,由 xls2ui 生成
continue
self.assertIn(fn, existing, "缺少 .dspy 端点文件: %s" % fn)
class TestInitDataContract(unittest.TestCase):
"""QC#5init/data.json Format B 种子必须真实存在且与 codes cond 对齐。"""
@classmethod
def setUpClass(cls):
cls.data = _load(INIT_DATA_PATH)
cls.codes = _load(MODEL_PATH)["codes"]
def test_is_valid_json_format_b(self):
self.assertIn("appcodes", self.data)
self.assertIsInstance(self.data["appcodes"], list)
self.assertGreaterEqual(len(self.data["appcodes"]), 2)
def test_parentids_cover_codes_cond(self):
parents = {g["parentid"] for g in self.data["appcodes"]}
for c in self.codes:
if c["table"] != "appcodes_kv":
continue
m = re.search(r"parentid='([^']+)'", c["cond"])
self.assertIsNotNone(m, "codes cond 解析失败: %s" % c)
self.assertIn(m.group(1), parents,
"codes 引用的编码组 %s 未在 init/data.json 注入" % m.group(1))
def test_items_and_id_length(self):
"""每组必须有 items(k/v)parentid+k 生成的 id 不得超 VARCHAR(32)。"""
for g in self.data["appcodes"]:
self.assertLessEqual(len(g["parentid"]), 22,
"parentid 过长会导致 appcodes_kv.id 超 32: %s" % g["parentid"])
self.assertTrue(g.get("items"), "%s 无 items" % g["parentid"])
for it in g["items"]:
self.assertTrue(it.get("k") and it.get("v"))
self.assertLessEqual(len("%s_%s" % (g["parentid"], it["k"])), 32)
def test_ref_type_items_match_code_enum(self):
"""world/scene/entity 三值必须齐(与 base.REF_TYPES 一致)。"""
from pbl_domain_ext.base import REF_TYPES, BIND_STATES
groups = {g["parentid"]: {it["k"] for it in g["items"]} for g in self.data["appcodes"]}
self.assertEqual(groups["pbl_domain_ref_type"], set(REF_TYPES))
self.assertEqual(groups["pbl_bind_state"], set(BIND_STATES))
class TestIdGeneration(unittest.TestCase):
"""QC#6主键必须走 appPublic.uniqueID.getID禁止 uuid4。"""
def test_no_uuid_in_package(self):
pkg = os.path.join(ROOT, "pbl_domain_ext")
for fn in sorted(os.listdir(pkg)):
if not fn.endswith(".py"):
continue
with open(os.path.join(pkg, fn), "r", encoding="utf-8") as fh:
src = fh.read()
self.assertNotIn("uuid", src, "%s 不得使用 uuid 生成主键" % fn)
def test_base_uses_platform_getid(self):
with open(os.path.join(ROOT, "pbl_domain_ext", "base.py"), "r", encoding="utf-8") as fh:
src = fh.read()
self.assertIn("from appPublic.uniqueID import getID", src)
def test_gen_id_shape(self):
from pbl_domain_ext.base import gen_id
a, b = gen_id(), gen_id()
# 平台 appPublic.uniqueID.getID() 实际返回 21 位;离线降级路径 32 位 hex。
# 两者都在 models 列宽 str(32) 内,断言按「唯一 + 不超列宽」而非固定长度。
self.assertGreaterEqual(len(a), 16, "主键过短,碰撞风险不可接受: %r" % a)
self.assertLessEqual(len(a), 32, "主键超列宽 str(32): %r" % a)
self.assertNotEqual(a, b)
class TestHostAgnosticImports(unittest.TestCase):
"""QC#4模块宿主无关——禁止 import 具体宿主应用sage/pipeline_app 等)。"""
FORBIDDEN_HOSTS = ("sage", "pipeline_app", "hrs6", "hrs7")
ALLOWED_ROOTS = ("ahserver", "sqlor", "appPublic", "apppublic", "pbl_domain_ext",
"json", "os", "re", "sys", "time", "hashlib", "unittest")
def test_no_host_import(self):
pkg = os.path.join(ROOT, "pbl_domain_ext")
pat = re.compile(r"^\s*(?:from|import)\s+([A-Za-z_][\w\.]*)", re.M)
for fn in sorted(os.listdir(pkg)):
if not fn.endswith(".py"):
continue
with open(os.path.join(pkg, fn), "r", encoding="utf-8") as fh:
src = fh.read()
for mod in pat.findall(src):
top = mod.split(".")[0]
self.assertNotIn(top, self.FORBIDDEN_HOSTS,
"%s 违反宿主无关铁律: import %s" % (fn, mod))
def test_serverenv_from_ahserver(self):
with open(os.path.join(ROOT, "pbl_domain_ext", "db.py"), "r", encoding="utf-8") as fh:
src = fh.read()
self.assertIn("from ahserver.serverenv import ServerEnv", src)
self.assertNotIn("from sage", src)
class TestSqlorSignatures(unittest.TestCase):
"""QC#3sor.C/U/D/R 只收 2 参——真实签名断言(不再被 fake_db 掩盖)。"""
def test_no_three_arg_crud_calls(self):
pkg = os.path.join(ROOT, "pbl_domain_ext")
bad = re.compile(r"sor\.[CUDR]\(\s*[^()]*?,\s*[^()]*?,\s*[^()]*?\)")
for fn in sorted(os.listdir(pkg)):
if not fn.endswith(".py"):
continue
with open(os.path.join(pkg, fn), "r", encoding="utf-8") as fh:
src = fh.read()
self.assertIsNone(bad.search(src), "%s 存在 3 参 sor.C/U/D/R 调用" % fn)
def test_sor_I_single_arg(self):
pkg = os.path.join(ROOT, "pbl_domain_ext")
bad = re.compile(r"sor\.I\(\s*[^()]*?,")
for fn in sorted(os.listdir(pkg)):
if not fn.endswith(".py"):
continue
with open(os.path.join(pkg, fn), "r", encoding="utf-8") as fh:
src = fh.read()
self.assertIsNone(bad.search(src), "%s sor.I 只能 1 参" % fn)
def test_update_ref_merges_where_into_ns(self):
"""update_ref 必须把 where 并入 ns 后两参调用 sor.U。"""
calls = []
class FakeSor(object):
async def U(self, table, ns):
calls.append((table, dict(ns)))
return 1
def C(self, *a, **k):
raise AssertionError("C 不应被调用")
from pbl_domain_ext import db as dbm
old = dbm._SOR_HOLDER.get("sor")
dbm.set_sor(FakeSor())
try:
# _sor_u 是同步契约函数:内部 _drive 负责把 async sor.U 的协程收敛掉,
# 测试不得再用 run_until_complete 包一层(协程已被 _drive 消费,
# run_until_complete(None) 会抛 TypeError —— 旧断言的错误写法)。
rc = dbm._sor_u(FakeSor(), "pbl_domain_ref",
{"bind_state": "unbound", "updater_id": "u1"},
{"id": "pk1", "tenant_id": "t1"})
finally:
dbm._SOR_HOLDER["sor"] = old
self.assertEqual(int(rc or 0), 1, "应返回受影响行数")
self.assertEqual(len(calls), 1)
table, ns = calls[0]
self.assertEqual(table, "pbl_domain_ref")
# where 条件必须在 ns 内,且不被 SET 覆盖
self.assertEqual(ns["id"], "pk1")
self.assertEqual(ns["tenant_id"], "t1")
self.assertEqual(ns["bind_state"], "unbound")
if __name__ == "__main__":
unittest.main(verbosity=2)