Files
xiaotutongxue/scripts/e2e-m1-orgs.py
T
lovife 7523ebe789 feat(task): M1-02 覆盖班级与班主任配置
补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 ——
班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。

后端 GET/PUT /tasks/{id}/orgs(整体替换语义 + 软移除 + 历史行复用);
前端「配置班级」抽屉:组织树只允许勾选班级,主班主任留空时显式提示
「将由该班负责人初审」而非留白。发布被拦时给出「去哪里补」的指引
(BLOCKING_HINTS),而不是只说"未设置覆盖班级"。

三条守住的规则:
1) 班级与初审人都必须在本租户内。跨租户的 id 一律当作"不存在或已删除",
   不透露"它存在但不属于你" —— 漏了的后果是 A 校能把 B 校的班挂到自己
   的任务上,B 校的班主任就出现在 A 校的审批流里。
2) 初审人必须持有班主任/副班主任角色。指定初审人本质上是一次授权,
   那个人将能看到该班学生的身份证号与学籍材料;不校验角色就等于把
   "谁能看学生隐私"变成任意授权。报错要给出下一步,不能只说"校验失败"。
3) 移除是软移除(status=0),且「移除再加回」必须复用原行。
   后者不是优化而是必须:uk_task_org UNIQUE (task_id, org_id) 不带 status
   条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束。

本轮修掉的 3 个真问题:
1) 我此前误判 uk_task_org 不存在 —— grep 只匹配含表名的行,而
   CONSTRAINT uk_task_org UNIQUE (task_id, org_id) 那行没有表名。
   基于这个错误前提,我写了一个多余的部分唯一索引 uk_task_org_active
   和一段基于错误前提的代码,已全部撤掉并更正注释。
   教训:查约束要看 CREATE TABLE 整块,不能只 grep 表名。
2) collect_task_org.expected_count 是 NOT NULL DEFAULT 0,与接口契约矛盾:
   DDL 注释说它是"由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在
   PUT 请求体里、说"用于与名册实际人数比对" —— 只有"管理员填的期望值 vs
   名册实际值"才需要比对。而 0 是有意义的业务值("预期 0 人"),拿它兼职
   表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警;告警
   变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉,
   而这条告警的全部价值就在那一次。已由 V4 迁移改为可空 + 去掉默认值,
   并同步更正 docs/db/schema-postgresql.sql 与 PRD 里的内联 DDL。
3) 删掉迁移文件后必须 mvn clean,否则它会"复活":我只跑 mvn package,
   target/classes 里的旧副本被打进 jar,Flyway 发现历史里没有 v4 就又执行
   了一遍 —— 索引回来了、历史行填回去了。之后又建了同名版本号的新 V4,
   于是"两个 v4"→"checksum 冲突",排查绕了好几圈。

顺带修掉:
· 任务列表操作列从 3 个链接涨到 4 个,160px 会挤成竖排 —— 把「进度」并进
  「状态」列腾出 40px,操作列给到 200px,合计 820px(可用约 824px)。
· defineEmits 必须用函数调用签名(frontend-pc/.agents/skills/code-vue)。
  我此前写的 5 个组件都是被禁的 tuple 形式,一并改正:
  OrgFormModal / TaskFormModal / TaskOrgDrawer / UserFormModal / UserImportModal。
· e2e-m1-orgs.py 的清理抽成幂等的 cleanup_all(),并在 __main__ 的 finally
  里兜一次 —— 该脚本首次运行时崩在准备阶段、清理段没执行,把两个测试账号
  留在了库里(是在浏览器里看到下拉框里有个「E2E班主任」才发现的)。
  其余三条 E2E 脚本仍是老样子,下一轮一并改。

验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、
e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言;
前端 typecheck / eslint / lint:style 0 错、build 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
2026-09-29 09:32:48 +08:00

