Files
xiaotutongxue/scripts/e2e-m1-materials.py
T
lovife d32969d766 feat(material): M1-04 材料项定义与内置模板
发布前置校验里最后缺的一项(40018「未配置必传材料项」)终于能配了。
按计划表这一项是后端(4 人日),材料规则的界面属 M1-09。

交付:
· 材料项 CRUD(《03-接口设计》§7.10):GET/POST /tasks/{id}/materials、
  PUT/DELETE /materials/{id}
· 三个内置模板(TPL_SCHOOL_ROLL / TPL_SOCIAL / TPL_ZHUANXIANG),幂等应用
· GET /material-catalog:材料码/档位/格式白名单/模板的静态清单,
  供界面下拉使用(接口是契约外的,理由见 Controller 注释:把这份常量在前端
  再抄一份会造成两份真相,而别名正是识别历史文件名用的)

落在代码里的三件事:
1) 默认值按两个维度分别取(MaterialCatalog):
   · 按材料码定 名字/别名/默认压缩档/份数/是否合并
   · 按压缩档定 固定尺寸(P1)/长边下限(P2)/目标DPI(P3)/输出格式
   把份数塞进档位默认里是很容易犯的错 —— 那样所有 P2 材料都会变成"要传 2 份",
   而"身份证要正反面"是这件材料的事实,不是档位的规则。
2) WORK_PROOF 的名字就是「工作证明」。机构模板里「职业技能等级个人申报表」
   的规范文件名就是「工作证明」,这是机构既有习惯,不要"修正" —— 改了会让
   导出包与该机构一直以来的归档口径对不上,带教老师只认这个名字。
3) 关联键是 (task_id, material_code),不是 material_def_id(表里没有那个外键)。
   所以 40027「已有文件不可删」必须按码查 —— 用 id 查会永远返回 0,
   这道校验形同虚设,后果是"删了材料定义,文件成了孤儿"。

本轮修掉的 2 个真问题:
1) NOT NULL DEFAULT 与"档位不适用就置空"打架(同一类 bug 的第二次):
   min_long_side(NOT NULL DEFAULT 1400)与 output_format(NOT NULL DEFAULT 'jpg')
   在切档位时被写成 null,PostgreSQL 直接 500。而更根本的是:契约 §3.4 明确写着
   outputFormat 的 null = 保持原格式 —— 这一列本就不该是 NOT NULL。
   已由 V5__material_profile_params_nullable.sql 把四个档位参数统一成
   "不适用时为空"(另两个本来就可空),并同步更正两份 DDL 文档。
   ⚠️ 这与上一轮的 expected_count 是同一类问题:NOT NULL DEFAULT x 看起来无害,
   但它让"未设置/不适用"无法表达,而契约往往又要求能表达。
   动"可选数字/字符串"字段前,先查 information_schema.columns 的 is_nullable。
2) 我的两条 E2E 断言本身是坏的:
   · 把 lower_case 断言成"应被拒" —— 实际服务端把它规范化成 LOWER_CASE 后
     接受了,这是对输入友好的正确行为,错的是断言;
   · "改档位后参数跟着变"用了 P3,而 OTHER_PROOF 的默认档本来就是 P3,
     于是无论改没改都会通过(假通过)。
   教训:断言要能真的失败。

顺带整理:
· 抽出 support/NamingTemplate:任务级 naming_template 与材料级 name_pattern
  必须共用同一套校验规则。两处各写一套会出现"任务级认的变量材料级不认",
  而这类不一致的表现是"发布时通过、导出时文件名叫错",最难在测试里撞上。
  TaskService 已改为委托。
· materialTemplateId(契约里是 int64)不再空转:§7.10 的三个模板在文档与代码里
  都是具名常量,库里也没有"材料模板表",拿 id 指代常量就得先造一张表放它们。
  新增 materialTemplateCode(字符串)承接内置模板,materialTemplateId 留给
  M1-05 的 collect_rule_template(那是真正的一行一条,id 才有意义)。

