Files
ClassHelper/packages/server/scripts/verify-e2e.mjs
T

537 lines
21 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.
#!/usr/bin/env node
/**
* 端到端联调验证(对应验收标准):
* 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到
* 2. 教师发布作业 -> 学生端实时收到,并可标记完成
* 3. 教师录入成绩 -> 学生端实时收到,且 /grades/my 可查
* 4. 权限隔离:学生无法访问其他班级数据、无法调用教师接口
* 5. 课表按周查询可用
*
* 用法:
* 1) 另开终端启动后端:pnpm dev:server
* 2) 执行:pnpm verify:e2e
*
* 环境变量:
* VERIFY_BASE_URL 默认 http://127.0.0.1:4000
*/
import { io } from 'socket.io-client';
const BASE_URL = process.env.VERIFY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`;
const TIME_LIMIT_MS = 5000;
const results = [];
function record(name, ok, detail = '') {
results.push({ name, ok, detail });
const mark = ok ? '\u2713' : '\u2717';
console.log(`${mark} ${name}${detail ? ` (${detail})` : ''}`);
}
async function api(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${BASE_URL}/api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
return { status: response.status, payload };
}
function waitForEvent(socket, event, timeoutMs = TIME_LIMIT_MS) {
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`等待事件 ${event} 超时(${timeoutMs}ms)`)), timeoutMs);
socket.once(event, (payload) => {
clearTimeout(timer);
resolve({ payload, elapsed: Date.now() - startedAt });
});
});
}
async function main() {
console.log(`\n=== 班级小助手 端到端验证(${BASE_URL})===\n`);
// ---------------------------------------------------------------- 0. 健康检查
const health = await api('/health');
record(
'后端健康检查 /api/health',
health.status === 200 && health.payload?.data?.status === 'ok',
`status=${health.status} db=${health.payload?.data?.database?.connected}`,
);
if (health.status !== 200) {
console.log('\n后端未启动,请先运行 pnpm dev:server\n');
process.exit(1);
}
console.log(` 已挂载模块:${(health.payload?.data?.modules ?? []).join(', ')}\n`);
// ---------------------------------------------------------------- 1. 登录
const teacherLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher1', password: 'teacher123' },
});
const teacherToken = teacherLogin.payload?.data?.token;
record('教师登录 teacher1', Boolean(teacherToken), `status=${teacherLogin.status}`);
if (!teacherToken) process.exit(1);
const studentLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'student01', password: 'student123' },
});
const studentToken = studentLogin.payload?.data?.token;
const studentUser = studentLogin.payload?.data?.user;
record('学生登录 student01', Boolean(studentToken), `classId=${studentUser?.classId}`);
const wrongPassword = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher1', password: 'bad-password' },
});
record('错误密码被拒绝(401)', wrongPassword.status === 401, `status=${wrongPassword.status}`);
// ---------------------------------------------------------------- 2. 数据准备
const myClasses = await api('/classes', { token: teacherToken });
const classes = myClasses.payload?.data ?? [];
record('教师获取班级列表', classes.length >= 2, `共 ${classes.length} 个班级`);
const ownClass = classes.find((item) => item.name?.includes('高一(1)')) ?? classes[0];
const classId = ownClass?.id;
if (!classId) {
record('教师至少管理一个班级', false, '数据为空,请先执行 pnpm db:seed');
process.exit(1);
}
// teacher2 名下但 teacher1 无权访问的班级,用于验证教师间权限隔离
const teacher2Login = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: 'teacher123' },
});
const teacher2Token = teacher2Login.payload?.data?.token;
const teacher1ClassIds = new Set(classes.map((item) => item.id));
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
const foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
record(
'教师权限隔离测试数据就绪',
Boolean(foreignClass),
foreignClass ? `外部班级:${foreignClass.name}` : '未找到其它教师的班级',
);
const studentList = await api(`/students?classId=${classId}`, { token: teacherToken });
const classmates = studentList.payload?.data ?? [];
const targetStudent = classmates.find((item) => item.id === studentUser?.id) ?? classmates[0];
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
const courseId = courses.payload?.data?.[0]?.id ?? null;
record('教师获取课程列表', Boolean(courseId), `course=${courses.payload?.data?.[0]?.name ?? '-'}`);
const weekGrid = await api(`/schedules/grid?classId=${classId}&week=1`, { token: studentToken });
const totalSlots = (weekGrid.payload?.data?.columns ?? []).reduce((sum, col) => sum + col.items.length, 0);
record(
'学生按周查看课表(week=1)',
weekGrid.status === 200 && totalSlots > 0,
`第 1 周 ${totalSlots} 节课`,
);
// ---------------------------------------------------------------- 3. WebSocket 连接
const socket = io(BASE_URL, {
auth: { token: studentToken, clientType: 'desktop' },
transports: ['websocket'],
reconnection: false,
});
const connected = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(null), TIME_LIMIT_MS);
socket.on('connected', (payload) => {
clearTimeout(timer);
resolve(payload);
});
socket.on('connect_error', (error) => {
clearTimeout(timer);
resolve({ error: error.message });
});
});
record(
'学生端 Socket.IO 连接与鉴权',
Boolean(connected && !connected.error),
connected?.error ?? `rooms=${connected?.rooms?.length ?? 0}`,
);
// ---------------------------------------------------------------- 4. 通知实时推送
const notificationWait = waitForEvent(socket, 'notification:new');
const createdNotification = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: {
classId,
title: `联调验证通知 ${new Date().toISOString()}`,
content: '这是端到端验证脚本自动创建的通知。',
priority: 'HIGH',
},
});
record('教师发布通知', createdNotification.status === 201, `status=${createdNotification.status}`);
try {
const { payload, elapsed } = await notificationWait;
record('学生端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) {
record('学生端 5 秒内收到 notification:new', false, error.message);
}
// ---------------------------------------------------------------- 5. 作业实时推送 + 标记完成
const homeworkWait = waitForEvent(socket, 'homework:new');
const createdHomework = await api('/homeworks', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId,
title: `联调验证作业 ${new Date().toISOString()}`,
content: '请完成验证脚本创建的作业。',
dueAt: new Date(Date.now() + 86_400_000).toISOString(),
},
});
const homeworkId = createdHomework.payload?.data?.id;
record('教师发布作业', createdHomework.status === 201, `status=${createdHomework.status}`);
try {
const { payload, elapsed } = await homeworkWait;
record('学生端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) {
record('学生端 5 秒内收到 homework:new', false, error.message);
}
const statusUpdate = await api(`/homeworks/${homeworkId}/status`, {
method: 'PATCH',
token: studentToken,
body: { completed: true },
});
record(
'学生标记作业完成',
statusUpdate.status === 200 && statusUpdate.payload?.data?.completed === true,
`status=${statusUpdate.status}`,
);
const myHomeworks = await api(`/homeworks?classId=${classId}`, { token: studentToken });
const markedHomework = (myHomeworks.payload?.data ?? []).find((item) => item.id === homeworkId);
record(
'学生作业列表回显完成状态',
markedHomework?.completed === true,
`completed=${markedHomework?.completed}`,
);
// ---------------------------------------------------------------- 6. 成绩实时推送
const gradeWait = waitForEvent(socket, 'grade:updated');
const createdGrade = await api('/grades', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId,
userId: targetStudent?.id,
examName: `联调验证考试 ${new Date().toISOString().slice(0, 10)}`,
score: 88,
totalScore: 100,
},
});
record('教师录入成绩', createdGrade.status === 201, `status=${createdGrade.status}`);
if (targetStudent?.id === studentUser?.id) {
try {
const { payload, elapsed } = await gradeWait;
record(
'学生端 5 秒内收到 grade:updated',
elapsed < TIME_LIMIT_MS,
`${elapsed}ms · ${payload?.score}/${payload?.totalScore}`,
);
} catch (error) {
record('学生端 5 秒内收到 grade:updated', false, error.message);
}
} else {
gradeWait.catch(() => {});
record('学生端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
}
const myGrades = await api('/grades/my', { token: studentToken });
record(
'学生查看个人成绩 /grades/my',
(myGrades.payload?.data ?? []).length > 0,
`共 ${myGrades.payload?.data?.length ?? 0} 条`,
);
// ---------------------------------------------------------------- 6.1 Web 管理端依赖的接口全覆盖
const classDetail = await api(`/classes/${classId}`, { token: teacherToken });
record(
'班级详情(学生/课程/协作教师)',
classDetail.status === 200 &&
Array.isArray(classDetail.payload?.data?.students) &&
Array.isArray(classDetail.payload?.data?.courses),
`学生 ${classDetail.payload?.data?.students?.length ?? 0} 人 · 课程 ${classDetail.payload?.data?.courses?.length ?? 0} 门`,
);
const teacherList = await api('/teachers', { token: teacherToken });
record(
'教师列表 /teachers',
(teacherList.payload?.data ?? []).length >= 2,
`共 ${teacherList.payload?.data?.length ?? 0} 人`,
);
const summary = await api('/dashboard/summary', { token: teacherToken });
record(
'仪表盘汇总 /dashboard/summary',
summary.status === 200 && (summary.payload?.data?.classCount ?? 0) > 0,
`班级 ${summary.payload?.data?.classCount ?? 0} · 学生 ${summary.payload?.data?.studentCount ?? 0}`,
);
const term = await api('/dashboard/term', { token: teacherToken });
record(
'学期信息 /dashboard/term',
term.status === 200 && (term.payload?.data?.currentWeek ?? 0) > 0,
`当前第 ${term.payload?.data?.currentWeek} 周`,
);
const createdCourse = await api('/courses', {
method: 'POST',
token: teacherToken,
body: { name: `联调验证课程 ${Date.now()}`, classId },
});
const createdCourseId = createdCourse.payload?.data?.id;
record('新增课程 POST /courses', createdCourse.status === 201, `status=${createdCourse.status}`);
if (createdCourseId) {
const removedCourse = await api(`/courses/${createdCourseId}`, { method: 'DELETE', token: teacherToken });
record('删除课程 DELETE /courses/:id', removedCourse.status === 200, `status=${removedCourse.status}`);
}
const assigned = await api(`/classes/${classId}/teachers`, {
method: 'POST',
token: teacherToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
});
record('分配协作教师', assigned.status === 201, `status=${assigned.status}`);
const unassigned = await api(`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, {
method: 'DELETE',
token: teacherToken,
});
record('取消协作教师', unassigned.status === 200, `status=${unassigned.status}`);
const tempUsername = `e2e_student_${Date.now()}`;
const addedStudent = await api(`/classes/${classId}/students`, {
method: 'POST',
token: teacherToken,
body: { username: tempUsername, name: '联调学生' },
});
const addedStudentId = addedStudent.payload?.data?.id;
record('班级添加学生', addedStudent.status === 201, `status=${addedStudent.status}`);
if (addedStudentId) {
const studentListAfter = await api(`/classes/${classId}/students`, { token: teacherToken });
record(
'学生名单包含新加入学生',
(studentListAfter.payload?.data ?? []).some((item) => item.id === addedStudentId),
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
);
const resetResult = await api(`/students/${addedStudentId}/reset-password`, {
method: 'POST',
token: teacherToken,
body: {},
});
record('重置学生密码', resetResult.status === 200, `status=${resetResult.status}`);
const renamed = await api(`/students/${addedStudentId}`, {
method: 'PATCH',
token: teacherToken,
body: { name: '联调学生(已改名)' },
});
record(
'修改学生信息',
renamed.status === 200 && renamed.payload?.data?.name?.includes('已改名'),
`name=${renamed.payload?.data?.name}`,
);
const removedStudent = await api(`/classes/${classId}/students/${addedStudentId}`, {
method: 'DELETE',
token: teacherToken,
});
record('班级移出学生', removedStudent.status === 200, `status=${removedStudent.status}`);
// 移出后学生已无班级,按权限规则只有管理员可以删除该账号
const teacherDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: teacherToken });
record('教师删除未分班学生被拒绝(403)', teacherDelete.status === 403, `status=${teacherDelete.status}`);
const adminLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'admin', password: 'admin123' },
});
const adminToken = adminLogin.payload?.data?.token;
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
const adminDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: adminToken });
record('管理员删除学生账号', adminDelete.status === 200, `status=${adminDelete.status}`);
}
const bulkResult = await api('/grades/bulk', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId,
examName: `联调批量考试 ${new Date().toISOString().slice(0, 10)}`,
totalScore: 100,
items: classmates.slice(0, 3).map((item, index) => ({ userId: item.id, score: 70 + index * 5 })),
},
});
record(
'批量录入成绩 POST /grades/bulk',
bulkResult.status === 201,
`录入 ${bulkResult.payload?.data?.count ?? 0} 条`,
);
const gradeStats = await api(`/grades/stats?classId=${classId}`, { token: teacherToken });
record(
'成绩统计 /grades/stats',
gradeStats.status === 200 && (gradeStats.payload?.data?.distribution ?? []).length === 5,
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}%`,
);
const unread = await api('/notifications/unread-count', { token: studentToken });
record(
'未读数 /notifications/unread-count',
unread.status === 200,
`未读 ${unread.payload?.data?.count ?? 0} 条`,
);
const markedRead = await api(`/notifications/${createdNotification.payload?.data?.id}/read`, {
method: 'POST',
token: studentToken,
});
record('标记通知已读', markedRead.status === 200, `status=${markedRead.status}`);
const readAll = await api('/notifications/read-all', { method: 'POST', token: studentToken, body: {} });
record('全部标为已读', readAll.status === 200, `本次标记 ${readAll.payload?.data?.marked ?? 0} 条`);
const pendingOnly = await api('/homeworks?pendingOnly=true', { token: studentToken });
const allPending = (pendingOnly.payload?.data ?? []).every((item) => item.completed !== true);
record(
'学生查询未完成作业(pendingOnly)',
pendingOnly.status === 200 && allPending,
`共 ${pendingOnly.payload?.data?.length ?? 0} 条`,
);
const homeworkDetail = await api(`/homeworks/${homeworkId}`, { token: studentToken });
record('作业详情 GET /homeworks/:id', homeworkDetail.status === 200, `status=${homeworkDetail.status}`);
const updatedGrade = await api(`/grades/${createdGrade.payload?.data?.id}`, {
method: 'PATCH',
token: teacherToken,
body: { score: 95 },
});
record(
'修改成绩 PATCH /grades/:id',
updatedGrade.status === 200 && updatedGrade.payload?.data?.score === 95,
`score=${updatedGrade.payload?.data?.score}`,
);
const dashboardForStudent = await api('/dashboard/summary', { token: studentToken });
record(
'学生仪表盘汇总',
dashboardForStudent.status === 200 && (dashboardForStudent.payload?.data?.classCount ?? 0) === 1,
`可见班级 ${dashboardForStudent.payload?.data?.classCount ?? 0} 个`,
);
// ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
record(
'学生访问其他班级被拒绝(403)',
studentCrossClass.status === 403,
`status=${studentCrossClass.status}`,
);
const studentCrossHomework = await api(`/homeworks?classId=${foreignClass.id}`, { token: studentToken });
record(
'学生跨班查询作业被拒绝(403)',
studentCrossHomework.status === 403,
`status=${studentCrossHomework.status}`,
);
const studentCrossSchedule = await api(`/schedules?classId=${foreignClass.id}`, { token: studentToken });
record(
'学生跨班查询课表被拒绝(403)',
studentCrossSchedule.status === 403,
`status=${studentCrossSchedule.status}`,
);
const teacherCrossClass = await api(`/classes/${foreignClass.id}`, { token: teacherToken });
record(
'教师访问他人班级被拒绝(403)',
teacherCrossClass.status === 403,
`status=${teacherCrossClass.status}`,
);
const teacherCrossHomework = await api(`/homeworks?classId=${foreignClass.id}`, { token: teacherToken });
record(
'教师跨班发布/查询被拒绝(403)',
teacherCrossHomework.status === 403,
`status=${teacherCrossHomework.status}`,
);
const teacherCrossPublish = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId: foreignClass.id, title: '跨班发布', content: '不应成功' },
});
record(
'教师向他人班级发布通知被拒绝(403)',
teacherCrossPublish.status === 403,
`status=${teacherCrossPublish.status}`,
);
}
const studentPublish = await api('/notifications', {
method: 'POST',
token: studentToken,
body: { classId, title: '学生越权发布', content: '不应成功' },
});
record('学生调用教师接口被拒绝(403)', studentPublish.status === 403, `status=${studentPublish.status}`);
const anonymous = await api('/classes');
record('未登录访问被拒绝(401)', anonymous.status === 401, `status=${anonymous.status}`);
// 学生只能看到自己班级:作用域内所有返回项都应属于自己班级
const studentClasses = await api('/classes', { token: studentToken });
const studentVisible = studentClasses.payload?.data ?? [];
record(
'学生班级列表仅含自己所在班级',
studentVisible.length === 1 && studentVisible[0]?.id === studentUser?.classId,
`共 ${studentVisible.length} 个`,
);
const studentHomeworkAll = await api('/homeworks', { token: studentToken });
const homeworkClassIds = new Set((studentHomeworkAll.payload?.data ?? []).map((item) => item.classId));
record(
'学生作业列表已按班级收敛',
homeworkClassIds.size <= 1 && (homeworkClassIds.size === 0 || homeworkClassIds.has(studentUser?.classId)),
`涉及 ${homeworkClassIds.size} 个班级`,
);
socket.close();
// ---------------------------------------------------------------- 汇总
const passed = results.filter((item) => item.ok).length;
const failed = results.filter((item) => !item.ok);
console.log(`\n=== 结果:${passed}/${results.length} 项通过 ===`);
if (failed.length > 0) {
console.log('失败项:');
for (const item of failed) console.log(` - ${item.name} (${item.detail})`);
process.exit(1);
}
console.log('全部验收项通过 \u2713\n');
}
main().catch((error) => {
console.error('\n验证脚本执行失败:', error);
process.exit(1);
});