=test_update_homework_tests_and_work_log
Some checks failed
CI / build-deploy (push) Has been cancelled

This commit is contained in:
SpecialX
2026-03-19 13:16:49 +08:00
parent eb08c0ab68
commit 99f116cb64
70 changed files with 7470 additions and 20220 deletions

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
authMock: vi.fn(),
parseAiChatPayloadMock: vi.fn(),
createAiChatCompletionMock: vi.fn(),
getAiErrorMessageMock: vi.fn(),
}))
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("@/shared/lib/ai", () => ({
parseAiChatPayload: mocks.parseAiChatPayloadMock,
createAiChatCompletion: mocks.createAiChatCompletionMock,
getAiErrorMessage: mocks.getAiErrorMessageMock,
}))
import { POST } from "@/app/api/ai/chat/route"
describe("POST /api/ai/chat", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("returns 401 when session is missing", async () => {
mocks.authMock.mockResolvedValue(null)
const req = new Request("http://localhost/api/ai/chat", {
method: "POST",
body: JSON.stringify({ messages: [] }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toEqual({ success: false, message: "Unauthorized" })
})
it("returns ai response content for valid input", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
mocks.parseAiChatPayloadMock.mockReturnValue({ messages: [{ role: "user", content: "hello" }] })
mocks.createAiChatCompletionMock.mockResolvedValue({
content: "mocked-answer",
usage: { totalTokens: 10, promptTokens: 5, completionTokens: 5 },
})
const req = new Request("http://localhost/api/ai/chat", {
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.content).toBe("mocked-answer")
expect(mocks.createAiChatCompletionMock).toHaveBeenCalledTimes(1)
})
it("maps invalid payload errors to 400", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
mocks.parseAiChatPayloadMock.mockImplementation(() => {
throw new Error("bad payload")
})
mocks.getAiErrorMessageMock.mockReturnValue("Invalid payload")
const req = new Request("http://localhost/api/ai/chat", {
method: "POST",
body: JSON.stringify({ messages: "bad" }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ success: false, message: "Invalid payload" })
})
})

View File

@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const whereMock = vi.fn()
const innerJoinMock = vi.fn(() => ({ where: whereMock }))
const fromMock = vi.fn(() => ({ innerJoin: innerJoinMock, where: whereMock }))
const selectMock = vi.fn(() => ({ from: fromMock }))
const updateWhereMock = vi.fn()
const updateSetMock = vi.fn(() => ({ where: updateWhereMock }))
const updateMock = vi.fn(() => ({ set: updateSetMock }))
const insertOnDuplicateKeyUpdateMock = vi.fn()
const insertValuesMock = vi.fn(() => ({ onDuplicateKeyUpdate: insertOnDuplicateKeyUpdateMock }))
const insertMock = vi.fn(() => ({ values: insertValuesMock }))
const enrollStudentByInvitationCodeMock = vi.fn()
return {
authMock: vi.fn(),
whereMock,
fromMock,
selectMock,
roleFindFirstMock: vi.fn(),
updateWhereMock,
updateSetMock,
updateMock,
insertOnDuplicateKeyUpdateMock,
insertValuesMock,
insertMock,
enrollStudentByInvitationCodeMock,
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("@/shared/db", () => ({
db: {
select: mocks.selectMock,
query: {
roles: {
findFirst: mocks.roleFindFirstMock,
},
},
update: mocks.updateMock,
insert: mocks.insertMock,
},
}))
vi.mock("@/shared/db/schema", () => ({
classes: { id: "id", invitationCode: "invitationCode" },
classSubjectTeachers: { classId: "classId", subjectId: "subjectId", teacherId: "teacherId", updatedAt: "updatedAt" },
roles: { id: "id", name: "name" },
users: { id: "id", onboardedAt: "onboardedAt", name: "name", phone: "phone", address: "address" },
usersToRoles: { userId: "userId", roleId: "roleId" },
subjects: { id: "id", name: "name" },
}))
vi.mock("@/modules/classes/data-access", () => ({
enrollStudentByInvitationCode: mocks.enrollStudentByInvitationCodeMock,
}))
import { POST } from "@/app/api/onboarding/complete/route"
describe("POST /api/onboarding/complete", () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.whereMock.mockResolvedValue([])
mocks.roleFindFirstMock.mockResolvedValue({ id: "role_student" })
})
it("returns 401 when session is missing", async () => {
mocks.authMock.mockResolvedValue(null)
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({ role: "student", name: "A" }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toEqual({ success: false, message: "Unauthorized" })
})
it("returns 400 for invalid payload structure", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: "not-json",
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ success: false, message: "Invalid payload" })
})
it("returns 400 for invalid role", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({ role: "guest", name: "A" }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ success: false, message: "Invalid role" })
})
it("returns 403 when non-admin selects admin role", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_2" } })
mocks.whereMock.mockResolvedValue([{ name: "teacher" }])
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({ role: "admin", name: "Admin User" }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({ success: false, message: "Forbidden" })
})
it("returns 400 when name is missing", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_3" } })
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({ role: "student", name: " " }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ success: false, message: "Name is required" })
})
it("completes student onboarding and enrolls deduplicated class codes", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({
role: "student",
name: "Student A",
classCodes: "C1, C1;C2 C3",
}),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ success: true })
expect(mocks.enrollStudentByInvitationCodeMock).toHaveBeenCalledTimes(3)
expect(mocks.enrollStudentByInvitationCodeMock).toHaveBeenNthCalledWith(1, "u_student", "C1")
expect(mocks.enrollStudentByInvitationCodeMock).toHaveBeenNthCalledWith(2, "u_student", "C2")
expect(mocks.enrollStudentByInvitationCodeMock).toHaveBeenNthCalledWith(3, "u_student", "C3")
expect(mocks.updateSetMock).toHaveBeenCalled()
expect(mocks.insertValuesMock).toHaveBeenCalled()
})
it("completes teacher onboarding and upserts class-subject mapping", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.whereMock
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: "class_1", invitationCode: "T1" }])
.mockResolvedValueOnce([
{ id: "sub_math", name: "数学" },
{ id: "sub_music", name: "音乐" },
])
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({
role: "teacher",
name: "Teacher A",
classCodes: "T1",
teacherSubjects: ["数学", "音乐", "invalid"],
}),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ success: true })
expect(mocks.enrollStudentByInvitationCodeMock).not.toHaveBeenCalled()
expect(mocks.insertOnDuplicateKeyUpdateMock).toHaveBeenCalledTimes(3)
})
})