已知缺口(写进了代码注释与交接摘要):
· minSizeKb 恒为 null:契约 §3.4 给了字段但"对应列"是 —,而《04》又用到
  materialDef.minSizeKb,两侧口径没对齐;且具体阈值属待办 D5「一寸照片阈值标定」,
  没有实测数据。编一个数会让它看起来"已实现",卡住真材料时没人说得清为什么。
· 条件显示只做存取与形状校验,求值在填报引擎 M3-10。
· 40030(审核中不可删材料)属 M5,当前删除只查 40027。

验收:e2e-m1-materials.py 68/68;五条 E2E 合计 278 项断言全通过;
Flyway 迁移 1~5 全部 success。
2026-09-29 09:42:08 +08:00

494 lines
26 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
M1-04 材料项定义 验收测试。
覆盖《03-接口设计》§7.10:
[x] 按内置模板初始化(TPL_SCHOOL_ROLL / TPL_SOCIAL / TPL_ZHUANXIANG),幂等
[x] 材料项 CRUD;材料码任务内唯一;材料码不可修改
[x] 默认值按「材料码」与「压缩档位」两个维度分别取(见 MaterialCatalog)
[x] 条件显示(FR-402):在校生→学籍表,非在校生→工作证明 / 学信网备案表
[x] 已有文件的材料项不可删(40027)—— 关联键是 (task_id, material_code)
[x] 校验:档位 / 格式白名单 / 体积范围 / 材料码格式 / 命名模板
[x] 状态限制与权限
为什么这些值得逐条断言:
材料清单直接决定学生要交什么、交多大、压到什么程度。默认值算错的表现是
"身份证被要求只传 1 份"(正反面传不上)或"证件照没被压到 295x413"
(机构退件),而这两种都不会报错,只会让材料收不成。
用法
python scripts/e2e-m1-materials.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:
"""幂等清理;崩溃时也会被 __main__ 的 finally 调一次。"""
if CREATED_TASK_IDS:
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
for stmt in [
f"DELETE FROM collect_file 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
# ── HTTP ────────────────────────────────────────────
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-04 材料项定义 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-04 材料项定义 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(f"管理员登录(租户 id={tenant_id})")
# 建任务时直接带内置模板
batch = "2002"
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E材料清单 {suffix}", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
"materialTemplateCode": "TPL_SCHOOL_ROLL",
})
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"建任务并带 TPL_SCHOOL_ROLL 模板(id={task_id})")
# ── 1. 模板初始化 ───────────────────────────────────
print("\n[1] 内置模板初始化")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
rows = body.get("data") or []
check("模板铺出 4 个材料项", len(rows) == 4, f"实际 {len(rows)}:{[r.get('materialCode') for r in rows]}")
if len(rows) != 4:
return 1
by_code = {r["materialCode"]: r for r in rows}
check("四个材料码正确(照片/身份证/学籍表/工作证明)",
set(by_code) == {"PHOTO1", "IDCARD", "SCHOOL_ROLL", "WORK_PROOF"}, f"{sorted(by_code)}")
p1 = by_code.get("PHOTO1", {})
check("一寸照片:名称来自 §2.1 材料目录", p1.get("materialName") == "一寸照片",
f"{p1.get('materialName')}")
check("一寸照片:别名自动填了(识别历史文件名要用)",
"证件照" in (p1.get("aliases") or []) and "1寸照片" in (p1.get("aliases") or []),
f"{p1.get('aliases')}")
check("一寸照片:默认档位 P1", p1.get("compressProfile") == "P1", f"{p1.get('compressProfile')}")
check("一寸照片:P1 档补上了固定尺寸 295x413",
(p1.get("fixedSize") or {}).get("width") == 295
and (p1.get("fixedSize") or {}).get("height") == 413,
f"{p1.get('fixedSize')}")
check("一寸照片:输出格式 jpg", p1.get("outputFormat") == "jpg", f"{p1.get('outputFormat')}")
idc = by_code.get("IDCARD", {})
check("身份证:默认档位 P2", idc.get("compressProfile") == "P2", f"{idc.get('compressProfile')}")
check("身份证:份数 2 且允许合并(正反面)",
idc.get("maxCount") == 2 and idc.get("mergeEnabled") is True,
f"maxCount={idc.get('maxCount')} merge={idc.get('mergeEnabled')}")
check("身份证:P2 档补上了长边下限 1400", idc.get("minLongSide") == 1400,
f"{idc.get('minLongSide')}")
check("身份证:P2 档下不该有 P1 的固定尺寸",
idc.get("fixedSize") is None, f"{idc.get('fixedSize')}")
wp = by_code.get("WORK_PROOF", {})
check("工作证明:名称是「工作证明」而不是「个人申报表」(机构既有习惯,不要修正)",
wp.get("materialName") == "工作证明", f"{wp.get('materialName')}")
check("工作证明:默认档位 P3", wp.get("compressProfile") == "P3", f"{wp.get('compressProfile')}")
check("工作证明:P3 档补上了目标 DPI 300", wp.get("targetDpi") == 300, f"{wp.get('targetDpi')}")
sr = by_code.get("SCHOOL_ROLL", {})
check("学籍表:默认档位 P4,输出保 PNG(截图转 JPG 会糊掉表格线)",
sr.get("compressProfile") == "P4" and sr.get("outputFormat") == "png",
f"{sr.get('compressProfile')} / {sr.get('outputFormat')}")
check("FR-402 级联:学籍表仅在校生显示(is_student=true)",
(sr.get("visibleWhen") or {}).get("field") == "is_student"
and (sr.get("visibleWhen") or {}).get("value") is True,
f"{sr.get('visibleWhen')}")
check("FR-402 级联:工作证明仅非在校生显示(is_student=false)",
(wp.get("visibleWhen") or {}).get("field") == "is_student"
and (wp.get("visibleWhen") or {}).get("value") is False,
f"{wp.get('visibleWhen')}")
check("模板里的材料都是必传(互斥由条件显示解决,在校生不会被要求交工作证明)",
all(r.get("required") is True for r in rows), f"{[(r['materialCode'], r.get('required')) for r in rows]}")
# 幂等
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials/apply-template", token,
{"templateCode": "TPL_SCHOOL_ROLL"})
check("重复应用同一模板是幂等的(不报错、不重复插)",
body.get("code") == 0 and (body.get("data") or {}).get("createdCount") == 0,
f"code={body.get('code')} data={body.get('data')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("重复应用后仍是 4 项", len(body.get("data") or []) == 4, f"{len(body.get('data') or [])}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials/apply-template", token,
{"templateCode": "TPL_NOPE"})
check("不存在的模板码被拒且提示可用模板", body.get("code") == 40001 and "TPL_" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
# ── 2. 材料目录 ─────────────────────────────────────
print("\n[2] 材料目录接口")
status, body = call("GET", f"{base}/api/v1/material-catalog", token)
cat = body.get("data") or {}
check("目录返回 6 种材料码", len(cat.get("codes") or []) == 6,
f"{[c.get('code') for c in (cat.get('codes') or [])]}")
check("目录返回 6 个压缩档位", len(cat.get("profiles") or []) == 6, f"{cat.get('profiles')}")
check("目录返回 5 种允许格式", len(cat.get("fileTypes") or []) == 5, f"{cat.get('fileTypes')}")
check("目录返回 3 个内置模板", len(cat.get("templates") or []) == 3, f"{cat.get('templates')}")
check("目录返回体积可配范围 307~5120",
cat.get("maxSizeRange") == [307, 5120], f"{cat.get('maxSizeRange')}")
# ── 3. 新增与默认值 ─────────────────────────────────
print("\n[3] 新增材料项")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "OTHER_PROOF", "materialName": "其它佐证材料",
"tipText": "如有获奖证书、技能证书请一并上传",
})
check("新增选传材料成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
other_id = body.get("data")
check("返回新增的 id", isinstance(other_id, int), f"{other_id}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "OTHER_PROOF", "materialName": "重复的",
})
check("同一个材料码不能配两次(任务内唯一)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
# 小写码会被规范化为大写 —— 这是对输入友好,也保证"码"只有一个口径
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "lower_case", "materialName": "小写码材料",
})
check("材料码小写会被规范化为大写(不是拒绝)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
lower_id = body.get("data")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
codes = [r["materialCode"] for r in (body.get("data") or [])]
check("规范化后的码是大写 LOWER_CASE", "LOWER_CASE" in codes, f"{codes}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "1INVALID", "materialName": "数字开头",
})
check("材料码以数字开头被拒", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
# 未知码允许(学校可能有《护士执业证》这类目录外的材料),但名称必须自己填
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "CUSTOM_CERT", "materialName": "护士执业证",
})
check("目录外的材料码允许使用(学校会有目录外的材料)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
custom_id = body.get("data")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
refetched = {r["materialCode"]: r for r in (body.get("data") or [])}
check("目录外的码默认档位 AUTO、份数 1、不合并、无固定尺寸",
refetched.get("CUSTOM_CERT", {}).get("compressProfile") == "AUTO"
and refetched.get("CUSTOM_CERT", {}).get("maxCount") == 1
and refetched.get("CUSTOM_CERT", {}).get("mergeEnabled") is False
and refetched.get("CUSTOM_CERT", {}).get("fixedSize") is None,
f"{refetched.get('CUSTOM_CERT')}")
# ── 4. 校验 ─────────────────────────────────────────
print("\n[4] 校验:不该放行的都要拦住")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_PROFILE", "materialName": "档位非法", "compressProfile": "P9",
})
check("非法压缩档位被拒", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_TYPE", "materialName": "格式非法", "fileTypes": ["jpg", "exe"],
})
check("格式白名单外的类型被拒且列出可选值",
body.get("code") == 40001 and "exe" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "TOO_BIG", "materialName": "体积超范围", "maxSizeKb": 9000,
})
check("体积上限超出 5M 被拒", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "TOO_SMALL", "materialName": "体积过小", "maxSizeKb": 100,
})
check("体积上限低于 0.3M 被拒(压得太狠等于把材料压废)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_NAMING", "materialName": "命名错", "namePattern": "{姓名}-{材料名}",
})
check("材料级命名模板缺 {证件号} 被拒 40020", body.get("code") == 40020,
f"code={body.get('code')} msg={body.get('msg')}")
check("命名模板的错误信息里点明了是哪个材料的问题",
"命名错" in (body.get("msg") or ""), f"msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_VISIBLE", "materialName": "条件缺字段",
"visibleWhen": {"op": "eq", "value": True},
})
check("条件显示规则缺 field 被拒", body.get("code") == 40001 and "field" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("以上失败都没有污染数据(7 项:4 模板 + OTHER_PROOF + CUSTOM_CERT + LOWER_CASE)",
len(body.get("data") or []) == 7, f"实际 {len(body.get('data') or [])}")
# ── 5. 修改 ─────────────────────────────────────────
print("\n[5] 修改材料项")
status, body = call("PUT", f"{base}/api/v1/materials/{other_id}", token, {
"materialCode": "OTHER_PROOF", "materialName": "其它佐证材料(改名)",
"required": False, "maxSizeKb": 2048,
"compressProfile": "P1",
})
check("修改成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
m = {r["materialCode"]: r for r in (body.get("data") or [])}.get("OTHER_PROOF", {})
check("名称已改", m.get("materialName") == "其它佐证材料(改名)", f"{m.get('materialName')}")
check("改为选传已生效", m.get("required") is False, f"{m.get('required')}")
check("体积上限已改 2048", m.get("maxSizeKb") == 2048, f"{m.get('maxSizeKb')}")
# 注意:这里必须换到与原档位(P3)**不同**的档位,否则断言会因"原值恰好相同"而假通过。
# 之前我用 P3 就踩了这个坑:OTHER_PROOF 的默认档本来就是 P3,测不出任何东西。
check("从 P3 改到 P1 后:P1 的固定尺寸出现、P3 的目标 DPI 消失",
m.get("compressProfile") == "P1"
and (m.get("fixedSize") or {}).get("width") == 295
and (m.get("fixedSize") or {}).get("height") == 413
and m.get("targetDpi") is None,
f"profile={m.get('compressProfile')} fixed={m.get('fixedSize')} dpi={m.get('targetDpi')}")
check("切档位后不适用的长边下限被清空(这正是 500 的那条路径)",
m.get("minLongSide") is None, f"{m.get('minLongSide')}")
status, body = call("PUT", f"{base}/api/v1/materials/{custom_id}", token, {
"materialCode": "RENAMED_CODE", "materialName": "护士执业证",
})
check("材料码不可修改(它是文件的关联键)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
# 清空命名模板:这一条正是"updateById 会跳过 null"会静默失效的地方
status, body = call("PUT", f"{base}/api/v1/materials/{custom_id}", token, {
"materialCode": "CUSTOM_CERT", "materialName": "护士执业证",
"namePattern": "{姓名}-{证件号}-{材料名}",
})
check("设置材料级命名模板成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/materials/{custom_id}", token, {
"materialCode": "CUSTOM_CERT", "materialName": "护士执业证", "namePattern": None,
})
check("清空材料级命名模板成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
m = {r["materialCode"]: r for r in (body.get("data") or [])}.get("CUSTOM_CERT", {})
check("清空后读回是 null(清空真的落库,不是被静默忽略)",
m.get("namePattern") is None, f"{m.get('namePattern')}")
# ── 6. 删除 ─────────────────────────────────────────
print("\n[6] 删除与 40027")
status, body = call("DELETE", f"{base}/api/v1/materials/{custom_id}", token)
check("没有文件的材料项可以删除", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("删除后列表少了一项", len(body.get("data") or []) == 6, f"{len(body.get('data') or [])}")
# 造一个"已上传的文件"——关联键是 (task_id, material_code),没有 material_def_id
okk, out = sql(
f"INSERT INTO collect_file (tenant_id, task_id, submission_id, material_code, "
f"original_name, file_ext, raw_size, final_size, file_hash) "
f"VALUES ({tenant_id}, {task_id}, 0, 'OTHER_PROOF', '身份证.jpg', 'jpg', 100, 90, 'seedhash')")
check("夹具:写入 1 个文件(关联 material_code=OTHER_PROOF)", okk, out)
status, body = call("DELETE", f"{base}/api/v1/materials/{other_id}", token)
check("已有文件的材料项不可删除 40027", body.get("code") == 40027,
f"code={body.get('code')} msg={body.get('msg')}")
check("拒绝理由给出了替代做法(设选传或加条件隐藏)",
"选传" in (body.get("msg") or "") or "隐藏" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
# ── 7. 状态限制与权限 ───────────────────────────────
print("\n[7] 状态限制与权限")
okk, out = 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')")
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("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "AFTER_PUBLISH", "materialName": "发布后补的材料",
})
check("收集中仍可补材料(「漏交了,加一项」是真实需求)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "AFTER_CLOSE", "materialName": "截止后加的",
})
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}/materials", token)
check("已截止仍可读材料清单(只读不等于看不见)", body.get("code") == 0,
f"code={body.get('code')}")
# 班主任只读
ht_user = f"htm{suffix}"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": ht_user, "realName": f"E2E材料班主任{suffix}",
"phone": "13900139301", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if check("造班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
ht_id = body["data"]["id"]
ht_pwd = body["data"]["initialPassword"]
CREATED_USER_IDS.append(ht_id)
status, body = login(base, ht_user, ht_pwd, args.tenant)
ht_token = body["data"]["token"]
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", ht_token)
check("班主任能读材料清单(他要照着检查学生交得对不对)", body.get("code") == 0,
f"code={body.get('code')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", ht_token,
{"materialCode": "BY_TEACHER", "materialName": "班主任加的"})
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)