pbl_domain_ext/tests/test_assoc.py
2026-09-18 00:05:05 +08:00

515 lines
23 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.

"""M8 基础域薄扩展离线单测(不依赖 Sage 运行时 / 不连库)。
运行cd modules/pbl_domain_ext && python3 -m pytest tests/ -q
或 python3 tests/test_assoc.py无 pytest 时直接跑)
覆盖:租户强制打头 / 幂等 upsert / 可见性推导与校验 / 过滤白名单 /
作用域回溯解析 / Game Definition 物化 / 不改基表铁律 / 契约层错误码。
"""
from __future__ import annotations
import json
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fake_db import FakeDb # noqa: E402 (tests/ 目录内导入)
from pbl_domain_ext import api, assoc # noqa: E402
from pbl_domain_ext.assoc import DomainExtError # noqa: E402
TENANT = "T-DEMO-001"
def new_db() -> FakeDb:
return FakeDb([assoc.WORLD_REF, assoc.SCENE_REF, assoc.ENTITY_REF])
class TestTenantGuard(unittest.TestCase):
"""租户强制打头:缺失/空/超长一律 fail-closed。"""
def test_missing_tenant_raises(self):
db = new_db()
for bad in (None, "", " "):
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, bad, 101)
self.assertEqual(ctx.exception.code, assoc.ERR_TENANT_REQUIRED)
def test_overlong_tenant_raises(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, "T" * 65, 101)
self.assertEqual(ctx.exception.code, assoc.ERR_TENANT_REQUIRED)
def test_missing_key_raises(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, TENANT, None)
self.assertEqual(ctx.exception.code, assoc.ERR_PARAM_REQUIRED)
def test_non_integer_key_raises(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_scene_ref(db, TENANT, "abc")
def test_negative_key_raises(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_entity_ref(db, TENANT, -3)
def test_list_without_tenant_raises(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.list_world_refs(db, None)
self.assertEqual(ctx.exception.code, assoc.ERR_TENANT_REQUIRED)
class TestUpsertIdempotent(unittest.TestCase):
def test_create_then_update_same_row(self):
db = new_db()
first = assoc.upsert_world_ref(db, TENANT, 101, class_id=7, team_id=9, role_code="teacher")
self.assertEqual(first["action"], "created")
self.assertGreater(first["id"], 0)
second = assoc.upsert_world_ref(db, TENANT, 101, class_id=8, team_id=9, role_code="teacher")
self.assertEqual(second["action"], "updated")
self.assertEqual(second["id"], first["id"])
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 1)
self.assertEqual(db.tables[assoc.WORLD_REF][0]["class_id"], 8)
def test_different_role_is_different_row(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, role_code="teacher")
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, role_code="student")
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 2)
def test_different_tenant_isolated(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 101, team_id=9)
assoc.upsert_world_ref(db, "T-OTHER", 101, team_id=9)
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 2)
page = assoc.list_world_refs(db, TENANT)
self.assertEqual(page["total"], 1)
self.assertEqual(page["items"][0]["tenant_id"], TENANT)
def test_scene_and_entity_parents_persisted(self):
db = new_db()
assoc.upsert_scene_ref(db, TENANT, 202, world_id=101, team_id=9)
assoc.upsert_entity_ref(db, TENANT, 303, scene_id=202, world_id=101, team_id=9)
scene_row = db.tables[assoc.SCENE_REF][0]
entity_row = db.tables[assoc.ENTITY_REF][0]
self.assertEqual(scene_row["world_id"], 101)
self.assertEqual(entity_row["scene_id"], 202)
self.assertEqual(entity_row["world_id"], 101)
def test_unknown_parent_column_rejected(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_ref(db, assoc.SCENE_REF, TENANT, 202, {"entity_id": 1})
def test_ext_json_serialized(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, ext={"stage": 2, "tags": ["a", "b"]})
stored = db.tables[assoc.WORLD_REF][0]["ext_json"]
self.assertEqual(json.loads(stored)["stage"], 2)
def test_ext_string_json_passthrough(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 102, team_id=9, ext='{"k": 1}')
self.assertEqual(json.loads(db.tables[assoc.WORLD_REF][0]["ext_json"])["k"], 1)
def test_ext_bad_json_rejected(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, ext="{not json")
def test_bad_source_rejected(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, source="drop table;")
class TestVisibility(unittest.TestCase):
def test_auto_visibility_by_scope(self):
db = new_db()
self.assertEqual(assoc.upsert_world_ref(db, TENANT, 1, team_id=5)["visibility"], "team")
self.assertEqual(assoc.upsert_world_ref(db, TENANT, 2, class_id=5)["visibility"], "class")
self.assertEqual(assoc.upsert_world_ref(db, TENANT, 3)["visibility"], "tenant")
def test_explicit_visibility_must_match_scope(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, TENANT, 4, visibility="team")
self.assertEqual(ctx.exception.code, assoc.ERR_PARAM_REQUIRED)
with self.assertRaises(DomainExtError):
assoc.upsert_world_ref(db, TENANT, 5, visibility="class")
def test_bad_visibility_value(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, TENANT, 6, visibility="public")
self.assertEqual(ctx.exception.code, assoc.ERR_BAD_VISIBILITY)
class TestListAndFilter(unittest.TestCase):
def setUp(self):
self.db = new_db()
for i in range(1, 8):
assoc.upsert_world_ref(self.db, TENANT, 100 + i, class_id=7, team_id=9)
assoc.upsert_world_ref(self.db, "T-OTHER", 999, team_id=1)
def test_pagination(self):
page = assoc.list_world_refs(self.db, TENANT, limit=3, offset=0)
self.assertEqual(page["total"], 7)
self.assertEqual(len(page["items"]), 3)
page2 = assoc.list_world_refs(self.db, TENANT, limit=3, offset=6)
self.assertEqual(len(page2["items"]), 1)
def test_limit_clamped(self):
page = assoc.list_world_refs(self.db, TENANT, limit=99999)
self.assertEqual(page["limit"], assoc.MAX_LIMIT)
def test_filter_whitelist(self):
page = assoc.list_world_refs(self.db, TENANT, {"class_id": 7})
self.assertEqual(page["total"], 7)
with self.assertRaises(DomainExtError):
assoc.list_world_refs(self.db, TENANT, {"password": "x"})
def test_empty_filter_value_ignored(self):
page = assoc.list_world_refs(self.db, TENANT, {"role_code": "", "team_id": 9})
self.assertEqual(page["total"], 7)
def test_scene_filter_by_world(self):
assoc.upsert_scene_ref(self.db, TENANT, 201, world_id=101, team_id=9)
assoc.upsert_scene_ref(self.db, TENANT, 202, world_id=102, team_id=9)
page = assoc.list_scene_refs(self.db, TENANT, {"world_id": 101})
self.assertEqual(page["total"], 1)
self.assertEqual(page["items"][0]["scene_id"], 201)
class TestGetDelete(unittest.TestCase):
def test_get_strict_and_lenient(self):
db = new_db()
assoc.upsert_entity_ref(db, TENANT, 303, scene_id=202, world_id=101, team_id=9)
row = assoc.get_ref(db, assoc.ENTITY_REF, TENANT, 303, team_id=9)
self.assertEqual(row["scene_id"], 202)
with self.assertRaises(DomainExtError) as ctx:
assoc.get_ref(db, assoc.ENTITY_REF, TENANT, 404, team_id=9)
self.assertEqual(ctx.exception.code, assoc.ERR_NOT_FOUND)
self.assertIsNone(assoc.get_ref(db, assoc.ENTITY_REF, TENANT, 404, team_id=9, strict=False))
def test_delete_only_own_tenant(self):
db = new_db()
res = assoc.upsert_world_ref(db, TENANT, 101, team_id=9)
assoc.upsert_world_ref(db, "T-OTHER", 101, team_id=9)
affected = assoc.delete_ref(db, assoc.WORLD_REF, TENANT, res["id"])
self.assertEqual(affected, 1)
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 1)
self.assertEqual(db.tables[assoc.WORLD_REF][0]["tenant_id"], "T-OTHER")
class TestScopeResolve(unittest.TestCase):
def setUp(self):
self.db = new_db()
assoc.upsert_world_ref(self.db, TENANT, 101, class_id=7, team_id=9)
assoc.upsert_scene_ref(self.db, TENANT, 202, world_id=101, class_id=7, team_id=9)
assoc.upsert_entity_ref(self.db, TENANT, 303, scene_id=202, world_id=101, team_id=9)
def test_resolve_from_entity_backfills_parents(self):
scope = assoc.resolve_scope(self.db, TENANT, entity_id=303)
self.assertEqual(scope["resolved_from"], "entity")
self.assertEqual(scope["scene_id"], 202)
self.assertEqual(scope["world_id"], 101)
self.assertEqual(scope["team_id"], 9)
self.assertEqual(scope["class_id"], 7)
self.assertEqual(scope["visibility"], "team")
self.assertIsNotNone(scope["world"])
def test_resolve_from_scene_only(self):
scope = assoc.resolve_scope(self.db, TENANT, scene_id=202)
self.assertEqual(scope["resolved_from"], "scene")
self.assertEqual(scope["world_id"], 101)
def test_resolve_from_world_only(self):
scope = assoc.resolve_scope(self.db, TENANT, world_id=101)
self.assertEqual(scope["resolved_from"], "world")
self.assertEqual(scope["class_id"], 7)
def test_resolve_requires_one_id(self):
with self.assertRaises(DomainExtError) as ctx:
assoc.resolve_scope(self.db, TENANT)
self.assertEqual(ctx.exception.code, assoc.ERR_PARAM_REQUIRED)
def test_resolve_not_found_fail_closed(self):
with self.assertRaises(DomainExtError) as ctx:
assoc.resolve_scope(self.db, TENANT, entity_id=999)
self.assertEqual(ctx.exception.code, assoc.ERR_NOT_FOUND)
def test_resolve_cross_tenant_not_leaked(self):
with self.assertRaises(DomainExtError):
assoc.resolve_scope(self.db, "T-OTHER", entity_id=303)
def test_resolve_entity_without_scene_parent(self):
assoc.upsert_entity_ref(self.db, TENANT, 404, world_id=101, team_id=9)
scope = assoc.resolve_scope(self.db, TENANT, entity_id=404)
self.assertEqual(scope["resolved_from"], "entity")
self.assertEqual(scope["world_id"], 101)
self.assertIsNone(scope["scene"])
class TestMaterialize(unittest.TestCase):
DEFINITION = {
"game": {
"world_id": 101,
"scenes": [{"scene_id": 202, "entities": [{"entity_id": 303}, {"entityId": 304}]}],
},
"meta": {"worldId": 101},
}
def test_collect_ids_dedup(self):
ids = assoc.collect_ids(self.DEFINITION, assoc.WORLD_REF)
self.assertEqual(ids, [101])
self.assertEqual(assoc.collect_ids(self.DEFINITION, assoc.SCENE_REF), [202])
self.assertEqual(sorted(assoc.collect_ids(self.DEFINITION, assoc.ENTITY_REF)), [303, 304])
def test_collect_ids_from_json_string(self):
ids = assoc.collect_ids(json.dumps(self.DEFINITION), assoc.WORLD_REF)
self.assertEqual(ids, [101])
def test_bind_creates_all_refs(self):
db = new_db()
summary = assoc.bind_game_definition(db, TENANT, self.DEFINITION, class_id=7, team_id=9)
self.assertEqual(summary["world_refs"], 1)
self.assertEqual(summary["scene_refs"], 1)
self.assertEqual(summary["entity_refs"], 2)
self.assertEqual(summary["created"], 4)
self.assertEqual(summary["source"], "compiler")
self.assertEqual(len(db.tables[assoc.ENTITY_REF]), 2)
def test_bind_is_idempotent(self):
db = new_db()
assoc.bind_game_definition(db, TENANT, self.DEFINITION, team_id=9)
second = assoc.bind_game_definition(db, TENANT, json.dumps(self.DEFINITION), team_id=9)
self.assertEqual(second["updated"], 4)
self.assertEqual(second["created"], 0)
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 1)
def test_bind_rejects_empty_and_bad_json(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.bind_game_definition(db, TENANT, " ")
with self.assertRaises(DomainExtError):
assoc.bind_game_definition(db, TENANT, "{bad json")
with self.assertRaises(DomainExtError):
assoc.bind_game_definition(db, TENANT, [1, 2, 3])
def test_bind_without_ids_reports_skip(self):
db = new_db()
summary = assoc.bind_game_definition(db, TENANT, {"game": {"name": "x"}})
self.assertEqual(summary["world_refs"], 0)
self.assertTrue(summary["skipped"])
def test_multiple_scenes_leave_parent_zero(self):
db = new_db()
definition = {"scenes": [{"scene_id": 1}, {"scene_id": 2}], "entities": [{"entity_id": 5}]}
summary = assoc.bind_game_definition(db, TENANT, definition, team_id=9)
self.assertEqual(summary["scene_refs"], 2)
self.assertEqual(db.tables[assoc.ENTITY_REF][0]["scene_id"], 0)
self.assertTrue(any("多个 scene_id" in s for s in summary["skipped"]))
def test_multiple_worlds_take_first(self):
db = new_db()
definition = {"worlds": [{"world_id": 11}, {"world_id": 22}], "scenes": [{"scene_id": 5}]}
summary = assoc.bind_game_definition(db, TENANT, definition, team_id=9)
self.assertEqual(summary["world_refs"], 2)
self.assertEqual(db.tables[assoc.SCENE_REF][0]["world_id"], 11)
self.assertTrue(any("多个 world_id" in s for s in summary["skipped"]))
class TestBaseTableUntouched(unittest.TestCase):
"""铁律:任何路径都不得写 world/scene/entity 基表。"""
def test_no_base_table_write(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 101, team_id=9)
assoc.upsert_scene_ref(db, TENANT, 202, world_id=101, team_id=9)
assoc.upsert_entity_ref(db, TENANT, 303, scene_id=202, world_id=101, team_id=9)
assoc.bind_game_definition(db, TENANT, TestMaterialize.DEFINITION, team_id=9)
assoc.resolve_scope(db, TENANT, entity_id=303)
self.assertEqual(db.base_table_writes, [])
for sql, _params in db.executed:
lowered = " ".join(sql.lower().split())
if lowered.startswith(("insert", "update", "delete")):
for base in ("world", "scene", "entity"):
self.assertFalse(lowered.startswith("insert into %s " % base), sql)
self.assertFalse(lowered.startswith("update %s " % base), sql)
self.assertFalse(lowered.startswith("delete from %s " % base), sql)
def test_all_writes_target_ref_tables_only(self):
db = new_db()
assoc.bind_game_definition(db, TENANT, TestMaterialize.DEFINITION, team_id=9)
write_tables = set()
for sql, _params in db.executed:
lowered = " ".join(sql.lower().split())
if lowered.startswith("insert into"):
write_tables.add(lowered.split()[2])
elif lowered.startswith("update"):
write_tables.add(lowered.split()[1])
elif lowered.startswith("delete from"):
write_tables.add(lowered.split()[2])
self.assertTrue(write_tables)
self.assertTrue(write_tables.issubset(set(assoc.REF_TABLES)), write_tables)
def test_table_name_injection_guard(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.list_refs(db, "pbl_world_ref; DROP TABLE world", TENANT)
with self.assertRaises(DomainExtError):
assoc.upsert_ref(db, "world", TENANT, 1)
def test_filter_column_injection_guard(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.list_refs(db, assoc.WORLD_REF, TENANT, {"1=1 OR tenant_id": "x"})
def test_values_always_parameterized(self):
db = new_db()
evil = "x'; DROP TABLE pbl_world_ref; --"
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, role_code=evil)
for sql, params in db.executed:
self.assertNotIn("DROP TABLE", sql)
self.assertEqual(db.tables[assoc.WORLD_REF][0]["role_code"], evil)
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 1)
class TestApiContract(unittest.TestCase):
def setUp(self):
self.db = new_db()
def call(self, name, **params):
params["_db"] = self.db
return api.call_api(name, params)
def test_upsert_and_list_ok(self):
res = self.call("pbl_world_ref_upsert", tenant_id=TENANT, world_id=101, class_id=7, team_id=9)
self.assertTrue(res["ok"], res)
self.assertEqual(res["data"]["action"], "created")
page = self.call("pbl_world_ref_list", tenant_id=TENANT, team_id=9)
self.assertTrue(page["ok"])
self.assertEqual(page["data"]["total"], 1)
def test_scene_and_entity_upsert_ok(self):
scene = self.call("pbl_scene_ref_upsert", tenant_id=TENANT, scene_id=202, world_id=101, team_id=9)
entity = self.call("pbl_entity_ref_upsert", tenant_id=TENANT, entity_id=303, scene_id=202,
world_id=101, team_id=9, ext={"hp": 100})
self.assertTrue(scene["ok"], scene)
self.assertTrue(entity["ok"], entity)
self.assertEqual(entity["data"]["visibility"], "team")
def test_missing_tenant_returns_error_code(self):
res = self.call("pbl_world_ref_list", world_id=101)
self.assertFalse(res["ok"])
self.assertEqual(res["code"], assoc.ERR_TENANT_REQUIRED)
def test_unknown_api(self):
res = api.call_api("pbl_not_exist", {"tenant_id": TENANT})
self.assertFalse(res["ok"])
self.assertEqual(res["code"], assoc.ERR_PARAM_REQUIRED)
def test_unknown_table_alias(self):
res = self.call("pbl_domain_ref_get", table="payroll", tenant_id=TENANT, id_value=1)
self.assertFalse(res["ok"])
def test_ref_get_and_delete(self):
created = self.call("pbl_scene_ref_upsert", tenant_id=TENANT, scene_id=202, world_id=101, team_id=9)
rid = created["data"]["id"]
got = self.call("pbl_domain_ref_get", table="scene", tenant_id=TENANT, scene_id=202, team_id=9)
self.assertTrue(got["ok"])
self.assertTrue(got["found"])
deleted = self.call("pbl_domain_ref_delete", table="pbl_scene_ref", tenant_id=TENANT, id=rid)
self.assertEqual(deleted["data"]["deleted"], 1)
missing = self.call("pbl_domain_ref_get", table="scene", tenant_id=TENANT, scene_id=202,
team_id=9, strict=False)
self.assertTrue(missing["ok"])
self.assertFalse(missing["found"])
def test_ref_get_strict_not_found(self):
res = self.call("pbl_domain_ref_get", table="world", tenant_id=TENANT, world_id=777)
self.assertFalse(res["ok"])
self.assertEqual(res["code"], assoc.ERR_NOT_FOUND)
def test_scope_resolve_and_stats(self):
self.call("pbl_world_ref_upsert", tenant_id=TENANT, world_id=101, class_id=7, team_id=9)
self.call("pbl_scene_ref_upsert", tenant_id=TENANT, scene_id=202, world_id=101, team_id=9)
self.call("pbl_entity_ref_upsert", tenant_id=TENANT, entity_id=303, scene_id=202,
world_id=101, team_id=9)
scope = self.call("pbl_domain_scope_resolve", tenant_id=TENANT, entity_id=303)
self.assertTrue(scope["ok"])
self.assertEqual(scope["data"]["resolved_from"], "entity")
self.assertEqual(scope["data"]["world_id"], 101)
stats = self.call("pbl_domain_stats", tenant_id=TENANT)
self.assertEqual(stats["data"]["total"], 3)
self.assertEqual(stats["data"][assoc.ENTITY_REF], 1)
def test_materialize_contract(self):
res = self.call("pbl_domain_materialize_game_definition", tenant_id=TENANT,
game_definition=TestMaterialize.DEFINITION, class_id=7, team_id=9)
self.assertTrue(res["ok"], res)
self.assertEqual(res["data"]["world_refs"], 1)
self.assertEqual(res["data"]["entity_refs"], 2)
bad = self.call("pbl_domain_materialize_game_definition", tenant_id=TENANT,
game_definition="{oops")
self.assertFalse(bad["ok"])
self.assertEqual(bad["code"], assoc.ERR_PARAM_REQUIRED)
def test_materialize_missing_definition(self):
res = self.call("pbl_domain_materialize_game_definition", tenant_id=TENANT)
self.assertFalse(res["ok"])
self.assertEqual(res["code"], assoc.ERR_PARAM_REQUIRED)
class TestInitRegistration(unittest.TestCase):
def test_module_metadata(self):
from pbl_domain_ext import init as init_mod
self.assertEqual(init_mod.MODULE_NAME, "pbl_domain_ext")
for table in (assoc.WORLD_REF, assoc.SCENE_REF, assoc.ENTITY_REF):
self.assertIn(table, init_mod.OWN_TABLES)
self.assertEqual(init_mod.READONLY_BASE_TABLES, ["world", "scene", "entity"])
self.assertIn("pbl_domain_scope_resolve", init_mod.API_NAMES)
def test_models_and_cruds_loadable(self):
from pbl_domain_ext import init as init_mod
models = init_mod.load_models()
names = {m.get("tblname") for m in models}
for table in (assoc.WORLD_REF, assoc.SCENE_REF, assoc.ENTITY_REF):
self.assertIn(table, names)
self.assertGreaterEqual(len(init_mod.load_cruds()), 3)
def test_load_entry_returns_summary_without_runtime(self):
from pbl_domain_ext import init as init_mod
summary = init_mod.load_pbl_domain_ext()
self.assertEqual(summary["module"], "pbl_domain_ext")
self.assertIsNone(summary["dbname"]) # 无 ServerEnv 时不抛,只记录错误
self.assertIn("dbname_error", summary)
def test_load_entry_registers_apis_on_env(self):
from pbl_domain_ext import init as init_mod
class FakeEnv(object):
def __init__(self):
self.apis = {}
def register_api(self, name, handler):
self.apis[name] = handler
env = FakeEnv()
init_mod.load_pbl_domain_ext(env)
self.assertIn("pbl_world_ref_list", env.apis)
if __name__ == "__main__":
unittest.main(verbosity=2)