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。
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user