553 lines
29 KiB
Python
553 lines
29 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""M1b 测试:模板平台公共部分 / 子对象扩展 / 关联表判定。
|
||
|
||
运行:cd modules/pbl_blueprint && python -m pytest tests/test_m1b_ext_ref.py -q
|
||
(无 pytest 时可直接 python tests/test_m1b_ext_ref.py)
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import unittest
|
||
|
||
import _m1b_loader # noqa: F401 (空壳包引导,绕开 M1a 挂载链 eager import)
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from pbl_blueprint.m1b_common import ( # noqa: E402
|
||
IS_NULL, TenantRequiredError, PermissionDeniedError, ValidationError,
|
||
NotFoundError, ConflictError,
|
||
)
|
||
from pbl_blueprint import m1b_template as T # noqa: E402
|
||
from pbl_blueprint import m1b_subobject as S # noqa: E402
|
||
from pbl_blueprint import m1b_ref as R # noqa: E402
|
||
from pbl_blueprint import m1b_api as A # noqa: E402
|
||
from pbl_blueprint import m1b_init as I # noqa: E402
|
||
|
||
|
||
class FakeDB(object):
|
||
"""最小内存 DB:支持 query/insert/update/delete(与 m1b_common 适配器契约一致)。"""
|
||
|
||
def __init__(self):
|
||
self.tables = {}
|
||
|
||
def query(self, table, conds=None, order_by=None, limit=None):
|
||
return list(self.tables.get(table, []))
|
||
|
||
def insert(self, table, row):
|
||
self.tables.setdefault(table, []).append(dict(row))
|
||
return row.get("id")
|
||
|
||
def update(self, table, conds, values):
|
||
n = 0
|
||
for r in self.tables.get(table, []):
|
||
if all((r.get(k) in (None, "") if isinstance(v, type(IS_NULL)) else r.get(k) == v)
|
||
for k, v in (conds or {}).items()):
|
||
r.update(values)
|
||
n += 1
|
||
return n
|
||
|
||
def delete(self, table, conds):
|
||
rows = self.tables.get(table, [])
|
||
keep = [r for r in rows if not all(r.get(k) == v for k, v in (conds or {}).items())]
|
||
self.tables[table] = keep
|
||
return len(rows) - len(keep)
|
||
|
||
|
||
TENANT = "t-001"
|
||
OTHER = "t-002"
|
||
|
||
|
||
class TestPlatformTemplate(unittest.TestCase):
|
||
def setUp(self):
|
||
self.db = FakeDB()
|
||
|
||
def test_create_platform_template_requires_admin(self):
|
||
with self.assertRaises(PermissionDeniedError):
|
||
T.create_template(self.db, None, {"code": "p1", "name": "平台模板"},
|
||
actor="u1", is_platform_admin=False)
|
||
|
||
def test_create_platform_template_ok_and_tenant_isolated(self):
|
||
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform",
|
||
"content": {"a": 1}},
|
||
actor="admin", is_platform_admin=True)
|
||
self.assertTrue(r["ok"])
|
||
self.assertTrue(r["is_platform"])
|
||
self.assertIsNone(r["template"]["tenant_id"])
|
||
# 租户可见平台公共模板
|
||
lst = T.list_templates(self.db, TENANT)
|
||
self.assertEqual(lst["total"], 1)
|
||
self.assertTrue(lst["items"][0]["is_platform"])
|
||
self.assertFalse(lst["items"][0]["editable"])
|
||
# 其他租户同样可见(公共)
|
||
self.assertEqual(T.list_templates(self.db, OTHER)["total"], 1)
|
||
|
||
def test_tenant_cannot_write_platform_template(self):
|
||
T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform"},
|
||
actor="admin", is_platform_admin=True)
|
||
lst = T.list_templates(self.db, TENANT)
|
||
tid = lst["items"][0]["id"]
|
||
with self.assertRaises(PermissionDeniedError):
|
||
T.update_template(self.db, TENANT, tid, {"name": "改名"}, actor="u1",
|
||
is_platform_admin=False)
|
||
with self.assertRaises(PermissionDeniedError):
|
||
T.delete_template(self.db, TENANT, tid, actor="u1", is_platform_admin=False)
|
||
|
||
def test_tenant_template_not_visible_to_others(self):
|
||
T.create_template(self.db, TENANT, {"code": "t1", "name": "租户模板"}, actor="u1")
|
||
self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1)
|
||
self.assertEqual(T.list_templates(self.db, OTHER)["total"], 0)
|
||
tid = T.list_templates(self.db, TENANT)["items"][0]["id"]
|
||
with self.assertRaises(NotFoundError):
|
||
T.get_template(self.db, OTHER, tid)
|
||
|
||
def test_missing_tenant_fail_closed(self):
|
||
with self.assertRaises(TenantRequiredError):
|
||
T.list_templates(self.db, None)
|
||
with self.assertRaises(TenantRequiredError):
|
||
T.list_templates(self.db, " ")
|
||
|
||
def test_code_unique_per_scope(self):
|
||
T.create_template(self.db, None, {"code": "same", "name": "平台", "scope": "platform"},
|
||
actor="admin", is_platform_admin=True)
|
||
# 同 code 在不同租户域可共存
|
||
T.create_template(self.db, TENANT, {"code": "same", "name": "租户"}, actor="u1")
|
||
with self.assertRaises(ConflictError):
|
||
T.create_template(self.db, TENANT, {"code": "same", "name": "重复"}, actor="u1")
|
||
with self.assertRaises(ConflictError):
|
||
T.create_template(self.db, None, {"code": "same", "name": "重复平台",
|
||
"scope": "platform"},
|
||
actor="admin", is_platform_admin=True)
|
||
|
||
def test_fork_platform_to_tenant(self):
|
||
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板",
|
||
"scope": "platform",
|
||
"content": {"missions": [{"id": "m1"}]},
|
||
"ext_schema": {"mission": {"difficulty": "hard"}}},
|
||
actor="admin", is_platform_admin=True)
|
||
f = T.fork_template(self.db, TENANT, r["id"], actor="u1", new_code="p1_local")
|
||
self.assertTrue(f["ok"])
|
||
self.assertEqual(f["template"]["tenant_id"], TENANT)
|
||
self.assertEqual(f["template"]["source"], "fork")
|
||
self.assertEqual(f["template"]["source_template_id"], r["id"])
|
||
# 派生副本可写
|
||
T.update_template(self.db, TENANT, f["id"], {"name": "本地改名"}, actor="u1")
|
||
# 平台模板未受影响
|
||
self.assertEqual(T.get_template(self.db, TENANT, r["id"])["name"], "平台模板")
|
||
|
||
def test_builtin_template_cannot_be_deleted(self):
|
||
r = T.create_template(self.db, None, {"code": "b1", "name": "内置", "scope": "platform",
|
||
"is_builtin": 1, "content": {"x": 1}},
|
||
actor="admin", is_platform_admin=True)
|
||
with self.assertRaises(PermissionDeniedError):
|
||
T.delete_template(self.db, TENANT, r["id"], actor="admin", is_platform_admin=True)
|
||
self.assertTrue(T.deprecate_template(self.db, TENANT, r["id"], actor="admin",
|
||
is_platform_admin=True)["ok"])
|
||
|
||
def test_publish_requires_content(self):
|
||
r = T.create_template(self.db, TENANT, {"code": "t1", "name": "空模板"}, actor="u1")
|
||
with self.assertRaises(ValidationError):
|
||
T.publish_template(self.db, TENANT, r["id"], actor="u1")
|
||
T.update_template(self.db, TENANT, r["id"], {"content": {"missions": []}}, actor="u1")
|
||
self.assertEqual(T.publish_template(self.db, TENANT, r["id"], actor="u1")["status"],
|
||
"published")
|
||
|
||
def test_instantiate_platform_template_sets_tenant(self):
|
||
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板",
|
||
"scope": "platform", "status": "published",
|
||
"content": {"missions": [{"id": "m1"}]},
|
||
"subobject_kinds": ["mission"]},
|
||
actor="admin", is_platform_admin=True)
|
||
captured = {}
|
||
|
||
def fake_create(db, tenant_id, payload, actor=None):
|
||
captured["tenant_id"] = tenant_id
|
||
captured["payload"] = payload
|
||
return {"ok": True, "id": "bp-1"}
|
||
|
||
res = T.instantiate_template(self.db, TENANT, r["id"], actor="u1",
|
||
create_blueprint=fake_create)
|
||
self.assertTrue(res["ok"])
|
||
self.assertTrue(res["is_platform_template"])
|
||
self.assertEqual(captured["tenant_id"], TENANT) # 实例绝不继承 NULL
|
||
self.assertEqual(captured["payload"]["tenant_id"], TENANT)
|
||
self.assertEqual(captured["payload"]["template_id"], r["id"])
|
||
# usage_count +1
|
||
self.assertEqual(int(T.get_template(self.db, TENANT, r["id"])["usage_count"]), 1)
|
||
|
||
def test_instantiate_deprecated_rejected(self):
|
||
r = T.create_template(self.db, TENANT, {"code": "t1", "name": "x", "content": {"a": 1}},
|
||
actor="u1")
|
||
T.deprecate_template(self.db, TENANT, r["id"], actor="u1")
|
||
with self.assertRaises(ValidationError):
|
||
T.instantiate_template(self.db, TENANT, r["id"], actor="u1")
|
||
|
||
def test_seed_idempotent(self):
|
||
a = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子",
|
||
"content": {"a": 1}}], actor="system")
|
||
self.assertEqual(a["created_count"], 1)
|
||
b = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子",
|
||
"content": {"a": 1}}], actor="system")
|
||
self.assertEqual(b["created_count"], 0)
|
||
self.assertEqual(b["skipped_count"], 1)
|
||
self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1)
|
||
|
||
|
||
class TestSubobjectExt(unittest.TestCase):
|
||
def setUp(self):
|
||
self.db = FakeDB()
|
||
self.bp = "bp-1"
|
||
S.create_ext_field_def(self.db, None, {
|
||
"scope": "platform", "subobject_kind": "mission", "ext_key": "difficulty",
|
||
"label": "难度", "value_type": "enum", "enum_options": ["easy", "medium", "hard"],
|
||
"required": 1}, actor="admin", is_platform_admin=True)
|
||
S.create_ext_field_def(self.db, None, {
|
||
"scope": "platform", "subobject_kind": "mission", "ext_key": "estimated_minutes",
|
||
"label": "耗时", "value_type": "int", "constraints": {"min": 1, "max": 600}},
|
||
actor="admin", is_platform_admin=True)
|
||
|
||
def test_undefined_ext_key_rejected(self):
|
||
with self.assertRaises(ValidationError):
|
||
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "not_defined", "x", actor="u1")
|
||
|
||
def test_invalid_kind_rejected(self):
|
||
with self.assertRaises(ValidationError):
|
||
S.set_ext(self.db, TENANT, self.bp, "not_a_kind", "m1", "difficulty", "easy",
|
||
actor="u1")
|
||
|
||
def test_enum_and_int_validation(self):
|
||
with self.assertRaises(ValidationError):
|
||
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "impossible",
|
||
actor="u1")
|
||
with self.assertRaises(ValidationError):
|
||
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "estimated_minutes", 9999,
|
||
actor="u1")
|
||
r = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "estimated_minutes", "90",
|
||
actor="u1")
|
||
self.assertEqual(r["value"], 90) # 字符串按定义转 int
|
||
self.assertEqual(r["value_type"], "int")
|
||
|
||
def test_upsert_idempotent(self):
|
||
a = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
|
||
self.assertTrue(a["created"])
|
||
b = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "hard", actor="u1")
|
||
self.assertFalse(b["created"])
|
||
self.assertEqual(b["id"], a["id"])
|
||
self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty"),
|
||
"hard")
|
||
self.assertEqual(len(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["items"]), 1)
|
||
|
||
def test_tenant_isolation_on_ext(self):
|
||
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
|
||
self.assertEqual(S.get_ext(self.db, OTHER, self.bp, "mission", "m1", "difficulty"), None)
|
||
self.assertEqual(S.list_ext(self.db, OTHER, blueprint_id=self.bp)["total"], 0)
|
||
with self.assertRaises(TenantRequiredError):
|
||
S.list_ext(self.db, None, blueprint_id=self.bp)
|
||
|
||
def test_tenant_def_overrides_platform(self):
|
||
S.create_ext_field_def(self.db, TENANT, {
|
||
"subobject_kind": "mission", "ext_key": "difficulty", "label": "本地难度",
|
||
"value_type": "enum", "enum_options": ["L1", "L2"]}, actor="u1")
|
||
d = S.resolve_ext_field_def(self.db, TENANT, "mission", "difficulty")
|
||
self.assertFalse(d["is_platform"])
|
||
self.assertEqual(d["label"], "本地难度")
|
||
# 平台枚举值在租户覆盖后失效
|
||
with self.assertRaises(ValidationError):
|
||
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
|
||
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "L1", actor="u1")
|
||
# 其他租户仍用平台定义
|
||
d2 = S.resolve_ext_field_def(self.db, OTHER, "mission", "difficulty")
|
||
self.assertTrue(d2["is_platform"])
|
||
|
||
def test_any_kind_def_visible(self):
|
||
S.create_ext_field_def(self.db, None, {"scope": "platform", "subobject_kind": "any",
|
||
"ext_key": "tags", "label": "标签",
|
||
"value_type": "json"},
|
||
actor="admin", is_platform_admin=True)
|
||
r = S.set_ext(self.db, TENANT, self.bp, "role", "r1", "tags", ["a", "b"], actor="u1")
|
||
self.assertEqual(r["value"], ["a", "b"])
|
||
|
||
def test_bulk_set_all_or_nothing(self):
|
||
with self.assertRaises(ValidationError) as cm:
|
||
S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1",
|
||
{"difficulty": "easy", "estimated_minutes": 99999}, actor="u1")
|
||
self.assertTrue(cm.exception.detail.get("errors"))
|
||
self.assertEqual(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["total"], 0)
|
||
ok = S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1",
|
||
{"difficulty": "easy", "estimated_minutes": 60}, actor="u1")
|
||
self.assertEqual(ok["count"], 2)
|
||
|
||
def test_apply_template_ext_schema_no_overwrite(self):
|
||
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
|
||
res = S.apply_template_ext_schema(
|
||
self.db, TENANT, self.bp,
|
||
{"mission": {"difficulty": "hard", "estimated_minutes": 45}},
|
||
{"mission": ["m1", "m2"]}, actor="u1", source_template_id="tpl-x")
|
||
self.assertEqual(res["applied_count"], 2) # m1.estimated_minutes + m2.estimated_minutes
|
||
self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty"),
|
||
"easy") # 已填不覆盖
|
||
self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m2", "difficulty"),
|
||
"hard")
|
||
rows = S.list_ext(self.db, TENANT, blueprint_id=self.bp)["items"]
|
||
self.assertTrue(all(r["source"] in ("manual", "template_instantiate") for r in rows))
|
||
self.assertTrue(any(r["source_template_id"] == "tpl-x" for r in rows))
|
||
|
||
def test_subobject_tree_ext(self):
|
||
S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1",
|
||
{"difficulty": "easy", "estimated_minutes": 30}, actor="u1")
|
||
tree = S.subobject_tree_ext(self.db, TENANT, self.bp)["ext"]
|
||
self.assertEqual(tree["mission"]["m1"]["difficulty"], "easy")
|
||
self.assertEqual(tree["mission"]["m1"]["estimated_minutes"], 30)
|
||
|
||
def test_delete_ext(self):
|
||
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
|
||
self.assertEqual(S.delete_ext(self.db, TENANT, self.bp, "mission", "m1",
|
||
ext_key="difficulty", actor="u1")["deleted_count"], 1)
|
||
self.assertEqual(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["total"], 0)
|
||
|
||
def test_ext_def_delete_blocked_when_in_use(self):
|
||
d = S.create_ext_field_def(self.db, TENANT, {
|
||
"subobject_kind": "role", "ext_key": "min_players", "label": "最少人数",
|
||
"value_type": "int"}, actor="u1")
|
||
S.set_ext(self.db, TENANT, self.bp, "role", "r1", "min_players", 2, actor="u1")
|
||
with self.assertRaises(ConflictError):
|
||
S.delete_ext_field_def(self.db, TENANT, d["id"], actor="u1")
|
||
|
||
def test_platform_ext_def_write_requires_admin(self):
|
||
with self.assertRaises(PermissionDeniedError):
|
||
S.create_ext_field_def(self.db, None, {"scope": "platform",
|
||
"subobject_kind": "role",
|
||
"ext_key": "x", "label": "x"},
|
||
actor="u1", is_platform_admin=False)
|
||
|
||
|
||
class TestRefTable(unittest.TestCase):
|
||
def setUp(self):
|
||
self.db = FakeDB()
|
||
self.bp = "bp-1"
|
||
|
||
def test_classify_ref_whitelist(self):
|
||
self.assertEqual(R.classify_ref("world", "world")[0], True)
|
||
self.assertEqual(R.classify_ref("world", "employee")[0], False) # 域-表不匹配
|
||
self.assertEqual(R.classify_ref("unknown_domain", "x")[0], False)
|
||
with self.assertRaises(ValidationError):
|
||
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "secret_tbl",
|
||
"ref_id": "w1"}, actor="u1")
|
||
|
||
def test_add_ref_and_idempotent(self):
|
||
a = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w1", "rel_type": "binds",
|
||
"required": 1}, actor="u1")
|
||
self.assertTrue(a["created"])
|
||
b = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w1", "rel_type": "binds",
|
||
"required": 0}, actor="u1")
|
||
self.assertFalse(b["created"])
|
||
self.assertEqual(b["id"], a["id"])
|
||
self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 1)
|
||
|
||
def test_ref_tenant_isolation(self):
|
||
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w1"}, actor="u1")
|
||
self.assertEqual(R.list_refs(self.db, OTHER, blueprint_id=self.bp)["total"], 0)
|
||
with self.assertRaises(TenantRequiredError):
|
||
R.list_refs(self.db, None, blueprint_id=self.bp)
|
||
with self.assertRaises(NotFoundError):
|
||
R.get_ref(self.db, OTHER, R.list_refs(self.db, TENANT,
|
||
blueprint_id=self.bp)["items"][0]["id"])
|
||
|
||
def test_src_kind_validation(self):
|
||
with self.assertRaises(ValidationError):
|
||
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "scene", "ref_table": "scene",
|
||
"ref_id": "s1", "src_kind": "mission"},
|
||
actor="u1") # 缺 src_id
|
||
ok = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "scene", "ref_table": "scene",
|
||
"ref_id": "s1", "src_kind": "mission",
|
||
"src_id": "m1"}, actor="u1")
|
||
self.assertTrue(ok["ok"])
|
||
|
||
def test_bulk_all_or_nothing(self):
|
||
with self.assertRaises(ValidationError):
|
||
R.bulk_add_refs(self.db, TENANT, self.bp, [
|
||
{"ref_domain": "world", "ref_table": "world", "ref_id": "w1"},
|
||
{"ref_domain": "bad", "ref_table": "bad", "ref_id": "x"}], actor="u1")
|
||
self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 0)
|
||
|
||
def test_resolve_refs_with_reader(self):
|
||
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w-exist", "required": 1}, actor="u1")
|
||
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w-gone", "required": 1}, actor="u1")
|
||
|
||
def reader(db, table, rid):
|
||
return {"id": rid} if rid == "w-exist" else None
|
||
|
||
res = R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1", reader=reader)
|
||
self.assertEqual(res["resolved_count"], 1)
|
||
self.assertEqual(res["missing_count"], 1)
|
||
self.assertEqual(len(res["blocking"]), 1)
|
||
rows = R.list_refs(self.db, TENANT, blueprint_id=self.bp)["items"]
|
||
st = {r["ref_id"]: r["resolve_status"] for r in rows}
|
||
self.assertEqual(st["w-exist"], "resolved")
|
||
self.assertEqual(st["w-gone"], "missing")
|
||
|
||
def test_resolve_without_reader_stays_unknown(self):
|
||
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w1"}, actor="u1")
|
||
res = R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1")
|
||
self.assertEqual(res["unknown_count"], 1)
|
||
self.assertEqual(res["missing_count"], 0) # 不臆断 missing
|
||
|
||
def test_impact_of_reverse_lookup(self):
|
||
R.add_ref(self.db, TENANT, "bp-1", {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w1", "required": 1}, actor="u1")
|
||
R.add_ref(self.db, TENANT, "bp-2", {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w1"}, actor="u1")
|
||
R.add_ref(self.db, OTHER, "bp-9", {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w1"}, actor="u2")
|
||
imp = R.impact_of(self.db, TENANT, "world", "world", "w1")
|
||
self.assertEqual(imp["blueprint_count"], 2) # 跨租户不泄露
|
||
self.assertEqual(sorted(imp["blueprint_ids"]), ["bp-1", "bp-2"])
|
||
self.assertFalse(imp["safe_to_delete"])
|
||
self.assertTrue(imp["warning"])
|
||
R.remove_ref(self.db, TENANT, R.list_refs(self.db, TENANT, blueprint_id="bp-1")["items"][0]["id"], actor="u1")
|
||
self.assertTrue(R.impact_of(self.db, TENANT, "world", "world", "w1")["safe_to_delete"])
|
||
|
||
def test_refs_from_content_extraction(self):
|
||
content = {
|
||
"world_id": "w-100",
|
||
"missions": [{"id": "m1", "kind": "mission", "scene_id": "sc-1"},
|
||
{"id": "m2", "kind": "mission", "scene_id": "sc-1"}],
|
||
"project": {"id": "pj1", "kind": "project", "game_id": "g-1"},
|
||
}
|
||
refs = R.refs_from_content(content)
|
||
keys = {(r["ref_domain"], r["ref_id"]) for r in refs}
|
||
self.assertIn(("world", "w-100"), keys)
|
||
self.assertIn(("scene", "sc-1"), keys)
|
||
self.assertIn(("scense_game", "g-1"), keys)
|
||
self.assertEqual(len(refs), len(keys)) # 去重生效
|
||
|
||
def test_sync_refs_from_content_idempotent(self):
|
||
content = {"world_id": "w-1", "missions": [{"id": "m1", "kind": "mission",
|
||
"scene_id": "sc-1"}]}
|
||
a = R.sync_refs_from_content(self.db, TENANT, self.bp, content, actor="u1")
|
||
self.assertEqual(a["added_count"], 2)
|
||
b = R.sync_refs_from_content(self.db, TENANT, self.bp, content, actor="u1")
|
||
self.assertEqual(b["added_count"], 0)
|
||
self.assertEqual(b["removed_count"], 0)
|
||
# content 去掉 world 引用 → 关联边同步软删
|
||
c = R.sync_refs_from_content(self.db, TENANT, self.bp,
|
||
{"missions": [{"id": "m1", "kind": "mission",
|
||
"scene_id": "sc-1"}]}, actor="u1")
|
||
self.assertEqual(c["removed_count"], 1)
|
||
self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 1)
|
||
|
||
def test_world_table_untouched(self):
|
||
"""Q-OPEN-3:关联操作绝不写 world 基表。"""
|
||
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w1"}, actor="u1")
|
||
R.sync_refs_from_content(self.db, TENANT, self.bp, {"world_id": "w2"}, actor="u1")
|
||
R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1",
|
||
reader=lambda db, t, i: {"id": i})
|
||
self.assertNotIn("world", self.db.tables)
|
||
self.assertNotIn("scene", self.db.tables)
|
||
self.assertIn("pbl_blueprint_ref", self.db.tables)
|
||
|
||
|
||
class TestApiDispatch(unittest.TestCase):
|
||
def setUp(self):
|
||
self.db = FakeDB()
|
||
|
||
def test_route_dispatch_platform_template(self):
|
||
r = A.dispatch(self.db, "POST", "/pbl/templates",
|
||
{"tenant_id": None, "is_platform_admin": True, "actor": "admin",
|
||
"code": "p1", "name": "平台模板", "scope": "platform",
|
||
"content": {"a": 1}})
|
||
self.assertTrue(r["ok"], r)
|
||
lst = A.dispatch(self.db, "GET", "/pbl/templates", {"tenant_id": TENANT})
|
||
self.assertEqual(lst["total"], 1)
|
||
tid = lst["items"][0]["id"]
|
||
det = A.dispatch(self.db, "GET", "/pbl/templates/%s" % tid, {"tenant_id": TENANT})
|
||
self.assertTrue(det["template"]["is_platform"])
|
||
bad = A.dispatch(self.db, "PUT", "/pbl/templates/%s" % tid,
|
||
{"tenant_id": TENANT, "actor": "u1", "name": "x"})
|
||
self.assertFalse(bad["ok"])
|
||
self.assertEqual(bad["code"], "PBL_PLATFORM_ADMIN_REQUIRED")
|
||
self.assertEqual(bad["http_status"], 403)
|
||
|
||
def test_route_missing_tenant_fail_closed(self):
|
||
r = A.dispatch(self.db, "GET", "/pbl/templates", {})
|
||
self.assertFalse(r["ok"])
|
||
self.assertEqual(r["code"], "PBL_TENANT_REQUIRED")
|
||
|
||
def test_route_unknown(self):
|
||
r = A.dispatch(self.db, "GET", "/pbl/nope", {"tenant_id": TENANT})
|
||
self.assertEqual(r["code"], "PBL_ROUTE_NOT_FOUND")
|
||
|
||
def test_route_ext_and_refs(self):
|
||
A.dispatch(self.db, "POST", "/pbl/ext-defs",
|
||
{"tenant_id": None, "is_platform_admin": True, "actor": "admin",
|
||
"scope": "platform", "subobject_kind": "mission", "ext_key": "difficulty",
|
||
"label": "难度", "value_type": "enum",
|
||
"enum_options": ["easy", "hard"]})
|
||
r = A.dispatch(self.db, "PUT", "/pbl/blueprints/bp-1/ext",
|
||
{"tenant_id": TENANT, "actor": "u1", "kind": "mission",
|
||
"subobject_id": "m1", "values": {"difficulty": "easy"}})
|
||
self.assertTrue(r["ok"], r)
|
||
g = A.dispatch(self.db, "GET", "/pbl/blueprints/bp-1/ext", {"tenant_id": TENANT})
|
||
self.assertEqual(g["ext"]["mission"]["m1"]["difficulty"], "easy")
|
||
add = A.dispatch(self.db, "POST", "/pbl/blueprints/bp-1/refs",
|
||
{"tenant_id": TENANT, "actor": "u1",
|
||
"ref_domain": "world", "ref_table": "world", "ref_id": "w1"})
|
||
self.assertTrue(add["ok"], add)
|
||
imp = A.dispatch(self.db, "GET", "/pbl/refs/impact",
|
||
{"tenant_id": TENANT, "ref_domain": "world", "ref_table": "world",
|
||
"ref_id": "w1"})
|
||
self.assertEqual(imp["blueprint_count"], 1)
|
||
|
||
|
||
class TestInitSeed(unittest.TestCase):
|
||
def test_init_m1b_seeds_platform_data(self):
|
||
db = FakeDB()
|
||
res = I.init_m1b(db=db, seed=True, actor="system")
|
||
self.assertTrue(res["ok"])
|
||
self.assertEqual(res["q_open_3"], "world/scene/entity 基表零改动")
|
||
self.assertNotIn("world", db.tables)
|
||
tpls = T.list_templates(db, TENANT)
|
||
self.assertGreaterEqual(tpls["total"], 3)
|
||
self.assertTrue(all(t["is_platform"] for t in tpls["items"]))
|
||
defs = S.list_ext_field_defs(db, TENANT)
|
||
self.assertGreaterEqual(defs["total"], 20)
|
||
self.assertTrue(all(d["is_platform"] for d in defs["items"]))
|
||
# 幂等
|
||
again = I.seed_platform_data(db, actor="system")
|
||
self.assertEqual(again["templates"]["created_count"], 0)
|
||
self.assertEqual(again["ext_defs"]["created_count"], 0)
|
||
self.assertEqual(T.list_templates(db, TENANT)["total"], tpls["total"])
|
||
|
||
def test_platform_seed_usable_end_to_end(self):
|
||
db = FakeDB()
|
||
I.init_m1b(db=db, seed=True)
|
||
tpl = [t for t in T.list_templates(db, TENANT)["items"]
|
||
if t["code"] == "pbl-stem-water-quality"][0]
|
||
full = T.get_template(db, TENANT, tpl["id"])
|
||
inst = T.instantiate_template(db, TENANT, tpl["id"], actor="u1")
|
||
self.assertTrue(inst["result"]["deferred"])
|
||
self.assertEqual(inst["result"]["payload"]["tenant_id"], TENANT)
|
||
# 模板 ext_schema 默认值可落到子对象扩展
|
||
res = S.apply_template_ext_schema(
|
||
db, TENANT, "bp-new", full["ext_schema"],
|
||
{"mission": ["ms-1", "ms-2"], "role": ["rl-1"], "project": ["pj-1"],
|
||
"driving_question": ["dq-1"], "artifact_def": ["af-1"]},
|
||
actor="u1", source_template_id=tpl["id"])
|
||
self.assertGreater(res["applied_count"], 5)
|
||
self.assertEqual(S.get_ext(db, TENANT, "bp-new", "mission", "ms-1", "difficulty"),
|
||
"medium")
|
||
self.assertEqual(S.get_ext(db, TENANT, "bp-new", "project", "pj-1", "assessment_mode"),
|
||
"rubric")
|
||
# content 中的跨域引用可抽取(本模板无 world_id → 0 条,不报错)
|
||
sync = R.sync_refs_from_content(db, TENANT, "bp-new", full["content"], actor="u1")
|
||
self.assertTrue(sync["ok"])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main(verbosity=2)
|