ef903a55c3
"能动态设置填报表单"是本产品的核心承诺之一,而在这之前表单定义只能用 SQL 改。
更要紧的是它有一个**不可逆的方向**:内置字段删不掉(40026),正确用法是
"用条件显示隐藏"—— 而只有界面上给这条路留了位置,用户才会照着做。
新增:
· apis/xt/form-schema.ts —— 表单定义接口 + 字段类型 + 条件选项 + 扩展字段 key 生成
· views/task/TaskFormSchemaDrawer.vue —— 21 项内置字段(必填/条件/排序/提示语就地改)
+ 扩展字段增删改 + 保存为新版本 + 版本冲突处理
· task/index.vue 操作列加「表单设计」,240→300px
浏览器实测:
· 版本标签「未保存过(当前为官方默认表单)」——服务端对没存过的任务返回内置默认定义,
让设计器从一份完整的官方表单开始,而不是从空白开始
· **21 项全在,且「条件显示」反查正确**:补考三科→仅补考时、
在读学校/专业/入学时间→仅在校生、本职业工作年限必填开关是关的。
把服务端任意 visibleWhen 对象映射回人话标签,是本界面最容易做错的一处
· 保存→库里 v1 current=1 fields=21;三次保存得到 v1/v2/v3,
旧的 is_current=0 全部保留(**版本只增不改**实测)
· 「不收集」= {field:"_never_",...}(内置字段删不掉,用一条永远不成立的条件隐藏);
保存后原有条件(is_retake/is_student)都保住了
⭐ 端到端:学生端 字段 21→17(已获证书编号消失、补考三科消失、在校生三项保留)、
材料 4→3(工作证明消失)
⚠️ 顺带修掉一个真 bug:**材料侧的条件没求值**
上面这次验证暴露的:表单字段的条件前端有求值(21→17),材料的没有 ——
「工作证明(仅非在校生)」照样显示给在校生;而后端校验是排除它的,
于是表现成"系统让我传,我不传也不报错",学生只能自己猜。
修法:把 visible() 抽成 whenVisible(),**字段与材料共用同一份**。
教训:同一套规则出现在两处时必须共用同一个函数 ——
抄一份的代价不是多几行,而是两边行为不一致,而两边各自看都没错。
修掉一条过期的 E2E 断言:e2e-m1-form.py 断言「联系电话不锁定」,
与实现刻意的只读相反(BuiltinFields L88-93:collect_submission 没有 phone 列,
所以一期只读;反过来做"前端允许改、后端悄悄丢掉"最糟 ——
学生以为改成功,实际提交的还是老号码)。改断言并写明理由,48/48 恢复。
frontend-mp 类型检查修干净:原先 fill.vue 还有 3 条(输入框事件、日期选择、
FormData 缺 submissionStatus),现在只剩 config/index.ts 的 2 条 ——
那是 uni-app 的 #ifdef H5/#ifndef H5 声明同一变量,vue-tsc 看不懂条件编译,设计如此。
验收:e2e-m1-form 48/48、materials 68/68、task 71/71、orgs 48/48、
m3-submit 31/31、file 29/29、draft 27/27、gate 29/29(合计 351 条断言全绿)
415 lines
22 KiB
Python
415 lines
22 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
M1-03 表单 schema 版本化与冲突检测 验收测试。
|
||
|
||
覆盖《03-接口设计》§7.9 / §3.5:
|
||
[x] 未保存过时返回内置字段目录的默认定义(官方 21 项,version=0)
|
||
[x] 22 项内置字段的锁定/必填/来源/选项与官方模板逐条对齐
|
||
[x] 保存为**新版本**(旧版本保留),is_current 只有一个
|
||
[x] baseVersion 不符 → 40025 **并回传最新定义**(前端据此合并)
|
||
[x] 缺少内置字段 → 40026(内置字段不可删)
|
||
[x] 扩展字段:必须 extra_ 前缀、不可与内置重名、不参与统计
|
||
[x] 客户端传 builtin=false 不能绕过「不可删除」(内建标志以目录为准)
|
||
[x] 状态限制与权限
|
||
|
||
为什么这些值得逐条断言:
|
||
表单定义是学生端看到的东西。版本搞错的表现是"学生按旧版填、老师按新版审";
|
||
内置字段被删的表现是"这个人的姓名取不到值"—— 都是不报错、只让人困惑的失败。
|
||
|
||
用法
|
||
python scripts/e2e-m1-form.py
|
||
|
||
依赖:cryptography;且需要能 `docker exec xt-pg`(清理)
|
||
退出码:0 = 全部通过
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import base64
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import urllib.error
|
||
import urllib.request
|
||
import uuid
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||
|
||
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||
PG_USER = os.environ.get("PG_USER", "fill")
|
||
PG_DB = os.environ.get("PG_DB", "filldb")
|
||
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
|
||
|
||
PASSED: list[str] = []
|
||
FAILED: list[tuple[str, str]] = []
|
||
|
||
CTX: dict[str, str | None] = {"base": None, "token": None}
|
||
CREATED_TASK_IDS: list[int] = []
|
||
CREATED_USER_IDS: list[int] = []
|
||
|
||
|
||
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) -> tuple[bool, str]:
|
||
try:
|
||
r = subprocess.run(
|
||
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
|
||
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
|
||
capture_output=True, text=True, timeout=40)
|
||
return r.returncode == 0, (r.stdout or r.stderr).strip()
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
def cleanup_all() -> None:
|
||
if CREATED_TASK_IDS:
|
||
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
|
||
for stmt in [
|
||
f"DELETE FROM collect_form_schema WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_task WHERE id IN ({ids})",
|
||
]:
|
||
sql(stmt)
|
||
base, token = CTX.get("base"), CTX.get("token")
|
||
if base and token:
|
||
for uid in CREATED_USER_IDS:
|
||
try:
|
||
call("DELETE", f"{base}/api/v1/users/{uid}", token)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def rsa_encrypt(plain: str) -> str:
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric import padding
|
||
with open(PUB_KEY, "rb") as f:
|
||
pub = serialization.load_pem_public_key(f.read())
|
||
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
|
||
|
||
|
||
def call(method: str, url: str, token: str | None = None,
|
||
body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
|
||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||
except urllib.error.HTTPError as e:
|
||
raw = e.read().decode("utf-8")
|
||
try:
|
||
return e.code, json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return e.code, {"raw": raw}
|
||
|
||
|
||
def login(base: str, username: str, password: str, tenant: str | None = None):
|
||
payload = {"username": username, "password": rsa_encrypt(password)}
|
||
if tenant:
|
||
payload["tenantCode"] = tenant
|
||
return call("POST", f"{base}/api/v1/auth/login", body=payload)
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="M1-03 表单 schema E2E")
|
||
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
|
||
ap.add_argument("--password", default="Admin@123")
|
||
args = ap.parse_args()
|
||
base = args.base.rstrip("/")
|
||
|
||
print("=" * 72)
|
||
print(f"M1-03 表单 schema 版本化 E2E base={base} 租户={args.tenant}")
|
||
print("=" * 72)
|
||
|
||
suffix = uuid.uuid4().hex[:6]
|
||
|
||
# ── 0. 准备 ────────────────────────────────────────
|
||
print("\n[0] 准备")
|
||
status, body = login(base, "admin", args.password, args.tenant)
|
||
if body.get("code") != 0:
|
||
fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}")
|
||
return 1
|
||
token = body["data"]["token"]
|
||
tenant_id = (body["data"].get("tenant") or {}).get("id")
|
||
CTX["base"], CTX["token"] = base, token
|
||
ok("管理员登录")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E表单 {suffix}", "collectMode": 1, "batchNo": "2003",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-12-31",
|
||
})
|
||
if body.get("code") != 0:
|
||
fail("建任务", f"code={body.get('code')} msg={body.get('msg')}")
|
||
return 1
|
||
task_id = body["data"]["id"]
|
||
CREATED_TASK_IDS.append(task_id)
|
||
ok(f"建任务(id={task_id})")
|
||
|
||
# ── 1. 默认定义 = 官方 21 项 ─────────────────────────
|
||
print("\n[1] 未保存过时的默认定义")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
|
||
d = body.get("data") or {}
|
||
fields = d.get("fields") or []
|
||
by_key = {f["key"]: f for f in fields}
|
||
check("返回内置默认定义(version=0,未落库)",
|
||
d.get("version") == 0 and d.get("latestVersion") == 0,
|
||
f"version={d.get('version')} latest={d.get('latestVersion')}")
|
||
check("恰好 21 个内置字段(官方模板 A~U 列)", len(fields) == 21, f"实际 {len(fields)}")
|
||
check("顺序即 A~U 列顺序",
|
||
[f["key"] for f in fields] == [
|
||
"seq_no", "real_name", "id_card_no", "education", "phone", "work_unit",
|
||
"occupation", "worktype", "job_level", "is_retake", "retake_theory",
|
||
"retake_practice", "retake_overall", "is_student", "school", "major",
|
||
"enroll_date", "work_years", "cert_occupation", "cert_level", "cert_no"],
|
||
f"{[f['key'] for f in fields]}")
|
||
check("全部标记为 builtin(内置不可删)", all(f.get("builtin") is True for f in fields),
|
||
f"{[(f['key'], f.get('builtin')) for f in fields if not f.get('builtin')]}")
|
||
|
||
# 锁定项:PRD §「锁定」= 姓名、身份证号、报考职业、报考工种、报考等级
|
||
locked_expected = {"real_name", "id_card_no", "occupation", "worktype", "job_level"}
|
||
locked_actual = {f["key"] for f in fields if f.get("locked")}
|
||
check("锁定项与 PRD 一致(姓名/身份证号/报考职业/工种/等级)",
|
||
locked_expected.issubset(locked_actual),
|
||
f"应为 {sorted(locked_expected)},实际 {sorted(locked_actual)}")
|
||
# ⚠️ 这一条按**实现的刻意决定**断言,而不是按 PRD 原文的"可核对修改"。
|
||
# 原因:collect_submission **没有 phone 列**(见 BuiltinFields L88-93),
|
||
# 所以「联系电话」一期设为只读,与"如号码有误请联系班主任"一致。
|
||
# 反过来做(前端允许改、后端悄悄丢掉)最糟:学生会以为改成功了,
|
||
# 而实际提交的还是老号码。
|
||
# 要真正开放修改,得先给 collect_submission 加一列(或让它落到 form_data)。
|
||
check("联系电话来自名册,且一期**刻意锁定**(没有 phone 列可存,宁可只读也不静默丢弃)",
|
||
by_key["phone"].get("source") == "ROSTER" and by_key["phone"].get("locked") is True,
|
||
f"source={by_key['phone'].get('source')} locked={by_key['phone'].get('locked')}")
|
||
check("姓名/身份证号的来源是名册(不在 collect_submission 里)",
|
||
by_key["real_name"].get("source") == "ROSTER"
|
||
and by_key["id_card_no"].get("source") == "ROSTER",
|
||
f"{by_key['real_name'].get('source')} / {by_key['id_card_no'].get('source')}")
|
||
check("学历的来源是 submission 且带 11 个官方选项",
|
||
by_key["education"].get("source") == "SUBMISSION"
|
||
and len(by_key["education"].get("options") or []) == 11,
|
||
f"source={by_key['education'].get('source')} options={len(by_key['education'].get('options') or [])}")
|
||
check("报考等级的 5 个官方选项照抄",
|
||
(by_key["job_level"].get("options") or [])[:2] == ["五级/初级工", "四级/中级工"],
|
||
f"{by_key['job_level'].get('options')}")
|
||
check("补考三科只在「是否补考=是」时显示(官方规则 1)",
|
||
all((by_key[k].get("visibleWhen") or {}).get("field") == "is_retake"
|
||
and (by_key[k].get("visibleWhen") or {}).get("value") is True
|
||
for k in ("retake_theory", "retake_practice", "retake_overall")),
|
||
f"{[(k, by_key[k].get('visibleWhen')) for k in ('retake_theory', 'retake_practice')]}")
|
||
check("在校生三项只在「是否在校生=是」时显示(官方规则 3/4)",
|
||
all((by_key[k].get("visibleWhen") or {}).get("field") == "is_student"
|
||
for k in ("school", "major", "enroll_date")),
|
||
f"{[(k, by_key[k].get('visibleWhen')) for k in ('school', 'major', 'enroll_date')]}")
|
||
check("入学时间带 YYYY-MM-DD 正则(官方原文「格式必须 YYYY-MM-DD」)",
|
||
(by_key["enroll_date"].get("validate") or {}).get("regex") == "^\\d{4}-\\d{2}-\\d{2}$",
|
||
f"{by_key['enroll_date'].get('validate')}")
|
||
check("工作单位的提示语写着「没有则填无」", "无" in (by_key["work_unit"].get("tip") or ""),
|
||
f"{by_key['work_unit'].get('tip')}")
|
||
|
||
# ── 2. 保存为版本 1 ─────────────────────────────────
|
||
print("\n[2] 保存为新版本")
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 0,
|
||
"fields": fields,
|
||
"extras": [{"key": "extra_skill_cert", "label": "其它技能证书编号",
|
||
"type": "text", "required": False, "sortNo": 100}],
|
||
})
|
||
check("从 version=0 保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||
saved = body.get("data") or {}
|
||
check("新版本是 1", saved.get("version") == 1, f"{saved.get('version')}")
|
||
check("isCurrent=true", saved.get("isCurrent") is True, f"{saved.get('isCurrent')}")
|
||
check("扩展字段存下来了", len(saved.get("extras") or []) == 1,
|
||
f"{saved.get('extras')}")
|
||
check("latestVersion 同步为 1", saved.get("latestVersion") == 1, f"{saved.get('latestVersion')}")
|
||
|
||
okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id}")
|
||
check("库里落了一行版本", okk and out.strip() == "1", f"{out}")
|
||
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
|
||
check("读回的是 version=1", (body.get("data") or {}).get("version") == 1,
|
||
f"{(body.get('data') or {}).get('version')}")
|
||
|
||
# ── 3. 改一处 → 版本 2 ──────────────────────────────
|
||
print("\n[3] 再次保存 → 版本递增、旧版保留")
|
||
fields2 = json.loads(json.dumps(fields))
|
||
for f in fields2:
|
||
if f["key"] == "work_unit":
|
||
f["required"] = False
|
||
f["tip"] = "改成选填了"
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 1, "fields": fields2, "extras": [],
|
||
})
|
||
check("从 version=1 保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||
check("新版本是 2", (body.get("data") or {}).get("version") == 2, f"{(body.get('data') or {}).get('version')}")
|
||
okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id}")
|
||
check("库里两行版本(旧版保留,不覆盖)", okk and out.strip() == "2", f"{out}")
|
||
okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id} AND is_current=1")
|
||
check("当前版本只有一个", okk and out.strip() == "1", f"{out}")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
|
||
d2 = {f["key"]: f for f in (body.get("data") or {}).get("fields") or []}
|
||
check("改动已生效(工作单位变选填)", d2["work_unit"].get("required") is False,
|
||
f"{d2['work_unit'].get('required')}")
|
||
check("扩展字段可以整体清空", (body.get("data") or {}).get("extras") == [],
|
||
f"{(body.get('data') or {}).get('extras')}")
|
||
|
||
# ── 4. 版本冲突 ─────────────────────────────────────
|
||
print("\n[4] 版本冲突(40025)")
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 1, "fields": fields2, "extras": [],
|
||
})
|
||
check("用过期 baseVersion 保存被拒 40025", body.get("code") == 40025,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
latest = body.get("data") or {}
|
||
check("40025 的响应里**带上了最新定义**(前端据此做差异合并)",
|
||
latest.get("version") == 2 and len(latest.get("fields") or []) == 21,
|
||
f"version={latest.get('version')} fields={len(latest.get('fields') or [])}")
|
||
check("冲突提示里说清了基准版本与最新版本",
|
||
"基准版本 1" in (body.get("msg") or "") and "最新 2" in (body.get("msg") or ""),
|
||
f"msg={body.get('msg')}")
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 99, "fields": fields2, "extras": [],
|
||
})
|
||
check("baseVersion 比最新大也被拒(不是只防「小于」)", body.get("code") == 40025,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
# ── 5. 内置字段不可删 ───────────────────────────────
|
||
print("\n[5] 内置字段不可删(40026)")
|
||
missing_one = [f for f in fields2 if f["key"] != "id_card_no"]
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 2, "fields": missing_one, "extras": [],
|
||
})
|
||
check("删掉「身份证号」被拒 40026", body.get("code") == 40026,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
check("拒绝理由点出了缺哪个字段,并给出替代做法(隐藏而不是删)",
|
||
"id_card_no" in (body.get("msg") or "") and "隐藏" in (body.get("msg") or ""),
|
||
f"msg={body.get('msg')}")
|
||
|
||
spoofed = json.loads(json.dumps(fields2))
|
||
spoofed = [f for f in spoofed if f["key"] != "id_card_no"]
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 2, "fields": spoofed, "extras": [],
|
||
})
|
||
check("把 builtin 传成 false 也无法绕过不可删除", body.get("code") == 40026,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
# ── 6. 扩展字段 ─────────────────────────────────────
|
||
print("\n[6] 扩展字段")
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 2, "fields": fields2,
|
||
"extras": [{"key": "education", "label": "想冒充内置列", "type": "text"}],
|
||
})
|
||
check("扩展字段不能与内置字段重名", body.get("code") == 40001,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 2, "fields": fields2,
|
||
"extras": [{"key": "skillCert", "label": "没有 extra_ 前缀", "type": "text"}],
|
||
})
|
||
check("扩展字段 key 必须带 extra_ 前缀", body.get("code") == 40001,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
check("以上失败都没有产生新版本",
|
||
(call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)[1].get("data") or {}).get("version") == 2,
|
||
"版本被推进了")
|
||
|
||
# ── 7. 状态限制与权限 ───────────────────────────────
|
||
print("\n[7] 状态限制与权限")
|
||
# ⚠️ 要测「已截止不可改表单」,必须先把任务推到「收集中」:
|
||
# 草稿是**不能直接截止的**(close 只允许 收集中 / 已暂停)——
|
||
# 这正是发布前置校验存在的意义:没配齐就发布,学生填了也没人审。
|
||
# 所以这里用 SQL 夹具把发布前置的三样备齐。
|
||
sql(f"INSERT INTO collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
|
||
f"VALUES ({tenant_id}, {task_id}, 'E2E学生A', 'seed', 'seed')")
|
||
sql(f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) "
|
||
f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)")
|
||
okk, out = sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
|
||
f"VALUES ({tenant_id}, {task_id}, "
|
||
f"(SELECT id FROM sys_org WHERE tenant_id={tenant_id} AND org_type=3 LIMIT 1), 1)")
|
||
check("夹具:名册 / 材料 / 覆盖班级就位", okk, out)
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
|
||
check("发布任务(进入收集中)", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 2, "fields": fields2, "extras": [],
|
||
})
|
||
check("收集中允许改表单(§3.5 说「已发布任务改表单必须递增」,说明是允许改的)",
|
||
body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
|
||
check("把任务截止", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
|
||
"baseVersion": 3, "fields": fields2, "extras": [],
|
||
})
|
||
check("已截止不可再改表单", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
|
||
check("已截止仍可读表单(只读不等于看不见)", body.get("code") == 0, f"code={body.get('code')}")
|
||
|
||
ht_user = f"htf{suffix}"
|
||
status, body = call("POST", f"{base}/api/v1/users", token, {
|
||
"username": ht_user, "realName": f"E2E表单班主任{suffix}",
|
||
"phone": "13900139401", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
|
||
})
|
||
if check("造班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
|
||
uid, pwd = body["data"]["id"], body["data"]["initialPassword"]
|
||
CREATED_USER_IDS.append(uid)
|
||
status, body = login(base, ht_user, pwd, args.tenant)
|
||
ht_token = body["data"]["token"]
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", ht_token)
|
||
check("班主任能读表单定义", body.get("code") == 0, f"code={body.get('code')}")
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", ht_token, {
|
||
"baseVersion": 2, "fields": fields2, "extras": [],
|
||
})
|
||
check("班主任不能改表单(无 task:manage)", body.get("code") == 40301,
|
||
f"code={body.get('code')}")
|
||
|
||
# ── 8. 清理 ────────────────────────────────────────
|
||
print("\n[8] 清理测试数据")
|
||
cleanup_all()
|
||
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号")
|
||
|
||
print("\n" + "=" * 72)
|
||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||
if FAILED:
|
||
print("\n失败项:")
|
||
for name, detail in FAILED:
|
||
print(f" · {name}\n {detail}")
|
||
print("=" * 72)
|
||
return 1 if FAILED else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
code = main()
|
||
except BaseException:
|
||
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
|
||
cleanup_all()
|
||
raise
|
||
raise SystemExit(code)
|