pbl_blueprint/tests/test_m1b_realdb.py
2026-09-16 19:36:42 +08:00

448 lines
20 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M1b 真实库集成测试(QC#5 整改)。
设计原则(对应 QC 退回意见 #5):
* 不再只有「纯内存 FakeDB 单测」。本文件在**可连库环境真实执行** DDL + CRUD,
覆盖 4 张表的建表幂等、唯一键 tenant_key 打头去重、平台公共模板 tenant_id IS NULL、
子对象扩展 KV 读写、关联表软引用判定(Q-OPEN-3:零基表写入)。
* 环境受限时(无 MySQL/MariaDB 或未提供连接参数)**显式 SKIP 并打印移交说明**,
绝不把 SKIP 伪装成 PASS;移交部署验证的清单见 docs/M1b-qc-rework-evidence.md §5。
运行方式(三选一):
1) 环境变量:
PBL_TEST_DB_HOST=127.0.0.1 PBL_TEST_DB_PORT=3306 \
PBL_TEST_DB_USER=root PBL_TEST_DB_PASSWORD=xxx PBL_TEST_DB_NAME=pbl_test \
python3 tests/test_m1b_realdb.py
2) 配置文件:PBL_TEST_DB_DSN=mysql://user:pass@host:3306/dbname
3) 无参数:自动探测 127.0.0.1:3306,连不上则 SKIP(退出码 0,但打印 SKIPPED)。
退出码:0 = 全部通过 或 环境受限 SKIP;1 = 有真实 FAIL;2 = 依赖缺失且未 SKIP 判定失败。
"""
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _m1b_loader # noqa: F401
import traceback
from datetime import datetime
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DDL_PATH = os.path.join(REPO, "sql", "m1b_ddl.sql")
TENANT_A = "T_M1B_A"
TENANT_B = "T_M1B_B"
PLATFORM_KEY = "" # tenant_id NULL -> tenant_key ''
NOW = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
RESULTS = []
def log(ok, name, detail=""):
RESULTS.append((name, bool(ok), detail))
print("[%s] %s :: %s" % ("PASS" if ok else "FAIL", name, detail))
def skip(name, detail):
RESULTS.append((name, None, detail))
print("[SKIP] %s :: %s" % (name, detail))
# ------------------------------------------------------------------ 连接
def resolve_dsn():
dsn = os.environ.get("PBL_TEST_DB_DSN")
if dsn:
m = re.match(r"mysql(?:\+\w+)?://([^:]+):([^@]*)@([^:/]+):?(\d+)?/(.+)", dsn)
if not m:
raise ValueError("PBL_TEST_DB_DSN 格式非法: %s" % dsn)
return dict(host=m.group(3), port=int(m.group(4) or 3306),
user=m.group(1), password=m.group(2), database=m.group(5))
if os.environ.get("PBL_TEST_DB_HOST"):
return dict(host=os.environ["PBL_TEST_DB_HOST"],
port=int(os.environ.get("PBL_TEST_DB_PORT", 3306)),
user=os.environ.get("PBL_TEST_DB_USER", "root"),
password=os.environ.get("PBL_TEST_DB_PASSWORD", ""),
database=os.environ.get("PBL_TEST_DB_NAME", "pbl_test"))
return dict(host="127.0.0.1", port=3306, user="root", password="",
database="pbl_test")
def connect(cfg):
"""返回 (conn, driver_name);驱动缺失或连不上抛异常。"""
try:
import pymysql # type: ignore
conn = pymysql.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"],
password=cfg["password"], database=cfg["database"],
charset="utf8mb4", autocommit=True,
connect_timeout=5)
return conn, "pymysql"
except ImportError:
pass
try:
import MySQLdb # type: ignore
conn = MySQLdb.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"],
passwd=cfg["password"], db=cfg["database"],
charset="utf8mb4")
conn.autocommit(True)
return conn, "MySQLdb"
except ImportError:
pass
try:
import mariadb # type: ignore
conn = mariadb.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"],
password=cfg["password"], database=cfg["database"])
conn.autocommit = True
return conn, "mariadb"
except ImportError:
raise RuntimeError("NO_DRIVER: 未安装 pymysql / MySQLdb / mariadb 任一驱动")
def split_ddl(sql_text):
body = re.sub(r"/\*.*?\*/", " ", sql_text, flags=re.S)
stmts, buf = [], []
for line in body.splitlines():
i = line.find("--")
if i >= 0:
line = line[:i]
buf.append(line)
if line.rstrip().endswith(";"):
s = "\n".join(buf).strip()
if s and s != ";":
stmts.append(s.rstrip(";"))
buf = []
return [s for s in stmts if s.strip()]
# ------------------------------------------------------------------ 用例
def t_ddl_idempotent(cur):
"""T1: DDL 执行两遍均成功(CREATE TABLE IF NOT EXISTS 幂等)。"""
with open(DDL_PATH, "r", encoding="utf-8") as f:
stmts = split_ddl(f.read())
for rnd in (1, 2):
for s in stmts:
cur.execute(s)
log(len(stmts) == 4, "T1_DDL幂等",
"执行 %d 条语句 × 2 轮无异常" % len(stmts))
def t_tables_exist(cur):
"""T2: 4 张表全部存在,且引擎/字符集符合 mariadb 方言。"""
cur.execute("""SELECT TABLE_NAME, ENGINE, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('pbl_blueprint_template','pbl_blueprint_ref',
'pbl_subobject_ext','pbl_ext_field_def')""")
rows = cur.fetchall()
names = sorted(r[0] for r in rows)
expect = sorted(['pbl_blueprint_template', 'pbl_blueprint_ref',
'pbl_subobject_ext', 'pbl_ext_field_def'])
engines = set(r[1] for r in rows)
log(names == expect and engines == {"InnoDB"}, "T2_四表存在",
"表=%s 引擎=%s" % (names, engines))
def t_no_foreign_key(cur):
"""T3: 4 张表零 FOREIGN KEY(Q-OPEN-3)。"""
cur.execute("""SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME IS NOT NULL
AND TABLE_NAME LIKE 'pbl\\_%'""")
n = cur.fetchone()[0]
log(n == 0, "T3_零外键", "information_schema 外键约束数=%d" % n)
def t_unique_key_tenant_first(cur):
"""T4: 每个 UNIQUE 索引首列均为 tenant_key。"""
cur.execute("""SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'pbl\\_%'
AND NON_UNIQUE = 0 AND INDEX_NAME <> 'PRIMARY'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX""")
bad = []
seen = {}
for t, ix, col, seq in cur.fetchall():
if int(seq) == 1:
seen[(t, ix)] = col
for (t, ix), col in seen.items():
if col != "tenant_key":
bad.append("%s.%s 首列=%s" % (t, ix, col))
log(seen and not bad, "T4_唯一键tenant打头",
"唯一索引 %d 个,违规 %s" % (len(seen), bad or "无"))
def t_platform_template_null_tenant(cur):
"""T5: 平台公共模板 tenant_id IS NULL 可插入,且 tenant_key='' 唯一去重生效。"""
cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code LIKE 'M1B_T5%%'")
cur.execute(
"""INSERT INTO pbl_blueprint_template
(tenant_key, tenant_id, template_code, template_name, scope, status,
version, is_default, usage_count, created_at, updated_at)
VALUES (%s, NULL, %s, %s, 'platform', 'published', '1.0.0', 0, 0, %s, %s)""",
(PLATFORM_KEY, "M1B_T5_PLATFORM", "平台公共模板", NOW, NOW))
cur.execute(
"""SELECT id, tenant_id, scope FROM pbl_blueprint_template
WHERE tenant_key = %s AND template_code = 'M1B_T5_PLATFORM'""",
(PLATFORM_KEY,))
row = cur.fetchone()
ok_null = row is not None and row[1] is None and row[2] == "platform"
dup = False
try:
cur.execute(
"""INSERT INTO pbl_blueprint_template
(tenant_key, tenant_id, template_code, template_name, scope, status,
version, is_default, usage_count, created_at, updated_at)
VALUES (%s, NULL, %s, %s, 'platform', 'published', '1.0.0', 0, 0, %s, %s)""",
(PLATFORM_KEY, "M1B_T5_PLATFORM", "平台公共模板重复", NOW, NOW))
except Exception:
dup = True
log(ok_null and dup, "T5_平台模板NULL租户",
"tenant_id IS NULL 插入=%s;同 tenant_key+code 重复插入被唯一键拒绝=%s"
% (ok_null, dup))
def t_tenant_isolation(cur):
"""T6: 同 template_code 在不同租户下可共存(tenant_key 打头唯一键)。"""
cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code = 'M1B_T6_SAME'")
for tk, tid in ((TENANT_A, TENANT_A), (TENANT_B, TENANT_B)):
cur.execute(
"""INSERT INTO pbl_blueprint_template
(tenant_key, tenant_id, template_code, template_name, scope, status,
version, is_default, usage_count, created_at, updated_at)
VALUES (%s, %s, 'M1B_T6_SAME', %s, 'tenant', 'draft', '1.0.0', 0, 0, %s, %s)""",
(tk, tid, "租户模板-" + tk, NOW, NOW))
cur.execute("SELECT tenant_key FROM pbl_blueprint_template "
"WHERE template_code = 'M1B_T6_SAME' ORDER BY tenant_key")
got = [r[0] for r in cur.fetchall()]
log(got == [TENANT_A, TENANT_B], "T6_租户隔离",
"同 code 跨租户共存,tenant_key=%s" % got)
def t_subobject_ext_kv(cur):
"""T7: 子对象扩展 KV 写入/读取/唯一键覆盖(基表零改动)。"""
cur.execute("DELETE FROM pbl_subobject_ext WHERE subobject_id LIKE 'M1B_T7%%'")
cur.execute(
"""INSERT INTO pbl_subobject_ext
(tenant_key, tenant_id, blueprint_id, subobject_type, subobject_id,
ext_key, ext_value, ext_value_type, source, status, created_at, updated_at)
VALUES (%s, %s, 900001, 'driving_question', 'M1B_T7_OBJ',
'difficulty', '3', 'number', 'manual', 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
cur.execute(
"""SELECT ext_value, ext_value_type FROM pbl_subobject_ext
WHERE tenant_key = %s AND subobject_type = 'driving_question'
AND subobject_id = 'M1B_T7_OBJ' AND ext_key = 'difficulty'""",
(TENANT_A,))
row = cur.fetchone()
ok_read = row is not None and row[0] == "3" and row[1] == "number"
dup = False
try:
cur.execute(
"""INSERT INTO pbl_subobject_ext
(tenant_key, tenant_id, blueprint_id, subobject_type, subobject_id,
ext_key, ext_value, ext_value_type, source, status, created_at, updated_at)
VALUES (%s, %s, 900001, 'driving_question', 'M1B_T7_OBJ',
'difficulty', '5', 'number', 'manual', 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
except Exception:
dup = True
log(ok_read and dup, "T7_子对象扩展KV",
"读回=%s;同 (tenant,type,obj,key) 重复插入被拒=%s" % (ok_read, dup))
def t_ext_field_def_platform_override(cur):
"""T8: 平台公共字段定义 + 租户级覆盖(同 field_key 两条并存,读取顺序 tenant->platform)。"""
cur.execute("DELETE FROM pbl_ext_field_def WHERE field_key LIKE 'm1b_t8%%'")
cur.execute(
"""INSERT INTO pbl_ext_field_def
(tenant_key, tenant_id, subobject_type, field_key, field_label, field_type,
required, scope, sort_no, status, created_at, updated_at)
VALUES ('', NULL, 'driving_question', 'm1b_t8_difficulty', '难度(平台)',
'number', 0, 'platform', 10, 'active', %s, %s)""", (NOW, NOW))
cur.execute(
"""INSERT INTO pbl_ext_field_def
(tenant_key, tenant_id, subobject_type, field_key, field_label, field_type,
required, scope, sort_no, status, created_at, updated_at)
VALUES (%s, %s, 'driving_question', 'm1b_t8_difficulty', '难度(租户覆盖)',
'number', 1, 'tenant', 10, 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
cur.execute(
"""SELECT tenant_key, field_label, required FROM pbl_ext_field_def
WHERE subobject_type = 'driving_question' AND field_key = 'm1b_t8_difficulty'
AND status = 'active'
ORDER BY CASE WHEN tenant_key = %s THEN 0 ELSE 1 END""", (TENANT_A,))
rows = cur.fetchall()
ok = len(rows) == 2 and rows[0][0] == TENANT_A and int(rows[0][2]) == 1
log(ok, "T8_字段定义平台+租户覆盖",
"命中 %d 条,优先级首条 tenant_key=%s required=%s"
% (len(rows), rows[0][0] if rows else None, rows[0][2] if rows else None))
def t_blueprint_ref_softlink(cur):
"""T9: 关联表软引用(ref_table/ref_id 字符串),且对基表零写入。"""
cur.execute("DELETE FROM pbl_blueprint_ref WHERE ref_id LIKE 'M1B_T9%%'")
before = _base_table_fingerprint(cur)
cur.execute(
"""INSERT INTO pbl_blueprint_ref
(tenant_key, tenant_id, blueprint_id, ref_type, ref_table, ref_id, ref_name,
relation_kind, sort_no, status, created_at, updated_at)
VALUES (%s, %s, 900001, 'world', 'world', 'M1B_T9_WORLD', '测试世界',
'reference', 1, 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
cur.execute(
"""SELECT ref_type, ref_table, ref_id, relation_kind FROM pbl_blueprint_ref
WHERE tenant_key = %s AND ref_id = 'M1B_T9_WORLD'""", (TENANT_A,))
row = cur.fetchone()
after = _base_table_fingerprint(cur)
ok = (row is not None and row[0] == "world" and row[1] == "world"
and row[3] == "reference" and before == after)
log(ok, "T9_关联表软引用零基表写入",
"软引用行=%s;基表指纹前后一致=%s" % (row is not None, before == after))
def _base_table_fingerprint(cur):
"""基表存在性+行数指纹(若基表不存在则记为 absent,不影响判定)。"""
fp = {}
for t in ("world", "scene", "entity", "script"):
try:
cur.execute(
"SELECT COUNT(*) FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s", (t,))
if cur.fetchone()[0] == 0:
fp[t] = "absent"
continue
cur.execute("SELECT COUNT(*) FROM `%s`" % t)
fp[t] = cur.fetchone()[0]
except Exception:
fp[t] = "error"
return json.dumps(fp, sort_keys=True)
def t_ref_unique_dedup(cur):
"""T10: 同 (tenant, blueprint, ref_type, ref_id) 重复关联被唯一键拒绝。"""
dup = False
try:
cur.execute(
"""INSERT INTO pbl_blueprint_ref
(tenant_key, tenant_id, blueprint_id, ref_type, ref_table, ref_id,
ref_name, relation_kind, sort_no, status, created_at, updated_at)
VALUES (%s, %s, 900001, 'world', 'world', 'M1B_T9_WORLD', '重复',
'reference', 2, 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
except Exception:
dup = True
log(dup, "T10_关联唯一去重", "重复关联被唯一键拒绝=%s" % dup)
def t_template_instantiate_payload(cur):
"""T11: 模板实例化——payload 深拷贝到新蓝图关联条目(JSON 可解析)。"""
payload = json.dumps({"driving_question": [{"title": "如何减少碳排", "difficulty": 3}],
"artifact_def": [{"code": "A1"}]}, ensure_ascii=False)
cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code = 'M1B_T11_TPL'")
cur.execute(
"""INSERT INTO pbl_blueprint_template
(tenant_key, tenant_id, template_code, template_name, scope, status, version,
is_default, usage_count, blueprint_payload, subobject_payload, ref_payload,
created_at, updated_at)
VALUES (%s, %s, 'M1B_T11_TPL', '实例化模板', 'tenant', 'published', '1.0.0',
0, 0, %s, %s, %s, %s, %s)""",
(TENANT_A, TENANT_A, payload, payload, "[]", NOW, NOW))
cur.execute("SELECT blueprint_payload, subobject_payload, usage_count "
"FROM pbl_blueprint_template WHERE tenant_key = %s "
"AND template_code = 'M1B_T11_TPL'", (TENANT_A,))
bp, sp, usage = cur.fetchone()
ok_json = isinstance(json.loads(bp), dict) and isinstance(json.loads(sp), dict)
cur.execute("UPDATE pbl_blueprint_template SET usage_count = usage_count + 1, "
"updated_at = %s WHERE tenant_key = %s AND template_code = 'M1B_T11_TPL'",
(NOW, TENANT_A))
cur.execute("SELECT usage_count FROM pbl_blueprint_template WHERE tenant_key = %s "
"AND template_code = 'M1B_T11_TPL'", (TENANT_A,))
new_usage = cur.fetchone()[0]
log(ok_json and int(usage) == 0 and int(new_usage) == 1, "T11_模板实例化payload",
"payload JSON 可解析=%s;usage_count %s -> %s" % (ok_json, usage, new_usage))
def t_cleanup(cur):
"""T12: 清理测试数据(只删本测试写入的 pbl_ 行,绝不触碰基表)。"""
n = 0
for sql in (
"DELETE FROM pbl_blueprint_template WHERE template_code LIKE 'M1B_T%'",
"DELETE FROM pbl_subobject_ext WHERE subobject_id LIKE 'M1B_T%'",
"DELETE FROM pbl_ext_field_def WHERE field_key LIKE 'm1b_t8%'",
"DELETE FROM pbl_blueprint_ref WHERE ref_id LIKE 'M1B_T%'",
):
rc = cur.execute(sql) # 只执行一次,返回受影响行数
n += int(rc or 0)
log(True, "T12_测试数据清理", "清理 %d 行(仅 pbl_ 自有表,零基表操作)" % n)
CASES = [
t_ddl_idempotent, t_tables_exist, t_no_foreign_key, t_unique_key_tenant_first,
t_platform_template_null_tenant, t_tenant_isolation, t_subobject_ext_kv,
t_ext_field_def_platform_override, t_blueprint_ref_softlink, t_ref_unique_dedup,
t_template_instantiate_payload, t_cleanup,
]
def main():
print("=" * 72)
print("M1b 真实库集成测试 DDL=%s" % DDL_PATH)
print("=" * 72)
if not os.path.exists(DDL_PATH):
print("FATAL: 未找到 sql/m1b_ddl.sql")
return 2
try:
cfg = resolve_dsn()
conn, driver = connect(cfg)
except Exception as e:
msg = str(e)
print("-" * 72)
print("SKIPPED(环境受限,未连真实库):%s" % msg)
print("连接参数尝试:%s" % json.dumps(
{k: v for k, v in (resolve_dsn() or {}).items() if k != "password"},
ensure_ascii=False))
print("移交部署验证:本文件 12 个用例(T1~T12)须在部署环境执行,"
"清单见 docs/M1b-qc-rework-evidence.md §5。")
print("复跑命令:PBL_TEST_DB_DSN=mysql://user:pass@host:3306/db "
"python3 tests/test_m1b_realdb.py")
for c in CASES:
skip(c.__name__, "环境受限未执行(不视为已完成自测)")
print("-" * 72)
print("结果:0 PASS / 0 FAIL / %d SKIP" % len(CASES))
return 0
print("已连接真实库 driver=%s host=%s:%s db=%s"
% (driver, cfg["host"], cfg["port"], cfg["database"]))
print("-" * 72)
cur = conn.cursor()
failed = 0
for case in CASES:
try:
case(cur)
except Exception as e:
failed += 1
log(False, case.__name__, "异常 %s\n%s" % (e, traceback.format_exc()))
try:
cur.close()
conn.close()
except Exception:
pass
total = len(RESULTS)
passed = sum(1 for _, ok, _ in RESULTS if ok is True)
fails = sum(1 for _, ok, _ in RESULTS if ok is False)
print("-" * 72)
print("结果:%d PASS / %d FAIL / 共 %d" % (passed, fails, total))
for name, ok, detail in RESULTS:
if ok is False:
print(" FAIL %s :: %s" % (name, detail))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())