View File

@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const whereMock = vi.fn()
const innerJoinMock = vi.fn(() => ({ where: whereMock }))
const fromMock = vi.fn(() => ({ innerJoin: innerJoinMock }))
const selectMock = vi.fn(() => ({ from: fromMock }))
return {
authMock: vi.fn(),
findFirstMock: vi.fn(),
whereMock,
selectMock,
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("@/shared/db", () => ({
db: {
query: {
users: {
findFirst: mocks.findFirstMock,
},
},
select: mocks.selectMock,
},
}))
vi.mock("@/shared/db/schema", () => ({
roles: { name: "name", id: "id" },
users: { id: "id" },
usersToRoles: { roleId: "roleId", userId: "userId" },
}))
import { GET } from "@/app/api/onboarding/status/route"
describe("GET /api/onboarding/status", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("returns not required when user is unauthenticated", async () => {
mocks.authMock.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ required: false })
expect(mocks.findFirstMock).not.toHaveBeenCalled()
})
it("returns teacher role for grade head and requires onboarding when missing onboardedAt", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
mocks.findFirstMock.mockResolvedValue({ onboardedAt: null })
mocks.whereMock.mockResolvedValue([{ name: "grade_head" }])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ required: true, role: "teacher" })
})
it("prioritizes admin role and marks not required when onboarded", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_2" } })
mocks.findFirstMock.mockResolvedValue({ onboardedAt: new Date("2026-01-01") })
mocks.whereMock.mockResolvedValue([{ name: "student" }, { name: "admin" }])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ required: false, role: "admin" })
})
})

View File

