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

537 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 -*-
"""pbl_blueprint M1a 契约测试走真实模块加载路径load_pbl_blueprint + 门面函数)。
覆盖:注册三处同步、表清单/字段类型、租户强制、CRUD 正反用例、状态机、
版本快照、fork、子对象 7 类、模板抽离与实例化、dspy/RBAC/i18n 一致性、SQL 方言自检。
DB 替身tests/fakedb.py模拟 sqlor C/U/D/R/I/sqlExe 语义),
真实库连通性另见 test_real_path.py。
"""
import json
import os
import re
import sys
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
PKG = os.path.join(REPO, 'pbl_blueprint')
sys.path.insert(0, REPO)
sys.path.insert(0, HERE)
import fakedb # noqa: E402
@pytest.fixture()
def mod():
"""每个用例一套干净的内存 DB + 真实 load_pbl_blueprint() 注册路径。"""
sor, env, store = fakedb.install(tenant_id='T1')
for name in [m for m in list(sys.modules) if m.startswith('pbl_blueprint')]:
sys.modules.pop(name, None)
import pbl_blueprint as pkg
info = pkg.load_pbl_blueprint(env)
yield pkg, info, store, env
sys.modules.pop('sqlor', None)
# ---------------------------------------------------------------- 注册接线
def test_01_load_registers_tables_and_crud(mod):
pkg, info, store, env = mod
assert info['module'] == 'pbl_blueprint'
assert info['dbname'] == 'pbl_test_db'
assert len(info['tables']) == 11
assert sorted(info['tables']) == sorted(pkg.TABLES)
assert sorted(info['crud']) == sorted(pkg.TABLES)
# env 上挂载了表定义 / CRUD / 门面 / 路径 / i18n
assert getattr(env, 'pbl_blueprint_tables')
assert getattr(env, 'pbl_crud_defs')['pbl_blueprint']['params']['editable']
assert getattr(env, 'pbl_blueprint_api')['blueprint_save'] is pkg.api_blueprint_save
assert len(getattr(env, 'pbl_blueprint_paths')['api']) == 25
assert getattr(env, 'pbl_blueprint_i18n').get('zh_CN')
def test_02_three_way_sync_of_functions(mod):
"""函数三处同步:定义 + __init__ 导出 + init.py 注册env.pbl_blueprint_api"""
pkg, _info, _store, env = mod
exported = set(getattr(pkg, '__all__'))
registry = set(getattr(env, 'pbl_blueprint_api').keys())
assert registry == set(pkg.API_REGISTRY.keys())
for fname, fn in pkg.API_REGISTRY.items():
assert callable(fn), fname
assert 'api_' + fname in exported or fname in exported, fname
# load 入口本身三处同步
assert 'load_pbl_blueprint' in exported
assert callable(getattr(pkg, 'load_pbl_blueprint'))
def test_03_dbname_not_hardcoded(mod):
pkg, _i, _s, _e = mod
pat = re.compile(r'''dbname\s*=\s*["'][^"']+["']''')
for f in sorted(os.listdir(PKG)):
if not f.endswith('.py'):
continue
text = open(os.path.join(PKG, f), encoding='utf-8').read()
for line_no, line in enumerate(text.splitlines(), 1):
if pat.search(line) and 'def ' not in line and 'kw' not in line:
raise AssertionError('%s:%d 疑似硬编码库名: %s' % (f, line_no, line.strip()))
def test_04_missing_dbname_fails_closed(mod):
pkg, _i, _s, env = mod
env.set_module_dbname('pbl_blueprint', None)
res = pkg.api_blueprint_list({'page': 1})
assert res['success'] is False
assert res['error']['code'] == 'PBL-DB-001'
def test_05_missing_tenant_fails_closed(mod):
pkg, _i, _s, env = mod
env.pbl_tenant_ctx = {}
res = pkg.api_blueprint_save({'blueprint_name': '无租户'})
assert res['success'] is False
assert res['error']['code'] == 'PBL-TENANT-001'
# ---------------------------------------------------------------- 表定义规范
def test_06_model_four_segment_and_abstract_types():
for fn in sorted(os.listdir(os.path.join(PKG, 'models'))):
if not fn.endswith('.json'):
continue
doc = json.load(open(os.path.join(PKG, 'models', fn), encoding='utf-8'))
assert set(['summary', 'fields', 'indexes', 'codes']).issubset(doc.keys()), fn
assert doc['fields']['id']['type'] == 'str(32)', fn
assert doc['fields']['id'].get('pk') is True, fn
assert 'tenant_id' in doc['fields'], fn
for col, spec in doc['fields'].items():
ty = spec['type']
assert not re.match(r'^(varchar|bigint|boolean|int\(11\)|char|tinyint)', ty), \
'%s.%s 使用了物理类型 %s' % (fn, col, ty)
if ty.startswith('double'):
assert ty == 'double(18,2)', '%s.%s' % (fn, col)
def test_07_crud_json_root_key_is_tblname():
for fn in sorted(os.listdir(os.path.join(PKG, 'json'))):
if not fn.endswith('.json'):
continue
tbl = fn[:-5]
doc = json.load(open(os.path.join(PKG, 'json', fn), encoding='utf-8'))
assert list(doc.keys()) == [tbl], fn
params = doc[tbl]['params']
assert 'editable' in params and 'browserfields' in params, fn
for k in ('new_data_url', 'update_data_url', 'delete_data_url'):
assert k in params['editable'], fn
assert 'wwwroot' not in json.dumps(params), fn
model_fields = json.load(
open(os.path.join(PKG, 'models', fn), encoding='utf-8'))['fields']
for col in params['editable']['editexclouded'] + params['browserfields']['fields']:
assert col in model_fields, '%s 字段 %s 不在 models 定义中' % (fn, col)
def test_08_sql_ddl_dialect_self_check():
sql = open(os.path.join(PKG, 'sql', 'pbl_blueprint.core.sql'), encoding='utf-8').read()
for bad in ('SERIAL', 'BIGSERIAL', 'nextval', 'AUTO_INCREMENT', 'boolean', 'int(11)'):
assert bad not in sql, 'SQL 含禁用写法 %s' % bad
assert 'engine: postgresql' in sql
for tbl in json.loads(json.dumps(__import__('pbl_blueprint', fromlist=['TABLES']).TABLES)):
pass
import pbl_blueprint as pkg
for tbl in pkg.TABLES:
assert 'CREATE TABLE IF NOT EXISTS %s' % tbl in sql, tbl
# ---------------------------------------------------------------- 蓝图 CRUD
def _new_blueprint(pkg, name='城市水资源调查'):
res = pkg.api_blueprint_save({'blueprint_name': name, 'domain_code': 'science',
'grade_code': 'primary_5', 'subject_code': 'science',
'project_duration': 12, 'student_count': 40,
'group_count': 8, 'complexity_level': 'medium',
'estimated_cost': 1250.50})
assert res['success'], res
return res['data']
def test_10_create_blueprint_auto_code_and_tenant(mod):
pkg, _i, store, _e = mod
bp = _new_blueprint(pkg)
assert bp['id'] and len(bp['id']) == 32
assert re.match(r'^PBL-\d{4}-\d{4}$', bp['blueprint_code']), bp['blueprint_code']
assert bp['tenant_id'] == 'T1'
assert bp['status'] == 'draft'
assert float(bp['estimated_cost']) == 1250.50
assert 'pbl_blueprint' in store
def test_11_create_requires_name(mod):
pkg, _i, _s, _e = mod
res = pkg.api_blueprint_save({'domain_code': 'science'})
assert res['success'] is False
assert res['error']['code'] == 'PBL-PARAM-001'
def test_12_duplicate_code_rejected(mod):
pkg, _i, _s, _e = mod
bp = _new_blueprint(pkg)
res = pkg.api_blueprint_save({'blueprint_name': '重名编码',
'blueprint_code': bp['blueprint_code']})
assert res['success'] is False
assert '已存在' in res['error']['msg']
def test_13_update_and_published_readonly(mod):
pkg, _i, _s, _e = mod
bp = _new_blueprint(pkg)
res = pkg.api_blueprint_save({'id': bp['id'], 'blueprint_name': '改名成功'})
assert res['success'] and res['data']['blueprint_name'] == '改名成功'
# 发布后禁止直接编辑
pkg.api_blueprint_publish({'id': bp['id']}, _seed_subobjects(pkg, bp['id']))
res2 = pkg.api_blueprint_save({'id': bp['id'], 'blueprint_name': '非法改'})
assert res2['success'] is False
assert res2['error']['code'] == 'PBL-STATE-001'
def test_14_list_pagination_and_filters(mod):
pkg, _i, _s, _e = mod
for n in range(5):
_new_blueprint(pkg, '蓝图%d' % n)
res = pkg.api_blueprint_list({'page': 1, 'rows': 2})
assert res['success']
assert res['data']['total'] == 5 and len(res['data']['rows']) == 2
res2 = pkg.api_blueprint_list({'blueprint_name': '蓝图3'})
assert res2['data']['total'] == 1
res3 = pkg.api_blueprint_list({'sortby': 'evil; drop table'})
assert res3['success'] and res3['data']['total'] == 5 # 非法排序被白名单兜住
def test_15_tenant_isolation(mod):
pkg, _i, store, env = mod
bp = _new_blueprint(pkg)
env.pbl_tenant_ctx = {'tenant_id': 'T2'}
res = pkg.api_blueprint_get({'id': bp['id']})
assert res['success'] is False
assert res['error']['code'] == 'PBL-NOT-FOUND'
res2 = pkg.api_blueprint_list({})
assert res2['data']['total'] == 0
def test_16_soft_delete_filters_out(mod):
pkg, _i, store, _e = mod
bp = _new_blueprint(pkg)
res = pkg.api_blueprint_delete({'id': bp['id']})
assert res['success'] and res['data']['deleted'] is True
assert store['pbl_blueprint'][bp['id']]['is_deleted'] == 1
assert pkg.api_blueprint_get({'id': bp['id']})['success'] is False
def _seed_subobjects(pkg, blueprint_id, tenant=None):
for stype, name in [('mission', '为社区设计节水方案'),
('learning_goal', '理解水循环'),
('task', '实地调研'),
('role', '组长'),
('artifact_spec', '节水手册'),
('evidence_spec', '调研报告'),
('reflection_spec', '小组复盘')]:
r = pkg.api_subobject_save({'subobject_type': stype, 'blueprint_id': blueprint_id,
'name': name, 'seq_no': 1})
assert r['success'], r
return blueprint_id
def test_20_subobject_crud_all_types(mod):
pkg, _i, store, _e = mod
bp = _new_blueprint(pkg)
_seed_subobjects(pkg, bp['id'])
counts = pkg.api_subobject_counts({'blueprint_id': bp['id']})
assert counts['success']
assert counts['data']['total'] == 7
assert set(counts['data'].keys()) >= set(pkg.SUBOBJECT_TYPE_TABLE.keys())
lst = pkg.api_subobject_list({'subobject_type': 'mission', 'blueprint_id': bp['id']})
assert lst['data']['total'] == 1
oid = lst['data']['rows'][0]['id']
upd = pkg.api_subobject_save({'subobject_type': 'mission', 'id': oid,
'blueprint_id': bp['id'], 'name': '改后情境'})
assert upd['data']['name'] == '改后情境'
dele = pkg.api_subobject_delete({'subobject_type': 'mission', 'id': oid})
assert dele['success'] and store['pbl_blueprint_mission'][oid]['is_deleted'] == 1
def test_21_subobject_unknown_type_rejected(mod):
pkg, _i, _s, _e = mod
bp = _new_blueprint(pkg)
res = pkg.api_subobject_save({'subobject_type': 'hack_me', 'blueprint_id': bp['id'],
'name': 'x'})
assert res['success'] is False
assert res['error']['code'] == 'PBL-PARAM-001'
def test_22_subobject_requires_existing_blueprint(mod):
pkg, _i, _s, _e = mod
res = pkg.api_subobject_save({'subobject_type': 'task', 'blueprint_id': 'no-such',
'name': 'x'})
assert res['success'] is False
assert res['error']['code'] == 'PBL-NOT-FOUND'
def test_23_tree_aggregates_children(mod):
pkg, _i, _s, _e = mod
bp = _new_blueprint(pkg)
_seed_subobjects(pkg, bp['id'])
res = pkg.api_blueprint_tree({'id': bp['id']})
assert res['success']
data = res['data']
assert data['blueprint']['id'] == bp['id']
assert len(data['children']) == 7
assert sum(data['counts'].values()) == 7
# ---------------------------------------------------------------- 状态机 / 版本
def test_30_status_flow_legal_and_illegal(mod):
pkg, _i, _s, _e = mod
bp = _new_blueprint(pkg)
r = pkg.api_blueprint_status({'id': bp['id'], 'status': 'in_review'})
assert r['success'] and r['data']['status'] == 'in_review'
bad = pkg.api_blueprint_status({'id': bp['id'], 'status': 'archived_from_nowhere'})
assert bad['success'] is False
r2 = pkg.api_blueprint_status({'id': bp['id'], 'status': 'archived'})
assert r2['success']
r3 = pkg.api_blueprint_status({'id': bp['id'], 'status': 'draft'})
assert r3['success'] is False # archived 为终态
assert r3['error']['code'] == 'PBL-STATE-001'
def test_31_publish_requires_goal_and_mission(mod):
pkg, _i, _s, _e = mod
bp = _new_blueprint(pkg)
res = pkg.api_blueprint_publish({'id': bp['id']})
assert res['success'] is False
assert res['error']['code'] == 'PBL-STATE-001'
assert '学习目标' in res['error']['msg'] or '情境' in res['error']['msg']
def test_32_publish_creates_version_snapshot(mod):
pkg, _i, store, _e = mod
bp = _new_blueprint(pkg)
_seed_subobjects(pkg, bp['id'])
res = pkg.api_blueprint_publish({'id': bp['id'], 'note': '首版'})
assert res['success'], res
data = res['data']
assert data['version_no'] == 1
assert len(data['content_hash']) == 64
ver = store['pbl_blueprint_version'][data['version_id']]
snap = json.loads(ver['snapshot_json'])
assert snap['blueprint']['blueprint_name'] == '城市水资源调查'
assert len(snap['subobjects']['mission']) == 1
assert store['pbl_blueprint'][bp['id']]['status'] == 'published'
# 二次发布版本号自增
pkg.api_blueprint_status({'id': bp['id'], 'status': 'in_review'})
res2 = pkg.api_blueprint_publish({'id': bp['id'], 'note': '二版'})
assert res2['data']['version_no'] == 2
vs = pkg.api_blueprint_versions({'id': bp['id']})
assert vs['data']['total'] == 2
vg = pkg.api_blueprint_version_get({'version_id': data['version_id']})
assert vg['data']['snapshot']['blueprint']['id'] == bp['id']
def test_33_fork_copies_subobjects(mod):
pkg, _i, store, _e = mod
bp = _new_blueprint(pkg)
_seed_subobjects(pkg, bp['id'])
res = pkg.api_blueprint_fork({'id': bp['id'], 'class_id': 'C99',
'blueprint_name': '副本蓝图'})
assert res['success'], res
nid = res['data']['id']
assert res['data']['copied_subobjects']['total'] == 7
new = store['pbl_blueprint'][nid]
assert new['status'] == 'draft' and new['current_version'] == 0
assert new['source_blueprint_id'] == bp['id']
assert new['class_id'] == 'C99'
assert new['blueprint_code'] != bp['blueprint_code']
assert pkg.api_subobject_counts({'blueprint_id': nid})['data']['total'] == 7
# ---------------------------------------------------------------- 模板
def test_40_template_from_blueprint_and_instantiate(mod):
pkg, _i, store, _e = mod
bp = _new_blueprint(pkg)
_seed_subobjects(pkg, bp['id'])
res = pkg.api_template_from_blueprint({'blueprint_id': bp['id'],
'template_name': '水资源模板'})
assert res['success'], res
tpl = res['data']
assert tpl['item_created'] == 7
assert tpl['source_blueprint_id'] == bp['id']
lst = pkg.api_template_list({})
assert lst['data']['total'] == 1
inst = pkg.api_template_instantiate({'id': tpl['id'],
'payload': {'blueprint_name': '实例化蓝图'}})
assert inst['success'], inst
nbp = store['pbl_blueprint'][inst['data']['blueprint_id']]
assert nbp['source_template_id'] == tpl['id']
assert nbp['status'] == 'draft'
assert pkg.api_subobject_counts({'blueprint_id': nbp['id']})['data']['total'] == 7
assert store['pbl_blueprint_template'][tpl['id']]['usage_count'] == 1
def test_41_template_save_and_delete(mod):
pkg, _i, store, _e = mod
r = pkg.api_template_save({'template_name': '空模板', 'template_type': 'blueprint'})
assert r['success'] and r['data']['template_code'].startswith('TPL-')
tid = r['data']['id']
r2 = pkg.api_template_save({'id': tid, 'template_name': '空模板改名'})
assert r2['data']['template_name'] == '空模板改名'
r3 = pkg.api_template_delete({'id': tid})
assert r3['success'] and store['pbl_blueprint_template'][tid]['is_deleted'] == 1
assert pkg.api_template_get({'id': tid})['success'] is False
def test_42_template_get_missing(mod):
pkg, _i, _s, _e = mod
assert pkg.api_template_get({'id': 'x'})['error']['code'] == 'PBL-NOT-FOUND'
# ---------------------------------------------------------------- 前端/权限一致性
def test_50_dspy_files_match_registered_paths(mod):
pkg, _i, _s, env = mod
api_dir = os.path.join(PKG, 'wwwroot', 'api')
dspy = set(f for f in os.listdir(api_dir) if f.endswith('.dspy'))
paths = set(os.path.basename(p) for p in getattr(env, 'pbl_blueprint_paths')['api'])
assert dspy == paths, ('dspy 与注册路径不一致', dspy ^ paths)
registry = set(pkg.API_REGISTRY.keys())
assert registry == set(f[:-5] for f in dspy) | {'blueprint_versions', 'blueprint_version_get',
'subobject_counts', 'table_manifest',
'template_get', 'template_delete',
'template_from_blueprint'} - {''}
def test_51_dspy_forbidden_patterns():
api_dir = os.path.join(PKG, 'wwwroot', 'api')
bad = re.compile(r'^\s*(import|from)\s|f["\']|\bprint\(|uuid\.|\bopen\(')
for f in sorted(os.listdir(api_dir)):
text = open(os.path.join(api_dir, f), encoding='utf-8').read()
for i, line in enumerate(text.splitlines(), 1):
if line.strip().startswith('#'):
continue
assert not bad.search(line), '%s:%d 命中禁项: %s' % (f, i, line.strip())
assert 'return' in text, f
def test_52_py_only_uses_sqlor_whitelist():
allowed = re.compile(r'sor\.|_db\.(insert_row|update_row|delete_row|select_rows|'
r'select_one|sql_exe|sql|_)|def (C|U|D|R|I|sql_exe)\b')
forbidden = re.compile(r'''\b(sor|sqlor)\.(save|list|insert|query|update|delete|select)\s*\(''')
for f in sorted(os.listdir(PKG)):
if not f.endswith('.py'):
continue
text = open(os.path.join(PKG, f), encoding='utf-8').read()
for i, line in enumerate(text.splitlines(), 1):
assert not forbidden.search(line), '%s:%d 编造 API: %s' % (f, i, line.strip())
assert allowed # 语义占位
def test_53_load_path_covers_all_paths():
sys.path.insert(0, os.path.join(REPO, 'scripts'))
import importlib.util
spec = importlib.util.spec_from_file_location(
'load_path_mod', os.path.join(REPO, 'scripts', 'load_path.py'))
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
entries = set(p for _r, p in m.build_entries())
from pbl_blueprint.m1b_compat import ( # M1b compat: pbl_blueprint.init 半迁移缺失符号改由包内兼容层供给
API_PATHS,
PAGE_PATHS,
CRUD_ALIASES,
)
for p in [x for x, _f, _r in API_PATHS] + [x for x, _r in PAGE_PATHS] + list(CRUD_ALIASES):
assert p in entries, 'load_path.py 漏注册 %s' % p
assert len([p for p in entries if p.endswith('.dspy')]) == 25
def test_54_i18n_covers_all_ui_labels():
zh = json.load(open(os.path.join(PKG, 'i18n', 'pbl_blueprint.zh_CN.json'), encoding='utf-8'))
en = json.load(open(os.path.join(PKG, 'i18n', 'pbl_blueprint.en_US.json'), encoding='utf-8'))
pat = re.compile(r"\{\{t\('([^']+)'")
used = set()
www = os.path.join(PKG, 'wwwroot')
for root, _dirs, files in os.walk(www):
for f in files:
if f.endswith('.ui'):
txt = open(os.path.join(root, f), encoding='utf-8').read()
used |= set(pat.findall(txt))
missing = used - set(zh.keys())
assert not missing, 'i18n 缺 key: %s' % sorted(missing)
assert set(zh.keys()) - set(en.keys()) == set()
def test_55_ui_urls_have_no_wwwroot_prefix():
www = os.path.join(PKG, 'wwwroot')
for root, _dirs, files in os.walk(www):
for f in files:
if f.endswith('.ui'):
txt = open(os.path.join(root, f), encoding='utf-8').read()
assert 'wwwroot' not in txt, f
for p in re.findall(r"entire_url\('(/pbl_blueprint/[^']+)'\)", txt):
tail = p.replace('/pbl_blueprint/', '')
assert os.path.exists(os.path.join(www, tail)), '%s -> %s 不存在' % (f, p)
# ---------------------------------------------------------------- 建表 / 自省
def test_60_ensure_schema_issues_idempotent_ddl(mod):
pkg, _i, store, _e = mod
res = pkg.ensure_schema()
assert res['success'], res
assert res['data']['pbl_blueprint'] >= 1
def test_61_table_manifest_shape(mod):
pkg, _i, _s, _e = mod
res = pkg.api_table_manifest()
assert res['success']
data = res['data']
assert data['engine'] == 'postgresql' and len(data['tables']) == 11
bp = [t for t in data['tables'] if t['table'] == 'pbl_blueprint'][0]
assert bp['pk'] == ['id'] and bp['tenant_field'] == 'tenant_id'
assert bp['field_types']['id'] == 'str(32)'
assert bp['field_types']['estimated_cost'] == 'double(18,2)'
assert bp['soft_delete_field'] == 'is_deleted'
def test_62_options_endpoints(mod):
pkg, _i, _s, _e = mod
assert len(pkg.api_blueprint_status_options()['data']) == 4
assert len(pkg.api_blueprint_domain_options()['data']) >= 5
assert len(pkg.api_subobject_type_options()['data']) == 7
# >>> M1b compat exports (auto-generated, idempotent) >>>
# 由 tools/m1b_fix_import_closure_v2.py 幂等生成;不覆盖本文件既有同名定义。
# QC #1 铁律:逐符号 getattr 采纳,单符号缺失不牵连其余符号;
# 供给方 pbl_blueprint.m1b_compat 为包内自足模块(无裸跨包 import恒可导入
_M1B_COMPAT_SYMBOLS = (
'API_PATHS',
'PAGE_PATHS',
'CRUD_ALIASES',
)
try:
import pbl_blueprint.m1b_compat as _m1b_compat
except ImportError: # pragma: no cover
try:
from . import m1b_compat as _m1b_compat
except ImportError:
_m1b_compat = None
if _m1b_compat is not None:
for _name in _M1B_COMPAT_SYMBOLS:
if _name in globals():
continue
_val = getattr(_m1b_compat, _name, None)
if _val is not None:
globals()[_name] = _val
# 装配面硬保证load_m1b / offline 必须可调用api_blueprint.py:504 引用点)
if not callable(globals().get('load_m1b')) and _m1b_compat is not None:
globals()['load_m1b'] = _m1b_compat.load_m1b
if not callable(globals().get('offline')) and _m1b_compat is not None:
globals()['offline'] = _m1b_compat.offline
# <<< M1b compat exports <<<