8f80744d63
一、规则模板的 FORM 部分(F-57/F-64)—— 此前只能「跳过并说明」,等于装饰
· FormSchemaService.saveRawVersion:用已存在的 schemaJson 存为新版本。
与手工保存的区别:内容不再校验/归一化(它当初就是按本服务规则存出来的,
再走一遍解析→序列化只会丢未建模字段);状态检查与版本管理保留 —— 版本化
逻辑只有一份
· RuleTemplateService 的 FORM 分支接上它(不再 skipped)
· 顺带补上另一半:saveAsTemplate 此前不存表单(存模板不存、套模板用不上,
两头各缺一半)—— 现在把源任务的当前表单一并带走;没存过 = null =
套用时按「用系统默认,不覆盖」跳过
二、已归档任务可删(F-14 / §17.2 D-9)—— 此前「已发布即删不掉」,
演示/测试任务只能改名挪开,库只增不减
· DELETE /tasks/{id} 支持 status=3:收集中/已截止仍拒(归档是最后的后悔药),
拒绝的话说清「先归档」
· 子表级联物理删 14 张表(TaskCascadeMapper 集中放置,表清单来自
information_schema 的事实核对)—— 大头是名册(证件号密文)、材料、审核记录
· ★ 任务行本身**软删**:它必须占住 task_code —— maxSeqByCodePrefix 靠
「含软删行的最大序号」保证编码永不复用(编码同时是文件目录名,重号会把
两批任务的文件混进同一目录)。第一版做成了物理删,e2e-m1-task 的
「编码递增」断言当场抓到重号 —— 这条回归现在被 e2e-m1-task-delete
的「删除后新建不重号」断言守住
· 物理文件尽力删(先取路径再删行,顺序反了文件就永远留在磁盘上;
删不掉只记日志不阻断);全程留痕(sys_operation_log 记 DELETE_TASK +
行数/文件数)
验证:e2e-m7-taskconfig → 75/75(新增 FORM 完整链路 7 条断言)
e2e-m1-task-delete(新增)→ 20/20(拒删/删净/占号软删/留痕/再删报错/草稿直删)
e2e-m1-task → 71/71(旧口径断言更新为新口径)
573 lines
29 KiB
Python
573 lines
29 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
M1-01 收集任务(CRUD + 状态机)验收测试。
|
||
|
||
覆盖《03-接口设计》§7.1~§7.5:
|
||
[x] 建任务:默认值、编码生成、一期范围限制(40044)
|
||
[x] 改任务:草稿可改模式,已发布不可改
|
||
[x] 状态机:草稿→发布→暂停⇄恢复→截止→归档,幂等与错误码
|
||
[x] 发布前置校验:5 项全部阻断,force 无法绕过
|
||
[x] 可见范围:班主任只看自己带的班级的任务
|
||
[x] 删除:仅草稿
|
||
|
||
⚠️ 关于「发布」的可达性
|
||
发布要求名册非空 + ≥1 项必传材料 + 已设置覆盖班级,而这三样分别属于 M2 / M1-04 / M1-02。
|
||
所以本脚本用**直接写库**的方式造前置数据(这是测试夹具,不是产品捷径):
|
||
只有这样才能真正走到 已发布/暂停/恢复/截止/归档 那几个状态并验证它们。
|
||
|
||
用法
|
||
python scripts/e2e-m1-task.py
|
||
python scripts/e2e-m1-task.py --base http://localhost:8080
|
||
|
||
依赖: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.parse
|
||
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]] = []
|
||
|
||
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 show(body: dict, indent: int = 9) -> None:
|
||
pad = " " * indent
|
||
text = json.dumps(body, ensure_ascii=False)
|
||
print(f"{pad}完整响应:{text[:400]}{'…' if len(text) > 400 else ''}")
|
||
|
||
|
||
# ── SQL(测试夹具)────────────────────────────────────
|
||
|
||
def sql(stmt: str) -> tuple[bool, str]:
|
||
"""直接执行 SQL —— 只用于造/清测试夹具。"""
|
||
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)
|
||
|
||
|
||
# ── 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-01 收集任务 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("--admin", default="admin")
|
||
ap.add_argument("--password", default="Admin@123")
|
||
args = ap.parse_args()
|
||
base = args.base.rstrip("/")
|
||
|
||
print("=" * 72)
|
||
print(f"M1-01 收集任务 E2E base={base} 租户={args.tenant}")
|
||
print("=" * 72)
|
||
|
||
suffix = uuid.uuid4().hex[:6]
|
||
|
||
# ── 0. 准备 ────────────────────────────────────────
|
||
print("\n[0] 准备")
|
||
status, body = login(base, args.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")
|
||
ok(f"管理员登录(租户 id={tenant_id})")
|
||
|
||
st, bd = call("GET", f"{base}/api/v1/orgs/tree", token)
|
||
def first_class(nodes):
|
||
for n in nodes or []:
|
||
if n.get("orgType") == 3:
|
||
return n
|
||
hit = first_class(n.get("children"))
|
||
if hit:
|
||
return hit
|
||
return None
|
||
cls = first_class(bd.get("data"))
|
||
if not cls:
|
||
fail("取一个班级组织", "demo 租户里没有 orgType=3 的组织,E2E 无法继续")
|
||
return 1
|
||
ok(f"取到班级组织:{cls['orgName']}(id={cls['id']})")
|
||
|
||
# ── 1. 建任务:校验与默认值 ─────────────────────────
|
||
print("\n[1] 建任务")
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 2, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
check("collectMode=2(社会收集)被拒 40044", body.get("code") == 40044,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 3,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
check("certType=3(专项职业能力)被拒 40044", body.get("code") == 40044,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "2026-09",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
check("批次号格式非法被拒", body.get("code") != 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-10-15", "endTime": "2026-09-28",
|
||
})
|
||
check("截止早于开始被拒", body.get("code") != 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
# 命名模板:缺必备变量
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
"namingTemplate": "{姓名}-{材料名}",
|
||
})
|
||
check("命名模板缺 {证件号} 被拒 40020", body.get("code") == 40020,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
# 命名模板:未知变量
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
"namingTemplate": "{姓名}-{身证号}-{材料名}",
|
||
})
|
||
check("命名模板含未知变量被拒 40020", body.get("code") == 40020,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
batch = "20" + suffix[:4] if suffix[:4].isdigit() else "2099"
|
||
|
||
# 顺带验证 application.yml 的 spring.jackson.* 真的生效。
|
||
# Boot 4 把 Jackson 自动配置挪到了 spring-boot-starter-jackson,
|
||
# 没引它时这些配置是**静默失效**的:看着配了,其实一行都没读。
|
||
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-10-15",
|
||
"thisFieldDoesNotExist": "fail-on-unknown-properties=false 生效时才不会报错",
|
||
})
|
||
check("请求带未知字段也能通过(spring.jackson 配置真的生效)",
|
||
body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}"
|
||
" —— 若为 40001/50000,说明 spring-boot-starter-jackson 没生效")
|
||
if body.get("code") == 0:
|
||
CREATED_TASK_IDS.append(body["data"]["id"])
|
||
|
||
# 合法创建
|
||
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-10-15",
|
||
})
|
||
if not check("建任务成功", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}"):
|
||
show(body)
|
||
return 1
|
||
task_id = body["data"]["id"]
|
||
task_code = body["data"]["taskCode"]
|
||
CREATED_TASK_IDS.append(task_id)
|
||
print(f" → id={task_id} taskCode={task_code} status={body['data']['status']}")
|
||
check("返回的状态是草稿(0)", body["data"]["status"] == 0, f"status={body['data']['status']}")
|
||
check("任务编码 = 批次号 + 4 位序号",
|
||
task_code.startswith(batch) and len(task_code) == len(batch) + 4 and task_code[len(batch):].isdigit(),
|
||
f"batch={batch} taskCode={task_code}")
|
||
|
||
# ── 2. 详情与默认值 ─────────────────────────────────
|
||
print("\n[2] 详情与默认值")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||
d = body.get("data") or {}
|
||
ac = d.get("auditConfig") or {}
|
||
cc = d.get("compressConfig") or {}
|
||
check("rosterRequired 恒为 true", d.get("rosterRequired") is True,
|
||
f"rosterRequired={d.get('rosterRequired')}")
|
||
check("名册为空时 stat 为 null(没有分母,不能假装是 0%)",
|
||
d.get("stat") is None, f"stat={d.get('stat')}")
|
||
check("auditConfig 默认:需班主任初审 + 需终审",
|
||
ac.get("needClassAudit") is True and ac.get("needAdminAudit") is True,
|
||
f"auditConfig={ac}")
|
||
check("auditConfig 默认:48 / 72 / 3",
|
||
ac.get("classAuditTimeoutHours") == 48 and ac.get("adminAuditTimeoutHours") == 72
|
||
and ac.get("maxRejectCount") == 3,
|
||
f"auditConfig={ac}")
|
||
check("auditConfig:批量通过只带无问题项恒为 true",
|
||
ac.get("batchApproveOnlyGreen") is True, f"batchApproveOnlyGreen={ac.get('batchApproveOnlyGreen')}")
|
||
check("compressConfig 默认:1024KB / 300dpi / q85",
|
||
cc.get("defaultMaxSizeKb") == 1024 and cc.get("defaultTargetDpi") == 300
|
||
and cc.get("defaultQuality") == 85,
|
||
f"compressConfig={cc}")
|
||
check("compressConfig 默认:minQuality=70 / hardFloorDpi=200",
|
||
cc.get("minQuality") == 70 and cc.get("hardFloorDpi") == 200,
|
||
f"compressConfig={cc}")
|
||
check("compressConfig:截图保 PNG 恒为 true",
|
||
cc.get("keepPngForScreenshot") is True, f"keepPngForScreenshot={cc.get('keepPngForScreenshot')}")
|
||
|
||
# 铁律:批量通过只带无问题项不可关闭
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工改", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-20",
|
||
"auditConfig": {"batchApproveOnlyGreen": False, "needAdminAudit": False},
|
||
})
|
||
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}", token)
|
||
d = body.get("data") or {}
|
||
check("客户端传 batchApproveOnlyGreen=false 被服务端扳回 true(铁律不可关)",
|
||
(d.get("auditConfig") or {}).get("batchApproveOnlyGreen") is True,
|
||
f"batchApproveOnlyGreen={(d.get('auditConfig') or {}).get('batchApproveOnlyGreen')}")
|
||
check("needsAdminAudit=false 被采纳(它是可配的)",
|
||
(d.get("auditConfig") or {}).get("needAdminAudit") is False,
|
||
f"needAdminAudit={(d.get('auditConfig') or {}).get('needAdminAudit')}")
|
||
check("专业已改为「数控加工改」", d.get("majorName") == "数控加工改", f"majorName={d.get('majorName')}")
|
||
|
||
# ── 3. 列表与过滤 ───────────────────────────────────
|
||
print("\n[3] 列表与过滤")
|
||
status, body = call("GET", f"{base}/api/v1/tasks?pageNum=1&pageSize=5", token)
|
||
dd = body.get("data") or {}
|
||
check("列表返回 {list,total,pageNum,pageSize}",
|
||
isinstance(dd.get("list"), list) and dd.get("pageNum") == 1 and dd.get("pageSize") == 5,
|
||
f"keys={list(dd.keys())}")
|
||
|
||
status, body = call("GET", f"{base}/api/v1/tasks?"
|
||
+ urllib.parse.urlencode({"keyword": f"E2E 任务 {suffix}"}), token)
|
||
hits = (body.get("data") or {}).get("list") or []
|
||
check("按关键字能搜到", any(h["id"] == task_id for h in hits), f"命中 {len(hits)} 条")
|
||
|
||
status, body = call("GET", f"{base}/api/v1/tasks?"
|
||
+ urllib.parse.urlencode({"status": 0, "batchNo": batch}), token)
|
||
hits = (body.get("data") or {}).get("list") or []
|
||
check("按状态+批次过滤能搜到", any(h["id"] == task_id for h in hits), f"命中 {len(hits)} 条")
|
||
|
||
status, body = call("GET", f"{base}/api/v1/tasks?status=3", token)
|
||
hits = (body.get("data") or {}).get("list") or []
|
||
check("按状态过滤排除了草稿任务", not any(h["id"] == task_id for h in hits),
|
||
f"状态=3 却命中了草稿任务")
|
||
|
||
# ── 4. 发布前置校验(全部阻断)──────────────────────
|
||
print("\n[4] 发布前置校验(阻断级,force 绕不过)")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
|
||
check("空任务发布被拒 40016(名册为空)", body.get("code") == 40016,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
msg = body.get("msg") or ""
|
||
check("一次把所有缺项都列出来了(不是撞到第一个就返回)",
|
||
("材料" in msg) and ("班级" in msg),
|
||
f"msg={msg}")
|
||
check("force=true 也无法绕过阻断级校验", body.get("code") == 40016,
|
||
f"code={body.get('code')}")
|
||
|
||
# ── 5. 造前置数据后发布 ─────────────────────────────
|
||
print("\n[5] 造前置数据(SQL 夹具)后发布")
|
||
okk, out = sql(
|
||
f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, id_card_no, id_card_hash) "
|
||
f"VALUES ({tenant_id}, {task_id}, {cls['id']}, 'E2E学生A', 'seed', 'seed')")
|
||
check("夹具:写入 1 行名册", okk, out)
|
||
okk, out = sql(
|
||
f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) "
|
||
f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)")
|
||
check("夹具:写入 1 项必传材料", okk, out)
|
||
okk, out = sql(
|
||
f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
|
||
f"VALUES ({tenant_id}, {task_id}, {cls['id']}, 1)")
|
||
check("夹具:写入 1 条覆盖班级(不指定初审人 → 会回退到班级负责人)", okk, out)
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {})
|
||
check("班级未指定初审人 → 只给 P1 提醒,不阻断", body.get("code") != 0 and "确认" 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}/publish", token, {"force": True})
|
||
if not check("带 force 发布成功", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}"):
|
||
show(body)
|
||
else:
|
||
check("发布后状态=收集中(1)", (body.get("data") or {}).get("status") == 1,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
check("发布了时间", bool((body.get("data") or {}).get("publishedTime")),
|
||
"publishedTime 为空")
|
||
|
||
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')}")
|
||
|
||
# 已发布不可改模式
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 2, "batchNo": batch,
|
||
"majorName": "数控加工改", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-20",
|
||
})
|
||
check("已发布不可改 collectMode", body.get("code") != 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
# ── 6. 暂停 / 恢复(幂等)──────────────────────────
|
||
print("\n[6] 暂停 / 恢复")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token,
|
||
{"reason": "名册需补充 3 人"})
|
||
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}", token)
|
||
check("暂停后状态=已暂停(4)", (body.get("data") or {}).get("status") == 4,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {})
|
||
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}/resume", token)
|
||
check("恢复成功", body.get("code") == 0, f"code={body.get('code')}")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||
check("恢复后状态=收集中(1)", (body.get("data") or {}).get("status") == 1,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
|
||
check("重复恢复幂等", body.get("code") == 0, f"code={body.get('code')}")
|
||
|
||
# ── 7. 截止 / 归档 ─────────────────────────────────
|
||
print("\n[7] 截止 / 归档")
|
||
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("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||
check("截止后状态=已截止(2)", (body.get("data") or {}).get("status") == 2,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {})
|
||
check("已截止不可暂停(40021)", body.get("code") == 40021,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/archive", token,
|
||
{"archiveRemark": "E2E 归档"})
|
||
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}", token)
|
||
check("归档后状态=已归档(3)", (body.get("data") or {}).get("status") == 3,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
|
||
check("已归档不可恢复(40022)", body.get("code") == 40022,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, {
|
||
"taskName": "改名试试", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工改", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-20",
|
||
})
|
||
check("已归档仍可改名称(只锁模式与认定类型)", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("DELETE", f"{base}/api/v1/tasks/{task_id}", token)
|
||
# 2026-09-30 客户确认(§17.2 D-9):已归档的任务**可以**删除(子表级联物理删、
|
||
# 任务行软删占住编码)。旧断言"已归档不可删除"随旧口径一起作废。
|
||
check("已归档可以删除(2026-09-30 起,级联清理 + 留痕)", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
# ── 8. 草稿路径与删除 ───────────────────────────────
|
||
print("\n[8] 草稿路径与删除")
|
||
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-10-15",
|
||
})
|
||
draft_id = (body.get("data") or {}).get("id")
|
||
if check("再建一个草稿任务", body.get("code") == 0, f"code={body.get('code')}"):
|
||
CREATED_TASK_IDS.append(draft_id)
|
||
check("任务编码递增(同一批次不重号)",
|
||
(body["data"]["taskCode"] != task_code),
|
||
f"两次都是 {task_code}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E草稿2 {suffix}", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
if check("删掉草稿后再建(同批次)", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}"):
|
||
third_id = body["data"]["id"]
|
||
third_code = body["data"]["taskCode"]
|
||
CREATED_TASK_IDS.append(third_id)
|
||
# 回归护栏:软删掉的任务仍然占着唯一约束,编码不能重用,
|
||
# 否则会报 duplicate key(且编码同时是文件目录名,复用会串目录)
|
||
check("软删后不改用旧编码(唯一约束不看软删标记)",
|
||
third_code not in (task_code,),
|
||
f"复用了编码 {third_code}")
|
||
check("新编码序号继续递增", third_code > task_code,
|
||
f"{task_code} → {third_code}")
|
||
status, body = call("DELETE", f"{base}/api/v1/tasks/{third_id}", token)
|
||
check("再删掉它", body.get("code") == 0, f"code={body.get('code')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/pause", token, {})
|
||
check("草稿不可暂停(40021)", body.get("code") == 40021,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/resume", token)
|
||
check("草稿不可恢复(40022)", body.get("code") == 40022,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/archive", token, {})
|
||
check("草稿不可归档", body.get("code") != 0, f"code={body.get('code')}")
|
||
|
||
status, body = call("DELETE", f"{base}/api/v1/tasks/{draft_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/{draft_id}", token)
|
||
check("删除后详情查不到", body.get("code") != 0, f"code={body.get('code')}")
|
||
|
||
# ── 9. 可见范围(班主任只看自己带的班)───────────────
|
||
print("\n[9] 可见范围:班主任只看自己带的班级")
|
||
ht_user = f"htt{suffix}"
|
||
status, body = call("POST", f"{base}/api/v1/users", token, {
|
||
"username": ht_user, "realName": f"E2E班主任{suffix}",
|
||
"phone": "13900139901", "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)
|
||
|
||
# 建两个任务:一个把他设为初审人,一个不设
|
||
mine, others = [], []
|
||
for tag in ("mine", "other"):
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E可见性-{tag}-{suffix}", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
if body.get("code") != 0:
|
||
fail(f"造可见性任务 {tag}", f"code={body.get('code')} msg={body.get('msg')}")
|
||
continue
|
||
tid = body["data"]["id"]
|
||
CREATED_TASK_IDS.append(tid)
|
||
(mine if tag == "mine" else others).append(tid)
|
||
if tag == "mine":
|
||
sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, head_user_id, status) "
|
||
f"VALUES ({tenant_id}, {tid}, {cls['id']}, {ht_id}, 1)")
|
||
else:
|
||
sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
|
||
f"VALUES ({tenant_id}, {tid}, {cls['id']}, 1)")
|
||
|
||
status, body = login(base, ht_user, ht_pwd, args.tenant)
|
||
if check("班主任能登录", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
|
||
ht_token = body["data"]["token"]
|
||
status, body = call("GET", f"{base}/api/v1/tasks?pageSize=200", token=ht_token)
|
||
seen = {t["id"] for t in (body.get("data") or {}).get("list") or []}
|
||
check("班主任能看到「自己带的班」的任务",
|
||
all(t in seen for t in mine), f"应看到 {mine},实际 {sorted(seen)}")
|
||
check("班主任看不到「不是自己带的班」的任务",
|
||
all(t not in seen for t in others), f"不该看到 {others},实际 {sorted(seen)}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", ht_token, {
|
||
"taskName": "班主任不该能建任务", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "x", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
check("班主任建任务被拒(无 task:manage)", body.get("code") == 40301,
|
||
f"code={body.get('code')}")
|
||
|
||
# ── 10. 清理 ───────────────────────────────────────
|
||
print("\n[10] 清理测试数据")
|
||
if CREATED_TASK_IDS:
|
||
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
|
||
for stmt in [
|
||
f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_task WHERE id IN ({ids})",
|
||
]:
|
||
okk, out = sql(stmt)
|
||
if not okk:
|
||
print(f" ⚠ {stmt[:60]}… → {out}")
|
||
for uid in CREATED_USER_IDS:
|
||
call("DELETE", f"{base}/api/v1/users/{uid}", token)
|
||
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__":
|
||
raise SystemExit(main())
|