563 lines
28 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-02 任务覆盖班级与班主任配置 验收测试。
覆盖《03-接口设计》§7.6:
[x] GET /tasks/{id}/orgs 只返回生效的覆盖关系,班级名与班主任名装配正确
[x] PUT /tasks/{id}/orgs 整体替换语义、软移除、历史行复用
[x] 校验:班级必须是 orgType=3、必须同租户、同一个班不能重复
[x] 校验:初审人必须是本租户内已启用且持班主任角色的账号、主副不能同一人
[x] 状态限制:草稿/收集中/已暂停可改,已截止/已归档不可改
[x] 权限:班主任无 task:manage,不能改
为什么这些校验值得逐条断言:
「指定初审人」本质上是一次**授权** —— 那个人将能看到该班学生的身份证号与学籍材料。
所以"不能把 A 校的班挂到 B 校的任务上""不能把非班主任设为初审人"
不是体验问题,是数据隔离与授权边界问题。每一条都要有断言守着。
用法
python scripts/e2e-m1-orgs.py
python scripts/e2e-m1-orgs.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] = []
CREATED_TENANT_IDS: list[int] = []
#: 清理时要用的上下文。放在模块级,是为了让**脚本中途崩溃也能清理**。
#:
#: ⚠️ 教训:本脚本第一次运行时在准备阶段抛了 TypeError 直接退出,
#: 清理段根本没被执行,于是把两个测试账号(hte688ed2 / adm688ed2)留在了库里。
#: 脚本崩了就留下垃圾数据,是自动化测试最容易犯也最容易被忽略的问题 ——
#: 因为"跑通了"的时候它不会暴露。
CTX: dict[str, str | None] = {"base": None, "token": None}
def cleanup_all() -> None:
"""
清理所有本脚本造出来的数据。**幂等**,可以重复调用;单项失败不影响其余项。
被 `main()` 末尾与 `__main__` 的 finally 各调用一次 —— 前者的输出好看,
后者保证崩溃时也能兜住。
"""
base = CTX.get("base")
token = CTX.get("token")
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})",
]:
sql(stmt)
if base and token:
for uid in CREATED_USER_IDS:
try:
call("DELETE", f"{base}/api/v1/users/{uid}", token)
except Exception:
pass
for tid in CREATED_TENANT_IDS:
for stmt in [
f"DELETE FROM sys_user_role WHERE user_id IN (SELECT id FROM sys_user WHERE tenant_id = {tid})",
f"DELETE FROM sys_user WHERE tenant_id = {tid}",
f"DELETE FROM sys_org WHERE tenant_id = {tid}",
f"DELETE FROM sys_role WHERE tenant_id = {tid}",
f"DELETE FROM collect_task WHERE tenant_id = {tid}",
f"DELETE FROM sys_tenant WHERE id = {tid}",
]:
sql(stmt)
# ── 断言 ──────────────────────────────────────────────
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 ''}")
def blocking_hint(body: dict) -> str:
"""
把 40001 的结构化校验明细摊平成一句人话。
后端对参数校验失败返回的是 `data.blocking[]`(每项含 field 与 title),
而不是只给一句"存在阻断性校验问题" —— 这对前端很重要(能直接高亮出错字段),
但脚本只打印 msg 的话就完全看不出是哪个字段错了,排查要多花好几轮。
"""
items = ((body.get("data") or {}).get("blocking")) or []
if not items:
return ""
parts = [f"{i.get('field')}: {i.get('title')}" for i in items if i]
return " 明细[" + ";".join(parts) + "]" if parts else ""
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-02 覆盖班级与班主任 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-02 覆盖班级与班主任 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"] = base
CTX["token"] = token
ok(f"管理员登录(租户 id={tenant_id})")
status, body = call("GET", f"{base}/api/v1/orgs/tree", token)
classes: list[dict] = []
school: list[dict] = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
if n.get("orgType") == 1:
school.append(n)
walk(n.get("children"))
walk(body.get("data"))
if len(classes) < 2 or not school:
fail("准备组织数据", f"demo 租户需要至少 2 个班级和 1 个学校节点,实际 班级={len(classes)} 学校={len(school)}")
return 1
ok(f"取到班级与学校节点:{classes[0]['orgName']} / {classes[1]['orgName']} / {school[0]['orgName']}")
# 造一个班主任账号(指定初审人时必须持有班主任角色)
# ⚠️ userType 是接口的 @NotNull 字段。界面上"用户类型由角色推导"是前端的职责,
# 接口这层仍要求显式传,且服务端会校验它与角色一致(3=班主任,2=学校管理员)
ht_user = f"hte{suffix}"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": ht_user, "realName": f"E2E班主任{suffix}",
"phone": "13900139101", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if body.get("code") != 0:
fail("造班主任账号", f"code={body.get('code')} msg={body.get('msg')}{blocking_hint(body)}")
return 1
head_id = body["data"]["id"]
head_pwd = body["data"]["initialPassword"]
CREATED_USER_IDS.append(head_id)
ok(f"造班主任账号(id={head_id})")
# 再造一个非班主任(学校管理员),用于验证"非班主任不能当初审人"
admin2_user = f"adm{suffix}"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": admin2_user, "realName": f"E2E管理员{suffix}",
"phone": "13900139102", "roleCodes": ["SCHOOL_ADMIN"], "userType": 2,
})
non_teacher_id = None
if body.get("code") == 0:
non_teacher_id = body["data"]["id"]
CREATED_USER_IDS.append(non_teacher_id)
ok(f"造非班主任账号(id={non_teacher_id})")
else:
fail("造非班主任账号", f"code={body.get('code')} msg={body.get('msg')}{blocking_hint(body)}")
# 造第二个租户(跨租户隔离用)
other_org_id = None
status, plat = login(base, "platform", "Platform@123")
if plat.get("code") != 0:
fail("平台管理员登录", f"code={plat.get('code')} msg={plat.get('msg')}")
else:
plat_token = plat["data"]["token"]
other_code = f"e2e{suffix}"
status, body = call("POST", f"{base}/api/v1/tenants", plat_token, {
"tenantName": f"E2E 隔离学校 {suffix}", "tenantCode": other_code, "tenantType": 1,
"contactName": "测试联系人", "contactPhone": "13900139103",
"expireTime": "2027-12-31",
"adminUsername": "admin", "adminRealName": "E2E 隔离管理员",
"adminPhone": "13900139104",
})
if body.get("code") != 0:
fail("开通第二个租户", f"code={body.get('code')} msg={body.get('msg')}")
else:
other_tenant_id = body["data"]["id"]
other_pwd = body["data"]["adminInitialPassword"]
CREATED_TENANT_IDS.append(other_tenant_id)
status, ob = login(base, "admin", other_pwd, other_code)
if ob.get("code") != 0:
fail("第二个租户管理员登录", f"code={ob.get('code')} msg={ob.get('msg')}")
else:
other_token = ob["data"]["token"]
# 在新租户里建一个班(先建学校→再建班)
status, body = call("POST", f"{base}/api/v1/orgs", other_token, {
"parentId": 0, "orgName": f"隔离学校{suffix}", "orgType": 1,
"orgCode": f"ISOL{suffix}", "sortNo": 1,
})
parent = body.get("data")
status, body = call("POST", f"{base}/api/v1/orgs", other_token, {
"parentId": parent, "orgName": f"隔离班{suffix}", "orgType": 3,
"orgCode": f"ISOLC{suffix}", "sortNo": 1,
})
if body.get("code") == 0:
other_org_id = body["data"]
ok(f"第二个租户与它的班级已就绪(orgId={other_org_id})")
else:
fail("在第二个租户里建班级", f"code={body.get('code')} msg={body.get('msg')}")
# 造一个任务
batch = "2001"
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",
})
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} code={body['data']['taskCode']})")
# ── 1. 空任务 ───────────────────────────────────────
print("\n[1] 空任务的覆盖范围")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
check("新任务的覆盖范围是空数组(不是 null)", body.get("code") == 0 and body.get("data") == [],
f"code={body.get('code')} data={body.get('data')}")
# ── 2. 设置覆盖班级 ─────────────────────────────────
print("\n[2] 设置覆盖班级与班主任")
c1, c2 = classes[0]["id"], classes[1]["id"]
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [
{"orgId": c1, "headUserId": head_id, "deputyUserIds": [], "expectedCount": 28},
{"orgId": c2, "headUserId": None, "deputyUserIds": [head_id], "expectedCount": 31},
],
})
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}/orgs", token)
rows = body.get("data") or []
check("返回 2 个班", len(rows) == 2, f"实际 {len(rows)} 行:{json.dumps(rows, ensure_ascii=False)[:200]}")
by_org = {r["orgId"]: r for r in rows}
r1 = by_org.get(c1) or {}
r2 = by_org.get(c2) or {}
check("第一个班:班级名正确", r1.get("orgName") == classes[0]["orgName"],
f"{r1.get('orgName')} != {classes[0]['orgName']}")
check("第一个班:初审人姓名带账号(同名同姓时能区分)",
(r1.get("headUserName") or "").startswith(f"E2E班主任{suffix}")
and f"({ht_user})" in (r1.get("headUserName") or ""),
f"headUserName={r1.get('headUserName')}")
check("第一个班:应填人数保存正确", r1.get("expectedCount") == 28, f"{r1.get('expectedCount')}")
check("第一个班:副班主任为空数组(不是 null)",
r1.get("deputyUserIds") == [] and r1.get("deputyUserNames") == [],
f"{r1.get('deputyUserIds')} / {r1.get('deputyUserNames')}")
check("第二个班:初审人为空(表示取班级负责人,不是「没人审」)",
r2.get("headUserId") is None and r2.get("headUserName") is None,
f"headUserId={r2.get('headUserId')} headUserName={r2.get('headUserName')}")
check("第二个班:副班主任姓名已装配",
r2.get("deputyUserNames") and r2["deputyUserNames"][0].startswith(f"E2E班主任{suffix}"),
f"deputyUserNames={r2.get('deputyUserNames')}")
check("status 恒为 1(只返回生效行)", all(r.get("status") == 1 for r in rows),
f"{[r.get('status') for r in rows]}")
# ── 3. 校验(全部拒绝)──────────────────────────────
print("\n[3] 校验:不该放行的都要拦住")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1}, {"orgId": c1}],
})
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}/orgs", token, {
"orgs": [{"orgId": 999999999}],
})
check("不存在的班级被拒 40001", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": school[0]["id"]}],
})
check("把「学校」当成一个班被拒 40001", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
check("拒绝理由讲清楚了「不是班级」(而不是笼统的校验失败)",
"不是班级" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
if non_teacher_id:
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": non_teacher_id}],
})
check("把非班主任设为初审人被拒 40001", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
check("拒绝理由讲清楚了「不是班主任」并给出怎么办",
"不是班主任" in (body.get("msg") or "") and "账号管理" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "deputyUserIds": [head_id]}],
})
check("主班主任与副班主任同一人被拒 40001", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
if other_org_id:
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": other_org_id}],
})
check("跨租户的班级被拒 40001(数据隔离边界)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
check("跨租户的班被当作「不存在」,不透露它存在",
"不存在" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
else:
fail("跨租户隔离用例", "第二个租户没造出来,这条最重要的隔离断言没能执行")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
check("以上失败都没有污染数据(仍是 2 个班)", len(body.get("data") or []) == 2,
f"实际 {len(body.get('data') or [])} 行")
# ── 4. 软移除与历史行复用 ───────────────────────────
print("\n[4] 软移除与历史行复用")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28}],
})
check("只提交 1 个班(整体替换)成功", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
check("另一个班已从覆盖范围消失", len(body.get("data") or []) == 1,
f"实际 {len(body.get('data') or [])} 行")
okk, out = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id} "
f"AND org_id = {c2} AND status = 0")
check("被移除的班是软移除(status=0 的行还在,不是物理删除)", okk and out.strip() == "1",
f"查询结果={out}")
okk, before = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}],
})
check("把移除的班加回来成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
okk, after = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
check("加回来时复用历史行(行数没有增加)", okk and before.strip() == after.strip(),
f"移除后 {before.strip()} 行 → 加回后 {after.strip()} 行")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
check("加回后仍是 2 个班生效", len(body.get("data") or []) == 2,
f"实际 {len(body.get('data') or [])} 行")
# 清空
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {"orgs": []})
check("允许清空(合法中间态,发布时由 40019 拦)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
check("清空后为空数组", body.get("data") == [], f"{body.get('data')}")
# ── 5. 状态限制 ─────────────────────────────────────
print("\n[5] 状态限制")
# 恢复 2 个班,然后造发布前置数据走到"收集中"
call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}],
})
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}, {c1}, '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)")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
if check("任务已发布(收集中)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}"):
# 故意有一条不传 expectedCount —— 它必须存成"未设置"(null),不能变成 0。
# 若变成 0,"期望 0 人、实际 31 人"这种假告警会出现在每个没填的班上。
mid_orgs = [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
{"orgId": c2}]
if len(classes) > 2:
mid_orgs.append({"orgId": classes[2]["id"]})
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {"orgs": mid_orgs})
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}/orgs", token)
rows2 = {r["orgId"]: r for r in (body.get("data") or [])}
check("不传 expectedCount 时存为 null(不是 0)",
c2 in rows2 and rows2[c2].get("expectedCount") is None,
f"expectedCount={rows2.get(c2, {}).get('expectedCount')}")
# 清空已填的应填人数:这一条正是之前触发 500 的路径
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id}],
})
check("可以把已填的应填人数清空(不再 500)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
check("清空后读回是 null(清空真的落库了,不是静默忽略)",
(body.get("data") or [{}])[0].get("expectedCount") is None,
f"{body.get('data')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28}],
})
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("GET", f"{base}/api/v1/tasks/{task_id}", token)
closed = (body.get("data") or {}).get("status") == 2
check("任务已截止", closed, f"status={(body.get('data') or {}).get('status')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id}],
})
check("已截止不可改覆盖范围", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
call("POST", f"{base}/api/v1/tasks/{task_id}/archive", token, {})
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1}],
})
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}/orgs", token)
check("已归档仍可读覆盖范围(只读不等于看不见)", body.get("code") == 0 and len(body.get("data") or []) == 1,
f"code={body.get('code')} rows={len(body.get('data') or [])}")
# ── 6. 权限 ────────────────────────────────────────
print("\n[6] 权限:班主任只能看不能改")
status, body = login(base, ht_user, head_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("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", ht_token, {
"orgs": [{"orgId": c1}],
})
check("班主任改覆盖范围被拒(无 task:manage)", body.get("code") == 40301,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", ht_token)
check("班主任能读覆盖范围(他需要知道自己带哪些班)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 7. 清理 ────────────────────────────────────────
print("\n[7] 清理测试数据")
cleanup_all()
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号、{len(CREATED_TENANT_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)