Some checks failed
CI / build-deploy (push) Has been cancelled
- 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、代码分割
318 lines
11 KiB
TypeScript
318 lines
11 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 classLimitMock = vi.fn()
|
|
const enrollmentWhereMock = vi.fn()
|
|
const subjectTeacherWhereMock = vi.fn()
|
|
const examFindFirstMock = vi.fn()
|
|
|
|
const txInsertValuesMock = vi.fn()
|
|
const txInsertMock = vi.fn(() => ({ values: txInsertValuesMock }))
|
|
const transactionMock = vi.fn(async (callback: (tx: { insert: typeof txInsertMock }) => unknown) =>
|
|
callback({ insert: txInsertMock })
|
|
)
|
|
|
|
const schema = {
|
|
classes: { id: "id", teacherId: "teacherId" },
|
|
classEnrollments: { classId: "classId", studentId: "studentId", status: "status" },
|
|
classSubjectTeachers: { classId: "classId", teacherId: "teacherId", subjectId: "subjectId" },
|
|
exams: { id: "id", subjectId: "subjectId", title: "title", structure: "structure" },
|
|
homeworkAnswers: { id: "id" },
|
|
homeworkAssignmentQuestions: { assignmentId: "assignmentId", questionId: "questionId" },
|
|
homeworkAssignmentTargets: { assignmentId: "assignmentId", studentId: "studentId" },
|
|
homeworkAssignments: { id: "id" },
|
|
homeworkSubmissions: { id: "id" },
|
|
}
|
|
|
|
return {
|
|
requirePermissionMock,
|
|
revalidatePathMock,
|
|
createIdMock,
|
|
ensureLimitMock,
|
|
classLimitMock,
|
|
enrollmentWhereMock,
|
|
subjectTeacherWhereMock,
|
|
examFindFirstMock,
|
|
txInsertValuesMock,
|
|
txInsertMock,
|
|
transactionMock,
|
|
schema,
|
|
}
|
|
})
|
|
|
|
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/schema", () => mocks.schema)
|
|
|
|
vi.mock("@/shared/db", () => ({
|
|
db: {
|
|
select: () => ({
|
|
from: (table: unknown) => {
|
|
if (table === mocks.schema.users) {
|
|
return {
|
|
innerJoin: () => ({
|
|
innerJoin: () => ({
|
|
where: () => ({
|
|
limit: mocks.ensureLimitMock,
|
|
}),
|
|
}),
|
|
}),
|
|
}
|
|
}
|
|
|
|
if (table === mocks.schema.classes) {
|
|
return {
|
|
where: () => ({
|
|
limit: mocks.classLimitMock,
|
|
}),
|
|
}
|
|
}
|
|
|
|
if (table === mocks.schema.classEnrollments) {
|
|
return {
|
|
innerJoin: () => ({
|
|
where: mocks.enrollmentWhereMock,
|
|
}),
|
|
}
|
|
}
|
|
|
|
if (table === mocks.schema.classSubjectTeachers) {
|
|
return {
|
|
where: mocks.subjectTeacherWhereMock,
|
|
}
|
|
}
|
|
|
|
return {
|
|
where: () => ({
|
|
limit: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}
|
|
},
|
|
}),
|
|
query: {
|
|
exams: {
|
|
findFirst: mocks.examFindFirstMock,
|
|
},
|
|
},
|
|
transaction: mocks.transactionMock,
|
|
},
|
|
}))
|
|
|
|
import { createHomeworkAssignmentAction } from "@/modules/homework/actions"
|
|
|
|
/** Helper to create a default admin auth context */
|
|
function adminCtx() {
|
|
return {
|
|
userId: "u_admin",
|
|
roles: ["admin"],
|
|
permissions: ["homework:create"],
|
|
dataScope: { type: "all" as const },
|
|
}
|
|
}
|
|
|
|
/** Helper to create a teacher auth context */
|
|
function teacherCtx(userId = "u_teacher") {
|
|
return {
|
|
userId,
|
|
roles: ["teacher"],
|
|
permissions: ["homework:create"],
|
|
dataScope: { type: "class_taught" as const, classIds: ["class_5"], subjectIds: ["subject_science"] },
|
|
}
|
|
}
|
|
|
|
describe("createHomeworkAssignmentAction", () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
})
|
|
|
|
it("creates published assignment from exam with targets", async () => {
|
|
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
|
|
mocks.classLimitMock.mockResolvedValue([{ id: "class_1", teacherId: "teacher_1" }])
|
|
mocks.examFindFirstMock.mockResolvedValue({
|
|
id: "exam_1",
|
|
title: "Exam A",
|
|
subjectId: "subject_1",
|
|
structure: { sections: [] },
|
|
questions: [{ questionId: "q_1", score: 10, order: 1 }],
|
|
})
|
|
mocks.enrollmentWhereMock.mockResolvedValue([{ studentId: "stu_1" }, { studentId: "stu_2" }])
|
|
mocks.createIdMock.mockReturnValue("assignment_1")
|
|
|
|
const formData = new FormData()
|
|
formData.set("sourceExamId", "exam_1")
|
|
formData.set("classId", "class_1")
|
|
formData.set("publish", "true")
|
|
const result = await createHomeworkAssignmentAction(null, formData)
|
|
|
|
expect(result).toEqual({ success: true, message: "Assignment created", data: "assignment_1" })
|
|
expect(mocks.txInsertValuesMock).toHaveBeenCalledTimes(3)
|
|
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/teacher/homework/assignments")
|
|
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/teacher/homework/submissions")
|
|
})
|
|
|
|
it("returns not found when source exam does not exist", async () => {
|
|
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
|
|
mocks.classLimitMock.mockResolvedValue([{ id: "class_1", teacherId: "teacher_1" }])
|
|
mocks.examFindFirstMock.mockResolvedValue(null)
|
|
|
|
const formData = new FormData()
|
|
formData.set("sourceExamId", "missing_exam")
|
|
formData.set("classId", "class_1")
|
|
formData.set("publish", "true")
|
|
const result = await createHomeworkAssignmentAction(null, formData)
|
|
|
|
expect(result).toEqual({ success: false, message: "Exam not found" })
|
|
expect(mocks.transactionMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("blocks publish when class has no active students", async () => {
|
|
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
|
|
mocks.classLimitMock.mockResolvedValue([{ id: "class_2", teacherId: "teacher_2" }])
|
|
mocks.examFindFirstMock.mockResolvedValue({
|
|
id: "exam_2",
|
|
title: "Exam B",
|
|
subjectId: "subject_2",
|
|
structure: { sections: [] },
|
|
questions: [{ questionId: "q_2", score: 5, order: 1 }],
|
|
})
|
|
mocks.enrollmentWhereMock.mockResolvedValue([])
|
|
|
|
const formData = new FormData()
|
|
formData.set("sourceExamId", "exam_2")
|
|
formData.set("classId", "class_2")
|
|
formData.set("publish", "true")
|
|
const result = await createHomeworkAssignmentAction(null, formData)
|
|
|
|
expect(result).toEqual({ success: false, message: "No active students in this class" })
|
|
expect(mocks.transactionMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("blocks teacher when not assigned to class", async () => {
|
|
const ctx = teacherCtx("u_teacher")
|
|
mocks.requirePermissionMock.mockResolvedValue(ctx)
|
|
mocks.classLimitMock.mockResolvedValue([{ id: "class_3", teacherId: "owner_teacher" }])
|
|
mocks.examFindFirstMock.mockResolvedValue({
|
|
id: "exam_3",
|
|
title: "Exam C",
|
|
subjectId: "subject_3",
|
|
structure: { sections: [] },
|
|
questions: [{ questionId: "q_3", score: 5, order: 1 }],
|
|
})
|
|
mocks.subjectTeacherWhereMock.mockResolvedValue([])
|
|
|
|
const formData = new FormData()
|
|
formData.set("sourceExamId", "exam_3")
|
|
formData.set("classId", "class_3")
|
|
formData.set("publish", "true")
|
|
const result = await createHomeworkAssignmentAction(null, formData)
|
|
|
|
expect(result).toEqual({ success: false, message: "Not assigned to this class" })
|
|
expect(mocks.transactionMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("blocks teacher when exam subject is not assigned", async () => {
|
|
const ctx = teacherCtx("u_teacher")
|
|
mocks.requirePermissionMock.mockResolvedValue(ctx)
|
|
mocks.classLimitMock.mockResolvedValue([{ id: "class_4", teacherId: "owner_teacher" }])
|
|
mocks.examFindFirstMock.mockResolvedValue({
|
|
id: "exam_4",
|
|
title: "Exam D",
|
|
subjectId: "subject_math",
|
|
structure: { sections: [] },
|
|
questions: [{ questionId: "q_4", score: 10, order: 1 }],
|
|
})
|
|
mocks.subjectTeacherWhereMock.mockResolvedValue([{ subjectId: "subject_english" }])
|
|
|
|
const formData = new FormData()
|
|
formData.set("sourceExamId", "exam_4")
|
|
formData.set("classId", "class_4")
|
|
formData.set("publish", "true")
|
|
const result = await createHomeworkAssignmentAction(null, formData)
|
|
|
|
expect(result).toEqual({ success: false, message: "Not assigned to this subject" })
|
|
expect(mocks.transactionMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("allows teacher assigned subject to publish", async () => {
|
|
const ctx = teacherCtx("u_teacher")
|
|
mocks.requirePermissionMock.mockResolvedValue(ctx)
|
|
mocks.classLimitMock.mockResolvedValue([{ id: "class_5", teacherId: "owner_teacher" }])
|
|
mocks.examFindFirstMock.mockResolvedValue({
|
|
id: "exam_5",
|
|
title: "Exam E",
|
|
subjectId: "subject_science",
|
|
structure: { sections: [] },
|
|
questions: [{ questionId: "q_5", score: 8, order: 1 }],
|
|
})
|
|
mocks.subjectTeacherWhereMock.mockResolvedValue([{ subjectId: "subject_science" }])
|
|
mocks.enrollmentWhereMock.mockResolvedValue([{ studentId: "stu_5" }])
|
|
mocks.createIdMock.mockReturnValue("assignment_5")
|
|
|
|
const formData = new FormData()
|
|
formData.set("sourceExamId", "exam_5")
|
|
formData.set("classId", "class_5")
|
|
formData.set("publish", "true")
|
|
const result = await createHomeworkAssignmentAction(null, formData)
|
|
|
|
expect(result).toEqual({ success: true, message: "Assignment created", data: "assignment_5" })
|
|
expect(mocks.txInsertValuesMock).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it("returns exam subject missing for teacher-assigned class", async () => {
|
|
const ctx = teacherCtx("u_teacher")
|
|
mocks.requirePermissionMock.mockResolvedValue(ctx)
|
|
mocks.classLimitMock.mockResolvedValue([{ id: "class_6", teacherId: "owner_teacher" }])
|
|
mocks.examFindFirstMock.mockResolvedValue({
|
|
id: "exam_6",
|
|
title: "Exam F",
|
|
subjectId: null,
|
|
structure: { sections: [] },
|
|
questions: [{ questionId: "q_6", score: 10, order: 1 }],
|
|
})
|
|
mocks.subjectTeacherWhereMock.mockResolvedValue([{ subjectId: "subject_history" }])
|
|
|
|
const formData = new FormData()
|
|
formData.set("sourceExamId", "exam_6")
|
|
formData.set("classId", "class_6")
|
|
formData.set("publish", "true")
|
|
const result = await createHomeworkAssignmentAction(null, formData)
|
|
|
|
expect(result).toEqual({ success: false, message: "Exam subject not set" })
|
|
expect(mocks.transactionMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("returns class not found when class is missing", async () => {
|
|
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
|
|
mocks.classLimitMock.mockResolvedValue([])
|
|
|
|
const formData = new FormData()
|
|
formData.set("sourceExamId", "exam_7")
|
|
formData.set("classId", "missing_class")
|
|
const result = await createHomeworkAssignmentAction(null, formData)
|
|
|
|
expect(result).toEqual({ success: false, message: "Class not found" })
|
|
expect(mocks.transactionMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|