pbl_domain_ext/tests/test_domain_ref.py
2026-09-18 18:18:56 +08:00

449 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 -*-
"""[M8] pbl_domain_ext 契约测试world/scene/entity 薄扩展)。
运行(环境无 pytest 时用 unittest二者皆可::
cd modules/pbl_domain_ext
python3 -m unittest discover -s tests -p 'test_*.py' -v
python3 -m pytest tests/ -q # 若环境已装 pytest
覆盖13 个 dspy 契约对应的 api 函数 × 租户隔离 / 权限(只读基表) / 正常 / 异常 四类用例,
外加薄扩展铁律(不写基表、表总账只有 pbl_domain_ref 一张新表、ext_json 字段名对齐设计 §J1
"""
import os
import sys
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
for p in (ROOT, HERE):
if p not in sys.path:
sys.path.insert(0, p)
import fake_db # noqa: E402
from pbl_domain_ext import api, db, base # noqa: E402
from pbl_domain_ext.base import EXT_FIELD, REF_TYPES, TABLE # noqa: E402
T1 = "T1" # 主测租户BASE_FIXTURES 中 world W1/W2、scene S1..S3、entity E1..E3
T2 = "T2" # 他租户BASE_FIXTURES 中仅 world W3
OP = "U_TESTER"
class BaseCase(unittest.TestCase):
"""每个用例独立内存库,互不污染。"""
def setUp(self):
self.sor = fake_db.make_fake_sor()
db.set_sor(self.sor)
def tearDown(self):
db.set_sor(None)
# ---- 断言助手 -------------------------------------------------------
def assertOk(self, resp, msg=""):
self.assertIsInstance(resp, dict, "契约必须返回 dict 响应体")
self.assertTrue(resp.get("success"), "%s 期望成功,实得 %r" % (msg, resp))
self.assertEqual(resp.get("code"), "PBL_DE_OK", msg)
return resp.get("data")
def assertErr(self, resp, codes, msg=""):
self.assertIsInstance(resp, dict, "契约必须返回 dict 响应体(不得裸抛栈)")
self.assertFalse(resp.get("success"), "%s 期望失败,实得 %r" % (msg, resp))
if isinstance(codes, str):
codes = (codes,)
self.assertIn(resp.get("code"), codes,
"%s 错误码期望 %s,实得 %r" % (msg, list(codes), resp))
self.assertIn("http_status", resp, "失败响应必须带 http_status")
return resp
def bind(self, ref_type, ref_id, tenant_id=T1, **kw):
params = {"tenant_id": tenant_id, "ref_type": ref_type,
"ref_id": ref_id, "operator_id": OP}
params.update(kw)
return api.pbl_domain_ref_bind(params)
def rows(self, table=TABLE):
return list(self.sor.tables.get(table, []))
# ==========================================================================
# 1. 关联表 pbl_domain_ref绑定 / 解绑 / 更新 / 查询
# ==========================================================================
class TestBind(BaseCase):
def test_bind_world_ok(self):
data = self.assertOk(self.bind("world", "W1", blueprint_id="BP1",
class_id="C1", team_id="TEAM_A",
ext_json='{"role":"host"}'))
self.assertEqual(data["ref_type"], "world")
self.assertEqual(str(data["ref_id"]), "W1")
self.assertEqual(data["blueprint_id"], "BP1")
self.assertEqual(data["bind_state"], "bound")
self.assertEqual(data["created_by"], OP)
self.assertEqual(len(self.rows()), 1, "应落 1 行关联记录")
def test_bind_scene_and_entity_ok(self):
self.assertOk(self.bind("scene", "S1"))
self.assertOk(self.bind("entity", "E1"))
self.assertEqual(sorted(r["ref_type"] for r in self.rows()),
["entity", "scene"])
def test_bind_is_idempotent_upsert(self):
self.assertOk(self.bind("world", "W1", blueprint_id="BP1"))
self.assertOk(self.bind("world", "W1", blueprint_id="BP2"))
self.assertEqual(len(self.rows()), 1, "同键重复绑定不得产生重复行(幂等 upsert")
self.assertEqual(self.rows()[0]["blueprint_id"], "BP2")
def test_bind_requires_tenant(self):
self.assertErr(api.pbl_domain_ref_bind({"ref_type": "world", "ref_id": "W1"}),
"PBL_DE_TENANT_MISSING", "缺 tenant_id")
def test_bind_rejects_bad_ref_type(self):
self.assertErr(self.bind("course", "W1"), "PBL_DE_REF_TYPE_INVALID",
"ref_type 仅允许 world/scene/entity")
def test_bind_rejects_empty_ref_id(self):
self.assertErr(self.bind("world", ""), "PBL_DE_PARAM_INVALID", "ref_id 为空")
def test_bind_rejects_missing_base_row(self):
self.assertErr(self.bind("world", "W_NOT_EXIST"),
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND"),
"基表未命中的对象不得建立关联")
def test_bind_rejects_invalid_ext_json(self):
self.assertErr(self.bind("world", "W1", ext_json="{not json"),
"PBL_DE_EXT_JSON_INVALID", "ext_json 必须合法 JSON")
def test_bind_tenant_isolation(self):
self.assertOk(self.bind("world", "W1", tenant_id=T1))
self.assertErr(self.bind("world", "W1", tenant_id=T2),
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"),
"W1 属 T1T2 不得绑定")
class TestUnbindUpdateGet(BaseCase):
def test_unbind_soft_delete(self):
self.assertOk(self.bind("world", "W1"))
data = self.assertOk(api.pbl_domain_ref_unbind(
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "operator_id": OP}))
self.assertIn(data.get("bind_state", "unbound"), ("unbound", "deleted"))
alive = [r for r in self.rows() if str(r.get("is_deleted", "0")) in ("0", "")]
self.assertEqual(alive, [], "解绑后不应再有生效关联行")
def test_unbind_missing(self):
self.assertErr(api.pbl_domain_ref_unbind(
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1"}),
"PBL_DE_NOT_FOUND", "解绑不存在的关联")
def test_unbind_cross_tenant_denied(self):
self.assertOk(self.bind("world", "W1", tenant_id=T1))
self.assertErr(api.pbl_domain_ref_unbind(
{"tenant_id": T2, "ref_type": "world", "ref_id": "W1"}),
("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), "跨租户不得解绑他租户关联")
def test_update_ext_json_ok(self):
self.assertOk(self.bind("world", "W1", ext_json='{"a":1}'))
data = self.assertOk(api.pbl_domain_ref_update(
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1",
"ext_json": '{"a":2,"b":"x"}', "class_id": "C9", "operator_id": OP}))
self.assertEqual(data["class_id"], "C9")
self.assertIn("ext_json", data, "响应必须用设计 §J1 权威字段名 ext_json")
self.assertEqual(self.rows()[0][EXT_FIELD] and '"a": 2' in self.rows()[0][EXT_FIELD]
or '"a":2' in self.rows()[0][EXT_FIELD], True)
self.assertEqual(self.rows()[0]["updated_by"], OP)
def test_update_missing(self):
self.assertErr(api.pbl_domain_ref_update(
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "class_id": "C1"}),
"PBL_DE_NOT_FOUND", "更新不存在的关联")
def test_update_cross_tenant_denied(self):
self.assertOk(self.bind("world", "W1", tenant_id=T1))
self.assertErr(api.pbl_domain_ref_update(
{"tenant_id": T2, "ref_type": "world", "ref_id": "W1", "class_id": "X"}),
("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), "跨租户不得改他租户关联")
def test_get_by_id_and_by_key(self):
bound = self.assertOk(self.bind("scene", "S1", blueprint_id="BP7"))
by_id = self.assertOk(api.pbl_domain_ref_get({"tenant_id": T1, "id": bound["id"]}))
self.assertEqual(by_id["id"], bound["id"])
by_key = self.assertOk(api.pbl_domain_ref_get(
{"tenant_id": T1, "ref_type": "scene", "ref_id": "S1", "with_base": 1}))
self.assertEqual(by_key["id"], bound["id"])
self.assertEqual(by_key["base"]["id"], "S1", "with_base 应附基表只读视图")
def test_get_missing(self):
self.assertErr(api.pbl_domain_ref_get({"tenant_id": T1, "id": "NOPE"}),
"PBL_DE_NOT_FOUND")
def test_get_cross_tenant_denied(self):
bound = self.assertOk(self.bind("world", "W1", tenant_id=T1))
self.assertErr(api.pbl_domain_ref_get({"tenant_id": T2, "id": bound["id"]}),
("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"),
"T2 不得读 T1 的关联记录")
def test_list_filter_and_paging(self):
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A"))
self.assertOk(self.bind("scene", "S1", class_id="C1", team_id="TEAM_A"))
self.assertOk(self.bind("entity", "E1", class_id="C2", team_id="TEAM_B"))
data = self.assertOk(api.pbl_domain_ref_list(
{"tenant_id": T1, "class_id": "C1", "page": 1, "page_size": 10}))
self.assertEqual(data["total"], 2)
self.assertEqual(len(data["items"]), 2)
data2 = self.assertOk(api.pbl_domain_ref_list(
{"tenant_id": T1, "ref_type": "entity"}))
self.assertEqual([i["ref_id"] for i in data2["items"]], ["E1"])
data3 = self.assertOk(api.pbl_domain_ref_list(
{"tenant_id": T1, "page": 1, "page_size": 2}))
self.assertLessEqual(len(data3["items"]), 2, "page_size 必须生效")
def test_list_requires_tenant(self):
self.assertErr(api.pbl_domain_ref_list({"ref_type": "world"}),
"PBL_DE_TENANT_MISSING")
def test_list_tenant_isolation(self):
self.assertOk(self.bind("world", "W1", tenant_id=T1))
data = self.assertOk(api.pbl_domain_ref_list({"tenant_id": T2}))
self.assertEqual(data["total"], 0, "T2 不得看到 T1 的关联记录")
class TestCheckAccess(BaseCase):
def test_check_access_bound_ok(self):
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A"))
data = self.assertOk(api.pbl_domain_ref_check_access(
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1",
"class_id": "C1", "team_id": "TEAM_A"}))
self.assertTrue(data.get("allowed") or data.get("access"),
"已绑定且班级/团队匹配应放行:%r" % data)
def test_check_access_other_team_denied(self):
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A"))
resp = api.pbl_domain_ref_check_access(
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "team_id": "TEAM_Z"})
data = resp.get("data") or {}
if resp.get("success"):
self.assertFalse(data.get("allowed") and data.get("access", True),
"他团队不得放行:%r" % data)
else:
self.assertIn(resp.get("code"), ("PBL_DE_ACCESS_DENIED", "PBL_DE_NOT_FOUND"))
def test_check_access_cross_tenant_denied(self):
self.assertOk(self.bind("world", "W1", tenant_id=T1))
resp = api.pbl_domain_ref_check_access(
{"tenant_id": T2, "ref_type": "world", "ref_id": "W1"})
data = resp.get("data") or {}
self.assertFalse(resp.get("success") and (data.get("allowed") or data.get("access")),
"跨租户不得放行")
def test_check_access_requires_params(self):
self.assertErr(api.pbl_domain_ref_check_access({"tenant_id": T1}),
("PBL_DE_PARAM_INVALID", "PBL_DE_REF_TYPE_INVALID"),
"缺 ref_type/ref_id")
# ==========================================================================
# 2. 基础域只读契约world / scene / entity
# ==========================================================================
class TestBaseDomainReadOnly(BaseCase):
def test_world_list_by_tenant(self):
data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T1}))
ids = sorted(str(i["id"]) for i in data["items"])
self.assertEqual(ids, ["W1", "W2"], "只返回本租户世界")
self.assertTrue(data.get("readonly"), "必须标注只读")
def test_world_list_tenant_isolation(self):
data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T2}))
self.assertEqual([str(i["id"]) for i in data["items"]], ["W3"])
def test_world_list_requires_tenant(self):
self.assertErr(api.pbl_world_list_by_tenant({}), "PBL_DE_TENANT_MISSING")
def test_world_list_marks_bound(self):
self.assertOk(self.bind("world", "W1", blueprint_id="BP1"))
data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T1, "only_bound": 1}))
self.assertEqual([str(i["id"]) for i in data["items"]], ["W1"])
self.assertTrue(data["items"][0]["pbl_bound"])
def test_world_get_context(self):
self.assertOk(self.bind("world", "W1", blueprint_id="BP1"))
data = self.assertOk(api.pbl_world_get_context(
{"tenant_id": T1, "world_id": "W1", "with_scenes": 1, "with_entities": 1}))
self.assertEqual(str(data["world"]["id"]), "W1")
self.assertEqual(sorted(str(s["id"]) for s in data.get("scenes", [])), ["S1", "S2"])
def test_world_get_context_missing(self):
self.assertErr(api.pbl_world_get_context({"tenant_id": T1, "world_id": "NOPE"}),
("PBL_DE_NOT_FOUND", "PBL_DE_BASE_MISSING"))
def test_world_get_context_cross_tenant(self):
self.assertErr(api.pbl_world_get_context({"tenant_id": T2, "world_id": "W1"}),
("PBL_DE_NOT_FOUND", "PBL_DE_BASE_MISSING", "PBL_DE_ACCESS_DENIED"),
"W1 属 T1T2 不得取其上下文")
def test_scene_list_by_world(self):
data = self.assertOk(api.pbl_scene_list_by_world(
{"tenant_id": T1, "world_id": "W1"}))
self.assertEqual(sorted(str(i["id"]) for i in data["items"]), ["S1", "S2"])
def test_scene_list_requires_world_id(self):
self.assertErr(api.pbl_scene_list_by_world({"tenant_id": T1}),
"PBL_DE_PARAM_INVALID")
def test_scene_list_cross_tenant_empty(self):
data = self.assertOk(api.pbl_scene_list_by_world(
{"tenant_id": T2, "world_id": "W1"}))
self.assertEqual(data["items"], [], "跨租户不得看到他租户场景")
def test_entity_list_by_scene(self):
data = self.assertOk(api.pbl_entity_list_by_scene(
{"tenant_id": T1, "scene_id": "S1"}))
self.assertEqual(sorted(str(i["id"]) for i in data["items"]), ["E1", "E2"])
def test_entity_list_requires_scene_id(self):
self.assertErr(api.pbl_entity_list_by_scene({"tenant_id": T1}),
"PBL_DE_PARAM_INVALID")
# ==========================================================================
# 3. 班级 / 团队维度契约
# ==========================================================================
class TestTeamClass(BaseCase):
def test_team_bind_world_ok(self):
data = self.assertOk(api.pbl_team_bind_world(
{"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A",
"class_id": "C1", "operator_id": OP}))
self.assertEqual(data["team_id"], "TEAM_A")
self.assertEqual(data["ref_type"], "world")
self.assertEqual(len(self.rows()), 1)
def test_team_bind_world_idempotent(self):
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
self.assertEqual(len(self.rows()), 1, "重复绑定不得产生重复行")
def test_team_bind_world_requires_team_id(self):
self.assertErr(api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1"}),
"PBL_DE_PARAM_INVALID")
def test_team_bind_world_base_missing(self):
self.assertErr(api.pbl_team_bind_world(
{"tenant_id": T1, "world_id": "NOPE", "team_id": "TEAM_A"}),
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND"))
def test_team_bind_world_cross_tenant(self):
self.assertErr(api.pbl_team_bind_world(
{"tenant_id": T2, "world_id": "W1", "team_id": "TEAM_A"}),
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"))
def test_team_world_list(self):
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2", "team_id": "TEAM_B"})
data = self.assertOk(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TEAM_A"}))
items = data["items"] if isinstance(data, dict) else data
self.assertEqual(sorted(str(i.get("id") or i.get("world_id")) for i in items), ["W1"])
def test_team_world_list_requires_team_id(self):
self.assertErr(api.pbl_team_world_list({"tenant_id": T1}), "PBL_DE_PARAM_INVALID")
def test_team_world_list_cross_tenant_empty(self):
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
data = self.assertOk(api.pbl_team_world_list({"tenant_id": T2, "team_id": "TEAM_A"}))
items = data["items"] if isinstance(data, dict) else data
self.assertEqual(list(items), [], "跨租户不得返回他租户团队世界")
def test_team_list_by_class(self):
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1",
"team_id": "TEAM_A", "class_id": "C1"})
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2",
"team_id": "TEAM_B", "class_id": "C2"})
data = self.assertOk(api.pbl_team_list_by_class({"tenant_id": T1, "class_id": "C1"}))
items = data["items"] if isinstance(data, dict) else data
self.assertEqual(len(items), 1, "只返回该班级团队")
def test_team_list_by_class_requires_class_id(self):
self.assertErr(api.pbl_team_list_by_class({"tenant_id": T1}), "PBL_DE_PARAM_INVALID")
# ==========================================================================
# 4. 薄扩展铁律 / 契约齐备 / 设计对齐
# ==========================================================================
class TestThinExtensionInvariants(BaseCase):
def test_base_tables_never_written(self):
before = {t: len(self.sor.tables.get(t, [])) for t in ("world", "scene", "entity")}
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="T"))
self.assertOk(self.bind("scene", "S1"))
self.assertOk(self.bind("entity", "E1"))
api.pbl_domain_ref_update({"tenant_id": T1, "ref_type": "world",
"ref_id": "W1", "class_id": "C2"})
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2", "team_id": "TB"})
api.pbl_domain_ref_unbind({"tenant_id": T1, "ref_type": "scene", "ref_id": "S1"})
self.assertEqual(self.sor.base_write_attempts, [],
"基表 world/scene/entity 必须零写入Q-OPEN-3 薄扩展铁律)")
after = {t: len(self.sor.tables.get(t, [])) for t in ("world", "scene", "entity")}
self.assertEqual(before, after, "基表行数不得变化")
def test_only_one_new_table(self):
self.assertOk(self.bind("world", "W1"))
new_tables = set(self.sor.tables) - set(fake_db.BASE_FIXTURES)
self.assertEqual(new_tables, {TABLE},
"M8 只允许新增 1 张关联表 pbl_domain_ref不得新增 world/scene/entity 三张 PBL 表")
def test_ext_field_name_matches_design(self):
self.assertEqual(EXT_FIELD, "ext_json",
"扩展列名必须为设计 §J1 权威名 ext_json非 ext")
self.assertOk(self.bind("world", "W1", ext_json='{"k":"v"}'))
row = self.rows()[0]
self.assertIn("ext_json", row, "落库列名必须是 ext_json")
self.assertNotIn("ext", row, "不得残留旧列名 ext")
def test_ref_types_cover_three_domains(self):
self.assertEqual(tuple(REF_TYPES), ("world", "scene", "entity"))
def test_all_13_contracts_callable_and_registered(self):
from pbl_domain_ext import init as mod_init
names = [n for n in mod_init.CONTRACT_FUNCS if n != "pbl_domain_materialize_game_definition"]
self.assertEqual(len(names), 13, "契约函数应为 13 个")
missing_impl = [n for n in names if not callable(getattr(api, n, None))]
self.assertEqual(missing_impl, [], "契约函数必须在 api.py 实现")
info = mod_init._register_serverenv()
self.assertEqual(info["missing"], [], "全部契约必须注册到 ServerEnvdspy 直接按名调用)")
for n in names:
self.assertIn(n, info["registered"])
def test_dspy_files_match_contracts(self):
from pbl_domain_ext import init as mod_init
api_dir = os.path.join(ROOT, "wwwroot", "api")
for name, _action, _w in mod_init.API_ROUTES:
path = os.path.join(api_dir, name + ".dspy")
self.assertTrue(os.path.isfile(path), "缺少 dspy 契约文件 %s" % path)
body = open(path, encoding="utf-8").read()
self.assertIn(name + "(", body, "%s.dspy 必须调用同名契约函数" % name)
self.assertNotIn("import ", body, "dspy 禁止 import全局已预载")
self.assertIn("return", body, "dspy 必须显式 return")
def test_dispatch_routes_all_contracts(self):
for name in ("pbl_domain_ref_bind", "pbl_world_list_by_tenant",
"pbl_team_bind_world", "pbl_scene_list_by_world"):
resp = api.dispatch(name, {"tenant_id": T1, "ref_type": "world",
"ref_id": "W1", "world_id": "W1",
"scene_id": "S1", "team_id": "TEAM_A"})
self.assertIsInstance(resp, dict, "dispatch(%s) 必须返回 dict" % name)
def test_materialize_game_definition_exported(self):
"""历史 QC 硬门禁pbl_compiler 曾引用该符号,必须真实存在且可调用。"""
import pbl_domain_ext as pkg
self.assertTrue(callable(getattr(pkg, "pbl_domain_materialize_game_definition", None)))
self.assertTrue(callable(getattr(api, "pbl_domain_materialize_game_definition", None)))
resp = api.pbl_domain_materialize_game_definition({"tenant_id": T1, "world_id": "W1"})
self.assertIsInstance(resp, dict)
if __name__ == "__main__":
unittest.main(verbosity=2)