pbl_blueprint/tests/test_m1b_realdb.py
2026-09-17 16:59:58 +08:00

350 lines
17 KiB
Python
Raw Permalink 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/#7/#8/#9本文件**真实执行**(非 py_compile
使用 sqlite 文件库落盘,断言库内行数与字段值,执行日志由
tools/m1b_run_tests.py 落盘到 projects/pbls/deliverables/m1b/test_logs/。
运行:
python3 modules/pbl_blueprint/tests/test_m1b_realdb.py -v
"""
import json
import os
import shutil
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
for p in (REPO_ROOT, MOD_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
import pbl_blueprint.m1b.init as m1b_init # noqa: E402
from pbl_blueprint.m1b.dbutil import ( # noqa: E402
get_conn, reset_conn, sql_rows, sql_scalar, table_exists,
)
from pbl_blueprint.m1b.errors import ( # noqa: E402
ErrorCode, PblConflict, PblForbidden, PblNotFound, PblValidationError,
TenantMissingError,
)
from pbl_blueprint.m1b.tables import SUBOBJECT_TYPES, TABLES # noqa: E402
from pbl_blueprint.m1b.tenant import require_tenant # noqa: E402
TENANT = "T_M1B_REALDB_001"
OTHER_TENANT = "T_M1B_REALDB_002"
class M1bRealDbTestCase(unittest.TestCase):
"""真实 sqlite 文件库上的 M1b 端到端断言。"""
@classmethod
def setUpClass(cls):
cls.tmpdir = tempfile.mkdtemp(prefix="m1b_realdb_")
cls.db_path = os.path.join(cls.tmpdir, "pbl_m1b_test.sqlite3")
os.environ["PBL_M1B_DB"] = cls.db_path
os.environ["PBL_AUDIT_DIR"] = os.path.join(cls.tmpdir, "audit")
reset_conn()
cls.conn = get_conn(cls.db_path)
cls.load_result = m1b_init.load_m1b(conn=cls.conn,
actor_id="test_m1b_realdb")
@classmethod
def tearDownClass(cls):
reset_conn()
shutil.rmtree(cls.tmpdir, ignore_errors=True)
os.environ.pop("PBL_M1B_DB", None)
os.environ.pop("PBL_AUDIT_DIR", None)
# ---------- 1. DDL 落库证据 ----------
def test_01_all_four_tables_created(self):
"""4 张 M1b 表全部真实建出QC #7表结构落库证据"""
ready = [t["name"] for t in TABLES if table_exists(t["name"], conn=self.conn)]
self.assertEqual(sorted(ready), sorted([t["name"] for t in TABLES]),
"建表缺失: %s" % set(t["name"] for t in TABLES) - set(ready))
self.assertEqual(self.load_result["ddl"]["missing"], [])
self.assertGreater(self.load_result["ddl"]["statements"], 0)
def test_02_ddl_is_idempotent(self):
"""重复执行 load_m1b 不报错、不产生重复注册行QC #8幂等"""
before = {t["name"]: sql_scalar("SELECT COUNT(*) FROM %s" % t["name"],
conn=self.conn)
for t in TABLES}
again = m1b_init.load_m1b(conn=self.conn, actor_id="test_idempotent")
after = {t["name"]: sql_scalar("SELECT COUNT(*) FROM %s" % t["name"],
conn=self.conn)
for t in TABLES}
self.assertEqual(before, after, "重复执行导致行数变化,非幂等")
self.assertEqual(again["ext_field_defs"]["created"], 0,
"第二次注册不应新增扩展字段定义")
self.assertGreater(again["ext_field_defs"]["updated"], 0)
def test_03_indexes_exist_and_unique_keys_tenant_first(self):
"""索引真实建出,且唯一键以 tenant_key 打头(规避 NULL 重复行)。"""
for t in TABLES:
rows = sql_rows("PRAGMA index_list(%s)" % t["name"], conn=self.conn)
names = [r["name"] for r in rows]
for ix in t["indexes"]:
self.assertIn(ix["name"], names,
"%s 缺索引 %s" % (t["name"], ix["name"]))
if ix.get("unique"):
self.assertEqual(ix["columns"][0], "tenant_key",
"%s 唯一键未以 tenant_key 打头" % ix["name"])
def test_04_no_foreign_key_declared(self):
"""Q-OPEN-34 张表均无 FOREIGN KEY。"""
for t in TABLES:
fks = sql_rows("PRAGMA foreign_key_list(%s)" % t["name"], conn=self.conn)
self.assertEqual(fks, [], "%s 不应声明外键" % t["name"])
def test_05_columns_match_table_definition(self):
"""库内列与表定义字段一一对应(模型-DDL-库三方一致QC #5"""
for t in TABLES:
rows = sql_rows("PRAGMA table_info(%s)" % t["name"], conn=self.conn)
db_cols = [r["name"] for r in rows]
expect = [f["name"] for f in t["fields"]]
self.assertEqual(sorted(db_cols), sorted(expect),
"%s 列与定义不一致" % t["name"])
# ---------- 2. 注册同步证据 ----------
def test_06_ext_field_defs_registered_for_all_seven_types(self):
"""7 类子对象都注册到扩展字段定义QC #6/#8库内注册数据"""
rows = sql_rows(
"SELECT DISTINCT owner_type FROM pbl_ext_field_def "
"WHERE tenant_id IS NULL ORDER BY owner_type", conn=self.conn)
owners = [r["owner_type"] for r in rows]
for st in SUBOBJECT_TYPES:
self.assertIn(st, owners, "子对象类型 %s 未注册扩展字段定义" % st)
self.assertIn("template", owners)
self.assertIn("blueprint", owners)
n = sql_scalar("SELECT COUNT(*) FROM pbl_ext_field_def", conn=self.conn)
self.assertEqual(int(n), len(m1b_init.EXT_FIELD_SEED))
def test_07_platform_templates_have_null_tenant_id(self):
"""平台公共模板 tenant_id 为 NULL、tenant_key='__platform__'QC #5 核心)。"""
rows = sql_rows(
"SELECT id, code, version, scope, tenant_id, tenant_key, status "
"FROM pbl_blueprint_template WHERE tenant_id IS NULL "
"ORDER BY code", conn=self.conn)
self.assertGreaterEqual(len(rows), 1, "未注册任何平台公共模板")
for r in rows:
self.assertIsNone(r["tenant_id"])
self.assertEqual(r["tenant_key"], "__platform__")
self.assertEqual(r["scope"], "platform")
self.assertEqual(r["status"], "published")
def test_08_audit_trail_written(self):
"""注册同步产生审计轨迹append-only 落盘)。"""
path = os.path.join(os.environ["PBL_AUDIT_DIR"],
"pbl_blueprint_audit.jsonl")
self.assertTrue(os.path.exists(path), "审计 JSONL 未落盘: %s" % path)
with open(path, "r", encoding="utf-8") as fh:
lines = [ln for ln in fh.read().splitlines() if ln.strip()]
self.assertGreaterEqual(len(lines), 1)
actions = set()
for ln in lines:
rec = json.loads(ln)
actions.add(rec["action"])
self.assertIn("id", rec)
self.assertIn("created_at", rec)
self.assertIn("ddl_apply", actions)
self.assertIn("register_sync", actions)
# ---------- 3. 模板平台公共部分 ----------
def test_09_tenant_can_read_platform_template_but_not_write(self):
"""租户可读平台公共模板;租户写平台模板被拒(写保护)。"""
from pbl_blueprint.m1b.template import (
get_template, list_templates, update_template,
)
items = list_templates(tenant_id=TENANT, conn=self.conn)
plat = [i for i in items if i.get("is_platform")]
self.assertGreaterEqual(len(plat), 1, "租户看不到平台公共模板")
tpl = get_template(tenant_id=TENANT, template_id=plat[0]["id"],
conn=self.conn, allow_offline=False)
self.assertIsNone(tpl["tenant_id"])
self.assertTrue(tpl["is_platform"])
with self.assertRaises(PblForbidden):
update_template(tenant_id=TENANT, template_id=tpl["id"],
data={"name": "租户篡改"}, conn=self.conn)
# 平台角色可写
ok_row = update_template(tenant_id=None, template_id=tpl["id"],
data={"description": "平台侧更新"},
role="platform", conn=self.conn)
self.assertEqual(ok_row["description"], "平台侧更新")
def test_10_tenant_cannot_create_platform_template(self):
"""非平台角色创建 scope=platform 模板 -> PblForbidden。"""
from pbl_blueprint.m1b.template import create_template
with self.assertRaises(PblForbidden):
create_template(tenant_id=TENANT,
data={"code": "hack.platform", "name": "越权",
"scope": "platform"}, conn=self.conn)
def test_11_tenant_template_requires_tenant_id(self):
"""租户模板缺 tenant_id -> TenantMissingErrorfail-closed"""
from pbl_blueprint.m1b.template import create_template
with self.assertRaises(TenantMissingError):
create_template(tenant_id=None,
data={"code": "no.tenant", "name": "无租户",
"scope": "tenant"}, conn=self.conn)
def test_12_template_unique_key_conflict(self):
"""同 (tenant_key, code, version) 重复创建 -> PblConflict。"""
from pbl_blueprint.m1b.template import create_template
payload = {"code": "tenant.dup", "name": "重复模板", "scope": "tenant",
"version": "1.0.0"}
create_template(tenant_id=TENANT, data=dict(payload), conn=self.conn)
with self.assertRaises(PblConflict):
create_template(tenant_id=TENANT, data=dict(payload), conn=self.conn)
def test_13_cross_tenant_template_isolation(self):
"""A 租户模板对 B 租户不可见(隔离)。"""
from pbl_blueprint.m1b.template import create_template, get_template
row = create_template(tenant_id=TENANT,
data={"code": "tenant.private", "name": "私有",
"scope": "tenant", "version": "1.0.0"},
conn=self.conn)
with self.assertRaises(PblNotFound):
get_template(tenant_id=OTHER_TENANT, template_id=row["id"],
conn=self.conn, allow_offline=False)
# ---------- 4. 实例化 + 离线兜底 ----------
def test_14_instantiate_creates_blueprint_and_subobjects(self):
"""模板实例化真实产出蓝图 + 7 类子对象 + 扩展值QC #4/#6"""
from pbl_blueprint.m1b.template import instantiate_template
plat = sql_rows(
"SELECT id, code FROM pbl_blueprint_template WHERE tenant_id IS NULL "
"ORDER BY code LIMIT 1", conn=self.conn)[0]
res = instantiate_template(
TENANT, template_id=plat["id"], actor_id="tester", conn=self.conn)
self.assertTrue(res["ok"])
self.assertFalse(res["fallback"], "库内有模板,不应走离线兜底")
self.assertTrue(res["blueprint"]["id"])
self.assertEqual(res["template"]["scope"], "platform")
self.assertGreaterEqual(res["counts"]["__total__"], 1,
"未实例化出任何子对象")
self.assertGreaterEqual(res["counts"]["ext_written"], 1,
"未写入任何扩展字段值")
# 子对象行真实落库
for st in SUBOBJECT_TYPES:
if res["counts"][st]:
n = sql_scalar(
"SELECT COUNT(*) FROM pbl_subobject_ext WHERE tenant_id = ? "
"AND blueprint_id = ? AND subobject_type = ?",
[TENANT, res["blueprint"]["id"], st], conn=self.conn)
self.assertIsNotNone(n)
def test_15_instantiate_requires_tenant(self):
"""实例化缺租户 -> TenantMissingError。"""
from pbl_blueprint.m1b.template import instantiate_template
with self.assertRaises(TenantMissingError):
instantiate_template(None, code="platform.pbl.stem", conn=self.conn)
def test_16_offline_fallback_marks_reason(self):
"""库中不存在的模板走离线兜底,且如实标注 source/fallback_reasonQC 要求)。"""
from pbl_blueprint.m1b.template import get_template, load_offline_templates
items = load_offline_templates(reason="unit-test forced")
if not items:
self.skipTest("离线模板包不存在,跳过兜底断言")
self.assertEqual(items[0]["source"], "offline_fallback")
self.assertIn("unit-test forced", items[0]["fallback_reason"])
self.assertFalse(items[0]["writable"], "离线兜底模板必须只读")
hit = get_template(tenant_id=TENANT, code=items[0]["code"],
version=items[0]["version"], conn=self.conn)
self.assertEqual(hit["source"], "offline_fallback")
def test_17_instantiate_from_offline_template(self):
"""离线模板也能完成实例化DB 模板缺失时的兜底可用性)。"""
from pbl_blueprint.m1b.template import (
instantiate_template, load_offline_templates,
)
items = load_offline_templates(reason="unit-test forced")
if not items:
self.skipTest("离线模板包不存在")
code = items[-1]["code"]
# 确保库里没有该 code 的租户可见模板
res = instantiate_template(TENANT, code=code,
version=items[-1]["version"],
actor_id="tester", conn=self.conn)
self.assertTrue(res["ok"])
self.assertTrue(res["fallback"])
self.assertTrue(res["fallback_reason"])
# ---------- 5. 租户上下文 ----------
def test_18_require_tenant_fail_closed(self):
"""require_tenant 对空/空白/'null' 一律抛错fail-closed"""
for bad in (None, "", " ", "null", "None"):
with self.assertRaises(TenantMissingError):
require_tenant(bad)
self.assertEqual(require_tenant(TENANT), TENANT)
self.assertIsNone(require_tenant(None, allow_null=True))
def test_19_m1b_status_reports_counts(self):
"""m1b_status 输出可核验的落库状态快照。"""
st = m1b_init.m1b_status(conn=self.conn)
self.assertTrue(all(st["tables"].values()), "存在未建出的表")
self.assertGreater(st["counts"]["pbl_ext_field_def"], 0)
self.assertGreater(st["counts"]["pbl_blueprint_template"], 0)
self.assertEqual(st["subobject_types"], list(SUBOBJECT_TYPES))
# ---------- 6. API 层pbl_agent_runtime 依赖面) ----------
def test_20_api_surface_available(self):
"""pbl_blueprint.api 暴露 pbl_template_instantiate / pbl_blueprint_create。"""
from pbl_blueprint.m1b.api import (
M1B_API_REGISTRY, pbl_blueprint_create, pbl_template_instantiate,
)
self.assertTrue(callable(pbl_template_instantiate))
self.assertTrue(callable(pbl_blueprint_create))
self.assertIn("pbl_template_instantiate", M1B_API_REGISTRY)
self.assertIn("pbl_blueprint_create", M1B_API_REGISTRY)
self.assertGreaterEqual(len(M1B_API_REGISTRY), 20)
def test_21_api_returns_unified_envelope(self):
"""API 返回统一响应包异常不外泄ok/code/message/data"""
from pbl_blueprint.m1b.api import pbl_template_instantiate
res = pbl_template_instantiate(tenant_id=None)
self.assertFalse(res["ok"])
self.assertEqual(res["code"], ErrorCode.TENANT_MISSING)
self.assertIn("http_status", res)
res2 = pbl_template_instantiate(tenant_id=TENANT, code="platform.pbl.stem")
self.assertTrue(res2["ok"], res2)
self.assertIn("counts", res2["data"])
def test_22_api_blueprint_create_direct_and_from_template(self):
"""pbl_blueprint_create 两种形态:直接建 / 由模板派生。"""
from pbl_blueprint.m1b.api import pbl_blueprint_create
r1 = pbl_blueprint_create(tenant_id=TENANT,
data={"name": "直接创建的蓝图"})
self.assertTrue(r1["ok"], r1)
self.assertTrue(r1["data"]["id"])
r2 = pbl_blueprint_create(tenant_id=TENANT,
data={"template_code": "platform.pbl.stem",
"name": "由模板派生"})
self.assertTrue(r2["ok"], r2)
self.assertIn("subobjects", r2["data"])
if __name__ == "__main__":
unittest.main(verbosity=2)