@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
authMock: vi.fn(),
getUserProfileMock: vi.fn(),
redirectMock: vi.fn((target: string) => {
throw new Error(`REDIRECT:${target}`)
}),
}))
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("@/modules/users/data-access", () => ({
getUserProfile: mocks.getUserProfileMock,
}))
vi.mock("next/navigation", () => ({
redirect: mocks.redirectMock,
}))
import DashboardPage from "@/app/(dashboard)/dashboard/page"
describe("dashboard route dispatcher", () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.redirectMock.mockImplementation((target: string) => {
throw new Error(`REDIRECT:${target}`)
})
})
it("redirects to login when session is missing", async () => {
mocks.authMock.mockResolvedValue(null)
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/login")
})
it("redirects to login when user profile is missing", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
mocks.getUserProfileMock.mockResolvedValue(null)
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/login")
})
it("redirects admin to admin dashboard", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "admin" })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/admin/dashboard")
})
it("redirects student to student dashboard", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "student" })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/student/dashboard")
})
it("redirects parent to parent dashboard", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_parent" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "parent" })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/parent/dashboard")
})
it("falls back to student dashboard when role is unknown", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "" })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/student/dashboard")
})
})

View File

@@ -0,0 +1,290 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const authMock = 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 {
authMock,
revalidatePathMock,
createIdMock,
ensureLimitMock,
countWhereMock,
selectMock,
assignmentFindFirstMock,
assignmentTargetFindFirstMock,
submissionFindFirstMock,
txAnswerFindFirstMock,
insertValuesMock,
insertMock,
txInsertValuesMock,
txInsertMock,
updateSetMock,
updateMock,
txUpdateSetMock,
txUpdateMock,
transactionMock,
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
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",
},
roles: { id: "id", name: "name" },
users: { id: "id" },
usersToRoles: { userId: "userId", roleId: "roleId" },
}))
import {
gradeHomeworkSubmissionAction,
saveHomeworkAnswerAction,
startHomeworkSubmissionAction,
submitHomeworkAction,
} from "@/modules/homework/actions"
describe("homework action flow", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("starts submission for assigned student", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
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.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
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.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
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.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
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.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
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.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
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.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
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" } }))
})
})

View File

@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const authMock = 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" },
roles: { id: "id", name: "name" },
users: { id: "id" },
usersToRoles: { userId: "userId", roleId: "roleId" },
}
return {
authMock,
revalidatePathMock,
createIdMock,
ensureLimitMock,
classLimitMock,
enrollmentWhereMock,
subjectTeacherWhereMock,
examFindFirstMock,
txInsertValuesMock,
txInsertMock,
transactionMock,
schema,
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
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"
describe("createHomeworkAssignmentAction", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("creates published assignment from exam with targets", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
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.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
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.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
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 () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
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 () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
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 () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
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 () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
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.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
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()
})
})

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest"
vi.mock("@/auth", () => ({
auth: (handler: (req: unknown) => unknown) => handler,
}))
import proxy from "@/proxy"
type SessionRole = "admin" | "teacher" | "student" | "parent"
const createRequest = (pathname: string, role?: SessionRole) => ({
nextUrl: {
pathname,
clone: () => new URL(`http://localhost${pathname}`),
},
auth: role ? { user: { role } } : null,
url: `http://localhost${pathname}`,
})
describe("proxy route guard", () => {
it("redirects unauthenticated requests to login with callback", async () => {
const response = await proxy(createRequest("/teacher/dashboard") as never)
expect(response.status).toBe(307)
const location = response.headers.get("location") ?? ""
expect(location).toContain("/login")
expect(location).toContain("callbackUrl=%2Fteacher%2Fdashboard")
})
it("redirects student away from admin routes", async () => {
const response = await proxy(createRequest("/admin/dashboard", "student") as never)
expect(response.status).toBe(307)
expect(response.headers.get("location")).toContain("/student/dashboard")
})
it("redirects parent away from management routes", async () => {
const response = await proxy(createRequest("/management/grade/insights", "parent") as never)
expect(response.status).toBe(307)
expect(response.headers.get("location")).toContain("/parent/dashboard")
})
it("allows teacher access to management routes", async () => {
const response = await proxy(createRequest("/management/grade/insights", "teacher") as never)
expect(response.status).toBe(200)
expect(response.headers.get("location")).toBeNull()
})
})