Files
NextEdu/tests/integration/homework-actions.test.ts
SpecialX 125f7ec54c
Some checks failed
CI / build-deploy (push) Has been cancelled
refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验
- UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内
- 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过)
- 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007)
- 项目规则: 架构图优先规则,改码必同步图
- 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫
- 无障碍: skip-link、aria-label、prefers-reduced-motion
- 性能: next/font优化、next/image、代码分割
2026-06-16 23:38:33 +08:00

305 lines
10 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const requirePermissionMock = vi.fn()
const revalidatePathMock = vi.fn()
const createIdMock = vi.fn()
const ensureLimitMock = vi.fn()
const ensureWhereMock = vi.fn(() => ({ limit: ensureLimitMock }))
const ensureInnerJoinSecondMock = vi.fn(() => ({ where: ensureWhereMock }))
const ensureInnerJoinFirstMock = vi.fn(() => ({ innerJoin: ensureInnerJoinSecondMock }))
const countWhereMock = vi.fn()
const selectFromMock = vi.fn(() => ({ innerJoin: ensureInnerJoinFirstMock, where: countWhereMock }))
const selectMock = vi.fn(() => ({ from: selectFromMock }))
const assignmentFindFirstMock = vi.fn()
const assignmentTargetFindFirstMock = vi.fn()
const submissionFindFirstMock = vi.fn()
const txAnswerFindFirstMock = vi.fn()
const insertValuesMock = vi.fn()
const insertMock = vi.fn(() => ({ values: insertValuesMock }))
const txInsertValuesMock = vi.fn()
const txInsertMock = vi.fn(() => ({ values: txInsertValuesMock }))
const updateWhereMock = vi.fn()
const updateSetMock = vi.fn(() => ({ where: updateWhereMock }))
const updateMock = vi.fn(() => ({ set: updateSetMock }))
const txUpdateWhereMock = vi.fn()
const txUpdateSetMock = vi.fn(() => ({ where: txUpdateWhereMock }))
const txUpdateMock = vi.fn(() => ({ set: txUpdateSetMock }))
const transactionMock = vi.fn(async (callback: (tx: unknown) => Promise<unknown>) =>
callback({
query: { homeworkAnswers: { findFirst: txAnswerFindFirstMock } },
update: txUpdateMock,
insert: txInsertMock,
})
)
return {
requirePermissionMock,
revalidatePathMock,
createIdMock,
ensureLimitMock,
countWhereMock,
selectMock,
assignmentFindFirstMock,
assignmentTargetFindFirstMock,
submissionFindFirstMock,
txAnswerFindFirstMock,
insertValuesMock,
insertMock,
txInsertValuesMock,
txInsertMock,
updateSetMock,
updateMock,
txUpdateSetMock,
txUpdateMock,
transactionMock,
}
})
vi.mock("@/shared/lib/auth-guard", () => ({
requirePermission: mocks.requirePermissionMock,
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(permission: string) {
super(`Permission denied: ${permission}`)
this.name = "PermissionDeniedError"
}
},
}))
vi.mock("next/cache", () => ({
revalidatePath: mocks.revalidatePathMock,
}))
vi.mock("@paralleldrive/cuid2", () => ({
createId: mocks.createIdMock,
}))
vi.mock("@/shared/db", () => ({
db: {
select: mocks.selectMock,
query: {
homeworkAssignments: { findFirst: mocks.assignmentFindFirstMock },
homeworkAssignmentTargets: { findFirst: mocks.assignmentTargetFindFirstMock },
homeworkSubmissions: { findFirst: mocks.submissionFindFirstMock },
},
insert: mocks.insertMock,
update: mocks.updateMock,
transaction: mocks.transactionMock,
},
}))
vi.mock("@/shared/db/schema", () => ({
classes: { id: "id", teacherId: "teacherId" },
classEnrollments: { classId: "classId", studentId: "studentId", status: "status" },
classSubjectTeachers: { classId: "classId", teacherId: "teacherId", subjectId: "subjectId" },
exams: { id: "id" },
homeworkAnswers: { id: "id", submissionId: "submissionId", questionId: "questionId" },
homeworkAssignmentQuestions: { assignmentId: "assignmentId" },
homeworkAssignmentTargets: { assignmentId: "assignmentId", studentId: "studentId" },
homeworkAssignments: { id: "id", status: "status" },
homeworkSubmissions: {
id: "id",
assignmentId: "assignmentId",
studentId: "studentId",
status: "status",
submittedAt: "submittedAt",
isLate: "isLate",
updatedAt: "updatedAt",
score: "score",
},
}))
import {
gradeHomeworkSubmissionAction,
saveHomeworkAnswerAction,
startHomeworkSubmissionAction,
submitHomeworkAction,
} from "@/modules/homework/actions"
function studentCtx(userId = "u_student") {
return {
userId,
roles: ["student"],
permissions: ["homework:submit"],
dataScope: { type: "class_members" as const },
}
}
function teacherCtx(userId = "u_teacher") {
return {
userId,
roles: ["teacher"],
permissions: ["homework:grade"],
dataScope: { type: "class_taught" as const, classIds: [] },
}
}
describe("homework action flow", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("starts submission for assigned student", async () => {
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.assignmentFindFirstMock.mockResolvedValue({
id: "a_1",
status: "published",
availableAt: null,
maxAttempts: 2,
})
mocks.assignmentTargetFindFirstMock.mockResolvedValue({ assignmentId: "a_1", studentId: "u_student" })
mocks.countWhereMock.mockResolvedValue([{ c: 0 }])
mocks.createIdMock.mockReturnValue("sub_1")
const formData = new FormData()
formData.set("assignmentId", "a_1")
const result = await startHomeworkSubmissionAction(null, formData)
expect(result).toEqual({ success: true, message: "Started", data: "sub_1" })
expect(mocks.insertValuesMock).toHaveBeenCalledWith(
expect.objectContaining({
id: "sub_1",
assignmentId: "a_1",
studentId: "u_student",
attemptNo: 1,
status: "started",
})
)
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/student/learning/assignments")
})
it("blocks submission when assignment is past due", async () => {
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_1",
studentId: "u_student",
status: "started",
assignment: {
dueAt: new Date(Date.now() - 60_000),
allowLate: false,
lateDueAt: null,
},
})
const formData = new FormData()
formData.set("submissionId", "sub_1")
const result = await submitHomeworkAction(null, formData)
expect(result).toEqual({ success: false, message: "Past due" })
})
it("submits started homework before due time", async () => {
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_2",
studentId: "u_student",
status: "started",
assignment: {
dueAt: new Date(Date.now() + 60_000),
allowLate: false,
lateDueAt: null,
},
})
const formData = new FormData()
formData.set("submissionId", "sub_2")
const result = await submitHomeworkAction(null, formData)
expect(result).toEqual({ success: true, message: "Submitted", data: "sub_2" })
expect(mocks.updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: "submitted", isLate: false }))
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/teacher/homework/submissions")
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/student/learning/assignments")
})
it("blocks start when attempts are exhausted", async () => {
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.assignmentFindFirstMock.mockResolvedValue({
id: "a_2",
status: "published",
availableAt: null,
maxAttempts: 1,
})
mocks.assignmentTargetFindFirstMock.mockResolvedValue({ assignmentId: "a_2", studentId: "u_student" })
mocks.countWhereMock.mockResolvedValue([{ c: 1 }])
const formData = new FormData()
formData.set("assignmentId", "a_2")
const result = await startHomeworkSubmissionAction(null, formData)
expect(result).toEqual({ success: false, message: "No attempts left" })
})
it("grades submission and writes total score", async () => {
mocks.requirePermissionMock.mockResolvedValue(teacherCtx())
const formData = new FormData()
formData.set("submissionId", "sub_1")
formData.set(
"answersJson",
JSON.stringify([
{ id: "ans_1", score: 5, feedback: "good" },
{ id: "ans_2", score: 3, feedback: "" },
])
)
const result = await gradeHomeworkSubmissionAction(null, formData)
expect(result).toEqual({ success: true, message: "Grading saved" })
expect(mocks.updateMock).toHaveBeenCalledTimes(3)
expect(mocks.updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ score: 8, status: "graded" }))
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/teacher/homework/submissions")
})
it("saves new answer for started submission", async () => {
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_3",
studentId: "u_student",
status: "started",
assignment: {},
})
mocks.txAnswerFindFirstMock.mockResolvedValue(null)
mocks.createIdMock.mockReturnValue("ans_new")
const formData = new FormData()
formData.set("submissionId", "sub_3")
formData.set("questionId", "q_3")
formData.set("answerJson", JSON.stringify({ text: "answer content" }))
const result = await saveHomeworkAnswerAction(null, formData)
expect(result).toEqual({ success: true, message: "Saved", data: "sub_3" })
expect(mocks.txInsertValuesMock).toHaveBeenCalledWith(
expect.objectContaining({
id: "ans_new",
submissionId: "sub_3",
questionId: "q_3",
})
)
})
it("updates existing answer for started submission", async () => {
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_4",
studentId: "u_student",
status: "started",
assignment: {},
})
mocks.txAnswerFindFirstMock.mockResolvedValue({ id: "ans_existing" })
const formData = new FormData()
formData.set("submissionId", "sub_4")
formData.set("questionId", "q_4")
formData.set("answerJson", JSON.stringify({ text: "updated answer" }))
const result = await saveHomeworkAnswerAction(null, formData)
expect(result).toEqual({ success: true, message: "Saved", data: "sub_4" })
expect(mocks.txUpdateMock).toHaveBeenCalledTimes(1)
expect(mocks.txUpdateSetMock).toHaveBeenCalledWith(expect.objectContaining({ answerContent: { text: "updated answer" } }))
})
})