test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检

新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、
check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、
check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、
e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、
dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。

压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个
WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。

度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」
(submitted / created / canExport),而不是 HTTP 200 + code 0。

验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
This commit is contained in:
2026-09-30 09:02:57 +08:00
parent 6945f194b5
commit 30c3714d35
26 changed files with 9190 additions and 1 deletions
+310
View File
@@ -0,0 +1,310 @@
# -*- coding: utf-8 -*-
"""字典管理 E2E(《03-接口设计》§13.3)。
这一屏的验收点不在于"能不能增删改",而在于**三条边界的口径**:
① **内置字典的项名改不了**(只可停用单项 + 追加自定义项)——
内置选项照抄官方表格,改名会让导出的汇总表与官方模板对不上,
而那时没有任何地方会报错;
② **项码建后不可改** —— 历史数据里存的是项码,改了它过去填过的值
会指向一个不存在的项,而查询**不报错、只显示空白**;
③ **有下级项的项不可停用**(40036)—— 否则会出现"选了父项却选不到子项",
而数据层面看不出矛盾。
另外两条容易做错的:
· `itemCount` 只算**启用中**的项(把停用的也算进去,管理员会以为界面漏了);
· 内置项的"停用"与自定义项的"停用"是**两回事**,分开存才能"一键恢复默认"。
跑法:python scripts/e2e-m1-dict.py
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
import urllib.parse
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
def _load(module_name: str, filename: str):
path = os.path.join(ROOT, "scripts", filename)
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
BASE = "http://localhost:8080"
# 测试用字典码(用自定义字典,不污染内置的)
TEST_DICT = "E2E_SUBJECT"
TENANT = 1
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> str:
_, out = R.sql(stmt)
return out
def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict:
_, b = R.call(method, f"{BASE}{path}", token, payload)
return b
def dict_of(token: str) -> dict:
d = body_of("GET", "/api/v1/dicts", token).get("data") or []
return {x["dictCode"]: x for x in d}
def items_of(token: str, code: str) -> dict:
return body_of("GET", f"/api/v1/dicts/{code}/items", token).get("data") or {}
def main() -> int:
ap = argparse.ArgumentParser(description="字典管理 E2E")
ap.parse_args()
print("=" * 74)
print(" 字典管理 E2E(§13.3)")
print("=" * 74)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(f" ✗ 管理员登录失败:{adm}")
return 2
at = adm["data"]["token"]
cleanup()
# ── 1. 内置字典目录 ──
print("\n[1] 内置字典目录(6 个)")
ds = dict_of(at)
check("字典列表可用", len(ds) >= 6, f"{sorted(ds)}")
expect = {"EDU_LEVEL": 11, "JOB_LEVEL": 5, "CERT_TYPE": 3,
"RETARGET_SUBJECT": 3, "REJECT_REASON": 10}
for code, n in expect.items():
check(f"{code} 项数 = {n}", (ds.get(code) or {}).get("itemCount") == n,
json.dumps(ds.get(code), ensure_ascii=False))
check("⭐ 内置字典都标了 builtin=true",
all((ds.get(c) or {}).get("builtin") is True for c in expect),
json.dumps({c: (ds.get(c) or {}).get("builtin") for c in expect}))
check("OCCUPATION 也在列表里(国标职业库,一期为空时 0 项)",
"OCCUPATION" in ds, f"{sorted(ds)}")
# ── 2. 学历项与表单口径一致 ──
print("\n[2] 学历项必须与表单下拉一致(两个入口看同一份口径)")
edu = items_of(at, "EDU_LEVEL")
names = [i["itemName"] for i in edu.get("list") or []]
check("学历 11 项", len(names) == 11, f"{names}")
check("含官方里那几项(照抄不得擅改)",
all(x in names for x in ("博士研究生", "技工学校", "其他")), f"{names}")
check("⭐ 内置项没有 id(它们在代码里,没有数据库行)",
all(i.get("id") is None for i in edu.get("list") or []),
json.dumps((edu.get("list") or [])[:1], ensure_ascii=False))
check("内置项都标了 builtin=true",
all(i.get("builtin") is True for i in edu.get("list") or []))
# 与表单口径对一遍:这是"两个入口一份口径"的机器化验证
jl = items_of(at, "JOB_LEVEL")
jnames = [i["itemName"] for i in jl.get("list") or []]
check("等级 5 项且文案是「四级/中级工」这种官方写法",
jnames == ["五级/初级工", "四级/中级工", "三级/高级工", "二级/技师", "一级/高级技师"],
f"{jnames}")
# ── 3. 驳回原因字典的 itemCode 是 R001 这种码 ──
print("\n[3] 驳回原因字典:码是 R001,项名是文案")
rr = items_of(at, "REJECT_REASON")
codes = [i["itemCode"] for i in rr.get("list") or []]
check("10 条且码是 R001~R009 + R999",
codes[:3] == ["R001", "R002", "R003"] and "R999" in codes, f"{codes}")
check("项名是可读文案(不是码本身)",
any("材料" in (i.get("itemName") or "") for i in rr.get("list") or []),
json.dumps((rr.get("list") or [])[:1], ensure_ascii=False))
# ── 4. 追加自定义项(对内置字典的追加)──
print("\n[4] 对内置字典追加自定义项(内置项本身不动)")
c = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", at,
{"itemCode": "E2E_TECH", "itemName": "E2E技师学院"})
check("追加成功", c.get("code") == 0, json.dumps(c, ensure_ascii=False)[:250])
new_id = (c.get("data") or {}).get("id")
check("返回了 id(自定义项有 id,内置项没有)", isinstance(new_id, int), f"{new_id}")
edu2 = items_of(at, "EDU_LEVEL")
check("列表里能看到内置 11 项 + 自定义 1 项",
len(edu2.get("list") or []) == 12, f"{len(edu2.get('list') or [])}")
check("自定义项标 builtin=false",
any(i.get("itemCode") == "E2E_TECH" and i.get("builtin") is False
for i in edu2.get("list") or []))
ds2 = dict_of(at)
check("⭐ itemCount 变成 12(含自定义项)",
(ds2.get("EDU_LEVEL") or {}).get("itemCount") == 12,
json.dumps(ds2.get("EDU_LEVEL"), ensure_ascii=False))
# ── 5. 重复项码要拒 ──
print("\n[5] 重复项码 / 与内置项撞名")
dup = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", at,
{"itemCode": "E2E_TECH", "itemName": "重复的"})
check("同字典内项码重复 → 拒绝", dup.get("code") != 0, f"code={dup.get('code')}")
dup2 = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", at,
{"itemCode": "大学本科", "itemName": "想覆盖内置项"})
check("⭐ 与内置项撞码 → 拒绝,且说明它是内置项",
dup2.get("code") != 0 and "内置" in (dup2.get("msg") or ""),
f"code={dup2.get('code')} msg={dup2.get('msg')}")
bad = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", at, {"itemCode": "X"})
check("缺项名 → 拒绝", bad.get("code") != 0, f"code={bad.get('code')}")
# ── 6. 改 / 停用 / 恢复 ──
print("\n[6] 改自定义项 / 停用 / 恢复")
u = body_of("PUT", f"/api/v1/dicts/items/{new_id}", at,
{"itemName": "E2E技师学院(改)", "sortNo": 5})
check("改名成功", u.get("code") == 0, json.dumps(u, ensure_ascii=False)[:200])
check("库里项名已改",
sql(f"select item_name from sys_dict_item where id={new_id};").strip()
== "E2E技师学院(改)")
d = body_of("PUT", f"/api/v1/dicts/items/{new_id}", at, {"status": 0})
check("停用成功", d.get("code") == 0, json.dumps(d, ensure_ascii=False)[:200])
ds3 = dict_of(at)
check("⭐ 停用后 itemCount 回到 11(只算启用中的项)",
(ds3.get("EDU_LEVEL") or {}).get("itemCount") == 11,
json.dumps(ds3.get("EDU_LEVEL"), ensure_ascii=False))
check("停用后项还在列表里(不是删掉)",
any(i.get("itemCode") == "E2E_TECH" for i in
(items_of(at, "EDU_LEVEL").get("list") or [])))
r = body_of("PUT", f"/api/v1/dicts/items/{new_id}", at, {"status": 1})
check("重新启用成功", r.get("code") == 0)
# ── 7. 内置项只能停用,不能改名 ──
print("\n[7] 内置项:只能停用,不能改名/删除")
nb = body_of("PUT", "/api/v1/dicts/items/0", at, {"itemName": "改内置项"})
check("改不存在的 id → 拒绝", nb.get("code") != 0, f"code={nb.get('code')}")
# ⚠️ 路径里有中文项码(内置项码就是中文,例如「小学」)→ 必须 URL 编码:
# http.client 只接受 ASCII 请求行,不编码会直接 UnicodeEncodeError
enc = urllib.parse.quote("小学")
t = body_of("POST", f"/api/v1/dicts/EDU_LEVEL/items/{enc}/toggle?enabled=false", at)
check("停用内置项「小学」成功", t.get("code") == 0, json.dumps(t, ensure_ascii=False)[:250])
edu3 = items_of(at, "EDU_LEVEL")
xiao = next((i for i in edu3.get("list") or [] if i.get("itemCode") == "小学"), None)
check("停用后 status=0(项还在,只是停用)", (xiao or {}).get("status") == 0,
json.dumps(xiao, ensure_ascii=False))
ds4 = dict_of(at)
check("⭐ itemCount 变成 11(内置 10 启用 + 自定义 1)",
(ds4.get("EDU_LEVEL") or {}).get("itemCount") == 11,
json.dumps(ds4.get("EDU_LEVEL"), ensure_ascii=False))
check("停用记在 sys_dict_disabled(不是 sys_dict_item)",
sql("select count(*) from sys_dict_disabled where dict_code='EDU_LEVEL' "
"and item_code='小学';").strip() == "1")
t2 = body_of("POST", f"/api/v1/dicts/EDU_LEVEL/items/{enc}/toggle?enabled=true", at)
check("⭐ 一键恢复默认可用(这正是把两者分开存的原因)", t2.get("code") == 0,
json.dumps(t2, ensure_ascii=False)[:200])
check("恢复后 status 又是 1",
next((i.get("status") for i in (items_of(at, "EDU_LEVEL").get("list") or [])
if i.get("itemCode") == "小学"), None) == 1)
bad2 = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items/E2E_TECH/toggle?enabled=false", at)
check("对**自定义**项调内置 toggle → 拒绝(说清它不是内置项)",
bad2.get("code") != 0 and "内置" in (bad2.get("msg") or ""),
f"code={bad2.get('code')} msg={bad2.get('msg')}")
# ── 8. 层级:有下级项的项不可停用(40036)──
print("\n[8] 层级(40036:有下级项的项不可停用)")
p = body_of("POST", f"/api/v1/dicts/{TEST_DICT}/items", at,
{"itemCode": "P1", "itemName": "父项"})
check("建父项成功(自定义字典码会被隐式创建)", p.get("code") == 0,
json.dumps(p, ensure_ascii=False)[:200])
pid = (p.get("data") or {}).get("id")
ch = body_of("POST", f"/api/v1/dicts/{TEST_DICT}/items", at,
{"itemCode": "C1", "itemName": "子项", "parentItemCode": "P1"})
check("建子项成功", ch.get("code") == 0, json.dumps(ch, ensure_ascii=False)[:200])
cid = (ch.get("data") or {}).get("id")
check("自定义字典出现在字典列表里且 builtin=false",
(dict_of(at).get(TEST_DICT) or {}).get("builtin") is False,
json.dumps(dict_of(at).get(TEST_DICT), ensure_ascii=False))
items = items_of(at, TEST_DICT)
check("字典项接口标出这个字典支持层级", items.get("hierarchical") is True,
json.dumps(items, ensure_ascii=False)[:200])
stop = body_of("PUT", f"/api/v1/dicts/items/{pid}", at, {"status": 0})
check("⭐ 停用父项 → 40036(有下级不可停用)", stop.get("code") == 40036,
f"code={stop.get('code')} msg={stop.get('msg')}")
dele = body_of("DELETE", f"/api/v1/dicts/items/{pid}", at)
check("⭐ 删父项 → 同样被拦(40036)", dele.get("code") == 40036,
f"code={dele.get('code')} msg={dele.get('msg')}")
badp = body_of("POST", f"/api/v1/dicts/{TEST_DICT}/items", at,
{"itemCode": "C2", "itemName": "孤儿", "parentItemCode": "NOT_EXIST"})
check("父项不存在 → 拒绝(不造孤儿项)", badp.get("code") != 0,
f"code={badp.get('code')} msg={badp.get('msg')}")
dl = body_of("DELETE", f"/api/v1/dicts/items/{cid}", at)
check("先删子项成功", dl.get("code") == 0, json.dumps(dl, ensure_ascii=False)[:200])
dl2 = body_of("DELETE", f"/api/v1/dicts/items/{pid}", at)
check("再删父项成功(下级没了就能删)", dl2.get("code") == 0)
check("软删(行还在,deleted=1)",
sql(f"select count(*) from sys_dict_item where id={pid} and deleted=1;").strip() == "1")
check("⭐ 删掉后同项码能重新加(部分唯一索引只约束未删的行)",
body_of("POST", f"/api/v1/dicts/{TEST_DICT}/items", at,
{"itemCode": "P1", "itemName": "父项重建"}).get("code") == 0)
# ── 9. 项码不可改 ──
print("\n[9] 项码不可改(改了会破坏历史数据)")
check("修改接口的 DTO 里根本没有 itemCode 字段(结构性保证)",
"itemCode" not in json.dumps(
body_of("PUT", f"/api/v1/dicts/items/{new_id}", at,
{"itemName": "E2E技师学院(改)"}).get("data") or {}))
body_of("PUT", f"/api/v1/dicts/items/{new_id}", at, {"itemCode": "HACKED"})
check("⭐ 传了 itemCode 也不生效(库里项码没变)",
sql(f"select item_code from sys_dict_item where id={new_id};").strip() == "E2E_TECH",
sql(f"select item_code from sys_dict_item where id={new_id};"))
# ── 10. 权限 ──
print("\n[10] 权限")
cfg = R.read_dev_config()
stok = R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], 993901, 1, ["STUDENT"], 4)
b = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", stok,
{"itemCode": "HACK", "itemName": "学生想加字典"})
check("学生加字典项 → 403", b.get("code") in (40101, 40301, 403),
f"code={b.get('code')} msg={b.get('msg')}")
b2 = body_of("GET", "/api/v1/dicts", stok)
check("学生读字典 → 也拒(字典是配置面,不是业务面)",
b2.get("code") in (40101, 40301, 403),
f"code={b2.get('code')} msg={b2.get('msg')}")
b3 = body_of("GET", "/api/v1/dicts")
check("未登录 → 40101", b3.get("code") == 40101, f"code={b3.get('code')}")
cleanup()
print("\n" + "=" * 74)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
print("=" * 74)
for n, det in FAILED:
print(f" ✗ {n}\n {det}")
return 1 if FAILED else 0
def cleanup() -> None:
R.sql(f"delete from sys_dict_item where tenant_id={TENANT} "
f"and (dict_code='{TEST_DICT}' or item_code like 'E2E%');")
R.sql(f"delete from sys_dict_disabled where tenant_id={TENANT};")
print(" 已清理:测试字典项 / 内置项停用记录")
if __name__ == "__main__":
raise SystemExit(main())