Files
aca-classroom-service/smoke/smoke.mjs
T
wanghu 21d15bd79f
build / build-push (push) Successful in 55s
班级绑定
2026-10-20 12:11:32 +08:00

300 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/**
* 教室管理后端冒烟脚本(Node 18+,无第三方依赖)。
*
* 用法:
* node smoke/smoke.mjs # 无认证模式(本地 YH_SSO_URLPATTERN=/none/* + ACACLASSROOM_DEVCONTEXT_ENABLED=true)
* TEACHER_COOKIE="CASTGC=..." node smoke/smoke.mjs # 真实 SSO 会话(浏览器复制 Cookie)
*
* 覆盖:健康检查 → 基础/校区/类型/楼栋/教室 全链路(自建数据 → 断言 → 逆序清理)。
*/
const BASE = process.env.SMOKE_BASE || "http://localhost:8106";
const COOKIE = process.env.TEACHER_COOKIE || "";
const headers = { ...(COOKIE ? { Cookie: COOKIE } : {}) };
let passed = 0, failed = 0;
const ok = (name, cond, detail = "") => {
if (cond) { passed++; console.log(` ✓ ${name}`); }
else { failed++; console.error(` ✗ ${name} ${detail}`); }
};
async function api(method, path, { form, json, params } = {}) {
const url = new URL(BASE + path);
if (params) Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v));
const opt = { method, headers: { ...headers } };
if (json != null) {
opt.headers["Content-Type"] = "application/json;charset=UTF-8";
opt.body = JSON.stringify(json);
} else if (form != null) {
opt.headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8";
opt.body = new URLSearchParams(form).toString();
}
const res = await fetch(url, opt);
const text = await res.text();
try { return JSON.parse(text); } catch { return { code: -1, msg: text.substring(0, 120) }; }
}
console.log(`[smoke] target=${BASE} auth=${COOKIE ? "cookie" : "none(dev-context)"}`);
// 1. 健康
const health = await fetch(`${BASE}/actuator/health`).then(r => r.json()).catch(() => null);
ok("actuator/health UP", health && health.status === "UP");
// 2. 基础
const info = await api("GET", "/api/base/GetUserInfo");
ok("GetUserInfo code=0", info.code === 0, JSON.stringify(info).substring(0, 80));
const mods = await api("GET", "/api/base/GetUserMod");
ok("GetUserMod code=0", mods.code === 0);
// 3. 校区
const stamp = Date.now().toString(36);
const campusName = `冒烟校区${stamp}`;
const cAdd = await api("POST", "/api/campus/Add", { form: { campusName, sortOrder: 99 } });
ok("campus Add", cAdd.code === 0 && cAdd.data > 0, cAdd.msg);
const campusId = cAdd.data;
const cDup = await api("POST", "/api/campus/Add", { form: { campusName } });
ok("campus Add 重名拒绝", cDup.code !== 0);
const cList = await api("GET", "/api/campus/List");
ok("campus List 含新校区", cList.code === 0 && cList.data.some(c => c.serID === campusId));
// 4. 教室类型
const typeName = `冒烟类型${stamp}`;
const tAdd = await api("POST", "/api/classtype/Add", { form: { typeName } });
ok("classtype Add", tAdd.code === 0, tAdd.msg);
const typeId = tAdd.data;
// 5. 楼栋
const bAdd = await api("POST", "/api/building/Add", { form: { campusId, buildingName: `冒烟楼栋${stamp}`, buildingCode: `S${stamp.slice(-3)}`, floorCount: 2 } });
ok("building Add", bAdd.code === 0, bAdd.msg);
const buildingId = bAdd.data;
// 6. 教室批量添加(带设备)+ 列表
const eqSeed = await api("POST", "/api/equipment/Add", { form: { equipmentName: `冒烟设备${stamp}` } });
ok("equipment Add(独立台账)", eqSeed.code === 0, eqSeed.msg);
const eqSeedId = eqSeed.data;
const eqSeedDup = await api("POST", "/api/equipment/Add", { form: { equipmentName: `冒烟设备${stamp}` } });
ok("equipment Add 校内重名拒绝", eqSeedDup.code !== 0);
const batch = await api("POST", "/api/classroom/BatchAdd", { json: { buildingId, floorFrom: 1, floorTo: 2, countPerFloor: 3, typeId, equipments: [{ equipmentId: eqSeedId, quantity: 2 }] } });
ok("classroom BatchAdd 6 间(带设备,每间数量 2)", batch.code === 0 && batch.data.created === 6, batch.msg);
const batchBad = await api("POST", "/api/classroom/BatchAdd", { json: { buildingId, floorFrom: 1, floorTo: 9, countPerFloor: 1, typeId } });
ok("BatchAdd 楼层超限拒绝", batchBad.code !== 0);
const rooms = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } });
ok("classroom List 6 间", rooms.code === 0 && rooms.data.total === 6, `total=${rooms.data && rooms.data.total}`);
const room1 = rooms.data.list[0].serID;
ok("教室行含设备关联(数量 2,名称×2)", rooms.data.list.every(r => Array.isArray(r.equipmentIds) && r.equipmentIds.includes(eqSeedId) && r.equipmentQtys && r.equipmentQtys[eqSeedId] === 2 && /冒烟设备.*×2$/.test(r.equipmentNames)), JSON.stringify(rooms.data.list[0] && rooms.data.list[0].equipmentNames));
const floors = await api("GET", "/api/classroom/Floors", { params: { buildingId } });
ok("classroom Floors [1,2]", floors.code === 0 && JSON.stringify(floors.data) === "[1,2]");
const bStats = await api("GET", "/api/building/Stats", { params: { campusId } });
ok("building Stats total=6", bStats.code === 0 && bStats.data.some(b => b.serID === buildingId && b.total === 6));
// 7. 教室编辑(含设备全量替换)+ 编号守卫
const edit = await api("POST", "/api/classroom/Edit", { json: { serID: room1, roomName: `冒烟教室${stamp}`, status: "closed", seatCount: 45, equipmentIds: [] } });
ok("classroom Edit(清空设备)", edit.code === 0, edit.msg);
const roomsAfterEdit = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } });
const room1After = roomsAfterEdit.data.list.find(r => r.serID === room1);
ok("Edit 后教室设备已清空", room1After && room1After.equipmentIds.length === 0);
const editQty = await api("POST", "/api/classroom/Edit", { json: { serID: room1, equipments: [{ equipmentId: eqSeedId, quantity: 3 }] } });
ok("classroom Edit equipments 按数量替换", editQty.code === 0, editQty.msg);
const roomsAfterQty = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } });
const room1Qty = roomsAfterQty.data.list.find(r => r.serID === room1);
ok("Edit 后设备数量为 3(名称×3)", room1Qty && room1Qty.equipmentQtys && room1Qty.equipmentQtys[eqSeedId] === 3 && /冒烟设备.*×3$/.test(room1Qty.equipmentNames), JSON.stringify(room1Qty && room1Qty.equipmentQtys));
const editClear = await api("POST", "/api/classroom/Edit", { json: { serID: room1, equipmentIds: [] } });
ok("classroom Edit 旧字段 equipmentIds 清空设备(兼容)", editClear.code === 0, editClear.msg);
const room2code = roomsAfterEdit.data.list[1].roomCode;
const editDup = await api("POST", "/api/classroom/Edit", { json: { serID: roomsAfterEdit.data.list[1].serID, roomCode: roomsAfterEdit.data.list[2].roomCode } });
ok("Edit 楼栋内编号重复拒绝", editDup.code !== 0);
// 8. 设备编辑 + 引用删除守卫(设备被教室引用时不可删)
const eqList = await api("GET", "/api/equipment/List");
ok("equipment List 含引用数与统计数量(5 间 × 每间 2 = 10)", eqList.code === 0 && eqList.data.list.some(e => e.serID === eqSeedId && e.classroomCount === 5 && e.quantity === 10), JSON.stringify(eqList.data && eqList.data.list.find(e => e.serID === eqSeedId)));
const eqEdit = await api("POST", "/api/equipment/Edit", { form: { serID: eqSeedId, equipmentName: `冒烟设备${stamp}` } });
ok("equipment Edit", eqEdit.code === 0, eqEdit.msg);
const eqDelGuarded = await api("POST", "/api/equipment/Delete", { form: { serid: eqSeedId } });
ok("equipment Delete 被引用守卫拒绝", eqDelGuarded.code !== 0);
// 9. 新增接口:列表排序 / 楼栋楼层分布 / 教室批量编辑
const sortedDesc = await api("GET", "/api/classroom/List", {
params: { buildingId, page: 1, count: 20, sortField: "seatCount", order: "desc" },
});
ok(
"List 排序 seatCount desc",
sortedDesc.code === 0 &&
sortedDesc.data.list[0].seatCount >= sortedDesc.data.list[sortedDesc.data.list.length - 1].seatCount,
JSON.stringify(sortedDesc.data && sortedDesc.data.list.map(r => r.seatCount))
);
const sortedFloorDesc = await api("GET", "/api/classroom/List", {
params: { buildingId, page: 1, count: 20, sortField: "floor", order: "desc" },
});
ok("List 排序 floor desc 首行为 2 层", sortedFloorDesc.code === 0 && sortedFloorDesc.data.list[0].floor === 2);
const defaultOrder = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } });
ok(
"List 不传排序参数保持默认序(楼层升序 + 编号升序)",
defaultOrder.code === 0 &&
defaultOrder.data.list[0].floor === 1 &&
defaultOrder.data.list[0].roomCode <= defaultOrder.data.list[1].roomCode
);
const bStatsFloors = await api("GET", "/api/building/Stats", { params: { campusId } });
const mineBuilding = bStatsFloors.data && bStatsFloors.data.find(b => b.serID === buildingId);
ok(
"building Stats 新增 floors 楼层分布(2 层,每层 3 间)",
mineBuilding &&
Array.isArray(mineBuilding.floors) &&
mineBuilding.floors.length === 2 &&
mineBuilding.floors[0].floor === 1 &&
mineBuilding.floors[0].total === 3 &&
mineBuilding.floors[1].floor === 2,
JSON.stringify(mineBuilding && mineBuilding.floors)
);
// 9.5 空间作用域查询(教室管理三栏布局):按校区 List + Tree 扁平数据
const byCampus = await api("GET", "/api/classroom/List", { params: { campusId, page: 1, count: 20 } });
ok(
"List 按校区作用域命中 6 间",
byCampus.code === 0 && byCampus.data.total === 6,
`total=${byCampus.data && byCampus.data.total}`
);
ok(
"List 行含校区/楼栋名称",
byCampus.data.list.every(r => r.campusName === campusName && r.buildingName === `冒烟楼栋${stamp}`),
JSON.stringify(byCampus.data.list[0])
);
const byCampusFloor = await api("GET", "/api/classroom/List", { params: { campusId, floor: 1, page: 1, count: 20 } });
ok("List 校区+楼层作用域命中 3 间", byCampusFloor.code === 0 && byCampusFloor.data.total === 3);
ok("List 校区/楼栋均缺拒绝", (await api("GET", "/api/classroom/List", { params: { page: 1, count: 20 } })).code !== 0);
const treeRows = await api("GET", "/api/classroom/Tree");
const myTreeRows = treeRows.data ? treeRows.data.filter(r => r.buildingId === buildingId) : [];
ok(
"Tree 扁平数据含本楼栋 6 间(轻量字段)",
treeRows.code === 0 && myTreeRows.length === 6 && myTreeRows.every(r => r.campusId === campusId && r.roomCode && r.status),
JSON.stringify(myTreeRows[0])
);
// 9.7 绑定班级:班级数据源=班级管理平台接口(bschclass,本应用不落库、无 Add/Delete)
// → BindClass/UnbindClass/BatchUnbind → 年级 Tab 过滤
// 前置:dev 身份 schId=3(云海中学)在平台须有高一/高二班级
const clsListAll = await api("GET", "/api/class/List");
ok("class List code=0(平台班级)", clsListAll.code === 0 && Array.isArray(clsListAll.data), clsListAll.msg);
ok(
"class List 仅含高中学段(gradeId 10/11/12 之外的学段被过滤)",
clsListAll.code === 0 && (clsListAll.data || []).every(c => [1, 2, 3].includes(c.grade)),
JSON.stringify(clsListAll.data && clsListAll.data.find(c => ![1, 2, 3].includes(c.grade)))
);
const hs = (clsListAll.data || []).filter(c => c.grade === 1 || c.grade === 2);
const clsA = hs.find(c => c.grade === 1 && c.classroomId == null);
const clsB = hs.find(c => c.grade === 2 && c.classroomId == null);
ok(
"平台班级数据可用(高一/高二各有未绑定班级)",
!!clsA && !!clsB,
JSON.stringify(hs && hs.map(c => `${c.serID}:${c.className}:room${c.classroomId}`))
);
ok(
"class List 行含 serID/className/studentCount(可空)/headTeacher",
clsA && clsA.serID > 0 && !!clsA.className && (clsA.studentCount == null || Number.isInteger(clsA.studentCount)),
JSON.stringify(clsA)
);
const clsListG1 = await api("GET", "/api/class/List", { params: { grade: 1 } });
ok(
"class List grade=1 只含高一且新班级未绑定(classroomId=null)",
clsListG1.code === 0 && (clsListG1.data || []).length > 0 && (clsListG1.data || []).every(c => c.grade === 1)
&& (clsListG1.data || []).find(c => c.serID === clsA.serID)?.classroomId == null,
JSON.stringify(clsListG1.data && clsListG1.data.length)
);
const bind = await api("POST", "/api/classroom/BindClass", { json: { serID: room1, classId: clsA.serID } });
ok("classroom BindClass 返回班级名(平台展示名)", bind.code === 0 && bind.data && bind.data.className === clsA.className, bind.msg);
const bindDup = await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[1].serID, classId: clsA.serID } });
ok("BindClass 班级已被其他教室占用拒绝", bindDup.code !== 0);
ok("BindClass 缺参拒绝", (await api("POST", "/api/classroom/BindClass", { json: { serID: room1 } })).code !== 0);
ok(
"BindClass 班级不在平台列表拒绝",
(await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[2].serID, classId: 999999999 } })).code !== 0
);
const roomsG1 = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 1, page: 1, count: 20 } });
const boundRow = roomsG1.data ? roomsG1.data.list.find(r => r.serID === room1) : null;
ok(
"List grade=1 含已绑教室且行含班级信息(名/人数/班主任同平台源)",
roomsG1.code === 0 && roomsG1.data.total === 6 && boundRow && boundRow.classId === clsA.serID &&
boundRow.className === clsA.className && (boundRow.studentCount ?? null) === (clsA.studentCount ?? null) &&
boundRow.headTeacher === clsA.headTeacher,
JSON.stringify(boundRow)
);
const roomsG2 = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 2, page: 1, count: 20 } });
ok(
"List grade=2 排除高一已绑定教室(6-1=5,其余未绑定)",
roomsG2.code === 0 && roomsG2.data.total === 5 && roomsG2.data.list.every(r => r.serID !== room1 && r.classId == null),
`total=${roomsG2.data && roomsG2.data.total}`
);
const roomsBoundOnly = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 1, bindStatus: "bound", page: 1, count: 20 } });
ok("List bindStatus=bound 只含已绑 1 间", roomsBoundOnly.code === 0 && roomsBoundOnly.data.total === 1);
ok("classroom UnbindClass", (await api("POST", "/api/classroom/UnbindClass", { form: { serid: room1 } })).code === 0);
ok("UnbindClass 未绑定教室静默成功", (await api("POST", "/api/classroom/UnbindClass", { form: { serid: room1 } })).code === 0);
// 批量绑定两间 → 批量解绑 3 间(含 1 间未绑定 → skipped);班级属平台数据,解绑即可、无需清理
await api("POST", "/api/classroom/BindClass", { json: { serID: room1, classId: clsA.serID } });
await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[1].serID, classId: clsB.serID } });
const bUnbind = await api("POST", "/api/classroom/BatchUnbind", { json: [room1, rooms.data.list[1].serID, rooms.data.list[2].serID] });
ok(
"BatchUnbind 3 间(updated=2, skipped=1)",
bUnbind.code === 0 && bUnbind.data.updated === 2 && bUnbind.data.skipped === 1,
JSON.stringify(bUnbind.data)
);
ok("BatchUnbind 空集合拒绝", (await api("POST", "/api/classroom/BatchUnbind", { json: [] })).code !== 0);
// 菜单兜底(仅本地免 SSO 模式):绑定班级在第二位
if (!COOKIE) {
const modsTree = await api("GET", "/api/base/GetUserMod");
const names = (modsTree.data || []).map(m => m.name);
ok(
"GetUserMod 菜单序 教室管理→绑定班级→基础设置",
modsTree.code === 0 && JSON.stringify(names) === JSON.stringify(["教室管理", "绑定班级", "基础设置"]),
JSON.stringify(names)
);
}
const allIds = roomsAfterEdit.data.list.map(r => r.serID);
const bEditStatus = await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: allIds, status: "closed" } });
ok("classroom BatchEdit 批量关闭 6 间", bEditStatus.code === 0 && bEditStatus.data.updated === 6, bEditStatus.msg);
const afterBatch = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20, status: "closed" } });
ok("BatchEdit 后按 closed 过滤命中 6 间", afterBatch.code === 0 && afterBatch.data.total === 6);
const bEditType = await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: allIds.slice(0, 2), typeId } });
ok("classroom BatchEdit 批量改类型 2 间", bEditType.code === 0 && bEditType.data.updated === 2, bEditType.msg);
ok("BatchEdit 空集合拒绝", (await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [] } })).code !== 0);
ok("BatchEdit 未指定修改项拒绝", (await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [room1] } })).code !== 0);
ok(
"BatchEdit 非法状态拒绝",
(await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [room1], status: "xxx" } })).code !== 0
);
const bEditSkip = await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [room1, 99999999], status: "open" } });
ok(
"BatchEdit 不存在 id 计入 skipped(不整批失败)",
bEditSkip.code === 0 && bEditSkip.data.updated === 1 && bEditSkip.data.skipped === 1,
JSON.stringify(bEditSkip.data)
);
// 10. 其余删除守卫(楼栋有教室 → 类型被引用 → 校区有楼栋)
const delBuilding = await api("POST", "/api/building/Delete", { form: { serid: buildingId } });
ok("building Delete 守卫拒绝", delBuilding.code !== 0);
const delType = await api("POST", "/api/classtype/Delete", { form: { serid: typeId } });
ok("classtype Delete 守卫拒绝", delType.code !== 0);
const delCampus = await api("POST", "/api/campus/Delete", { form: { serid: campusId } });
ok("campus Delete 守卫拒绝", delCampus.code !== 0);
// 11. 逆序清理:教室(关联随删)→ 楼栋 → 类型 → 设备 → 校区
const delRooms = await api("POST", "/api/classroom/Delete", { json: allIds });
ok("classroom Delete 全部(关联自动清理)", delRooms.code === 0, delRooms.msg);
ok("equipment Delete(解除引用后可删)", (await api("POST", "/api/equipment/Delete", { form: { serid: eqSeedId } })).code === 0);
ok("building Delete", (await api("POST", "/api/building/Delete", { form: { serid: buildingId } })).code === 0);
ok("classtype Delete", (await api("POST", "/api/classtype/Delete", { form: { serid: typeId } })).code === 0);
ok("campus Delete", (await api("POST", "/api/campus/Delete", { form: { serid: campusId } })).code === 0);
console.log(`\n[smoke] 通过 ${passed} / ${passed + failed}`);
process.exit(failed ? 1 : 0);