From 22bdd6ef9831862292401516e1038397ffd870fa Mon Sep 17 00:00:00 2001 From: Cyber-Mitch Date: Mon, 2 Nov 2026 11:59:27 +0100 Subject: [PATCH] test(e2e): cover course syllabus, enrollment, progress, and quiz attempts --- tests/e2e/course-progress.test.ts | 330 +++++++++++++++++++++++++++ tests/e2e/course-syllabus.test.ts | 266 ++++++++++++++++++++++ tests/e2e/enrollment-status.test.ts | 332 ++++++++++++++++++++++++++++ tests/e2e/quiz-attempts.test.ts | 326 +++++++++++++++++++++++++++ 4 files changed, 1254 insertions(+) create mode 100644 tests/e2e/course-progress.test.ts create mode 100644 tests/e2e/course-syllabus.test.ts create mode 100644 tests/e2e/enrollment-status.test.ts create mode 100644 tests/e2e/quiz-attempts.test.ts diff --git a/tests/e2e/course-progress.test.ts b/tests/e2e/course-progress.test.ts new file mode 100644 index 0000000..0540944 --- /dev/null +++ b/tests/e2e/course-progress.test.ts @@ -0,0 +1,330 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { inArray } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { redis } from "../../src/config/redis.js"; +import { + courses, + quizzes, + quizSubmissions, + users, +} from "../../src/database/schema.js"; + +/** + * E2E coverage for GET /api/v1/users/me/courses/:courseId/progress + * (closes #626). + * + * Covers the unauthenticated, unknown-course, no-activity, partially + * complete, module-ordering, quiz-score, average-score, completion-percent, + * time-spent, and quiz-derived fallback paths. + */ +describe("Course Progress API — GET /api/v1/users/me/courses/:courseId/progress (#626)", () => { + let app: Awaited>; + + const createdUserIds: string[] = []; + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + }); + + afterEach(async () => { + if (createdCourseIds.length > 0) { + await db.delete(courses).where(inArray(courses.id, createdCourseIds)); + createdCourseIds.length = 0; + } + if (createdUserIds.length > 0) { + await db.delete(users).where(inArray(users.id, createdUserIds)); + createdUserIds.length = 0; + } + await redis.flushdb(); + }); + + afterAll(async () => { + await app.close(); + }); + + function stellarAddress(): string { + return `G${randomUUID().replace(/-/g, "").toUpperCase().padEnd(55, "A").slice(0, 55)}`; + } + + async function seedUser(): Promise { + const id = randomUUID(); + createdUserIds.push(id); + + const [user] = await db + .insert(users) + .values({ + id, + stellarAddress: stellarAddress(), + displayName: "Course Progress Test User", + }) + .returning(); + return user; + } + + async function seedCourse( + overrides: Partial = {}, + ): Promise { + const id = overrides.id ?? randomUUID(); + createdCourseIds.push(id); + + const [course] = await db + .insert(courses) + .values({ + id, + title: "Course Progress Test Course", + description: "A course used to exercise learner progress.", + difficulty: "beginner", + isActive: true, + modules: [ + { id: "module-1", title: "Module One", description: "First", order: 1 }, + { id: "module-2", title: "Module Two", description: "Second", order: 2 }, + ], + ...overrides, + }) + .returning(); + return course; + } + + async function seedQuiz( + courseId: string, + moduleId: string, + questionCount = 2, + ): Promise { + const [quiz] = await db + .insert(quizzes) + .values({ + courseId, + moduleId, + questions: Array.from({ length: questionCount }, (_, i) => ({ + prompt: `Question ${i + 1}`, + options: ["A", "B"], + correctIndex: 0, + })), + }) + .returning({ id: quizzes.id }); + return quiz.id; + } + + async function submit( + quizId: string, + userId: string, + overrides: Partial = {}, + ): Promise { + await db.insert(quizSubmissions).values({ + quizId, + userId, + answers: [], + score: 1, + ...overrides, + }); + } + + function tokenFor(user: typeof users.$inferSelect): string { + return app.jwt.sign({ sub: user.id, stellarAddress: user.stellarAddress }); + } + + it("rejects unauthenticated requests with 401", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/users/me/courses/${randomUUID()}/progress`, + }); + + expect(response.statusCode).toBe(401); + expect(JSON.parse(response.payload).error).toBe("UNAUTHORIZED"); + }); + + it("rejects a malformed course id with 400", async () => { + const user = await seedUser(); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/courses/not-a-uuid/progress", + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.payload).error).toContain("VALIDATION"); + }); + + it("returns 404 for a course that does not exist", async () => { + const user = await seedUser(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/users/me/courses/${randomUUID()}/progress`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("returns 404 for an inactive course", async () => { + const user = await seedUser(); + const course = await seedCourse({ isActive: false }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/users/me/courses/${course.id}/progress`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("returns zeroed progress for a learner with no activity", async () => { + const user = await seedUser(); + const course = await seedCourse(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/users/me/courses/${course.id}/progress`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + + expect(data).toMatchObject({ + courseId: course.id, + courseTitle: course.title, + modulesCompleted: 0, + quizzesTaken: 0, + quizScores: [], + averageScore: null, + completionPercentage: 0, + timeSpentMinutes: 0, + }); + expect(data.modules).toEqual([ + { moduleId: "module-1", title: "Module One", status: "not_started" }, + { moduleId: "module-2", title: "Module Two", status: "not_started" }, + ]); + }); + + it("sorts modules by order and reports partial completion, quiz scores, averages, and time spent", async () => { + const user = await seedUser(); + const otherUser = await seedUser(); + + // Deliberately out of order: the endpoint must sort by `order`. + const course = await seedCourse({ + modules: [ + { id: "module-3", title: "Module Three", description: "Third", order: 3 }, + { id: "module-1", title: "Module One", description: "First", order: 1 }, + { id: "module-2", title: "Module Two", description: "Second", order: 2 }, + ], + courseModules: [ + { id: "module-1", title: "Module One", estimatedDurationMinutes: 30 }, + { id: "module-2", title: "Module Two", estimatedDurationMinutes: 45 }, + { id: "module-3", title: "Module Three", estimatedDurationMinutes: 20 }, + ], + }); + + const moduleOneQuiz = await seedQuiz(course.id, "module-1", 4); + const moduleTwoQuiz = await seedQuiz(course.id, "module-2", 4); + const supersededQuiz = await seedQuiz(course.id, "module-3", 4); + + await submit(moduleOneQuiz, user.id, { + score: 2, + submittedAt: new Date("2026-03-01T09:00:00.000Z"), + }); + await submit(moduleTwoQuiz, user.id, { + score: 3, + submittedAt: new Date("2026-03-02T09:00:00.000Z"), + }); + // A superseded attempt must not complete module 3 or affect the average. + await submit(supersededQuiz, user.id, { + score: 4, + superseded: true, + submittedAt: new Date("2026-03-03T09:00:00.000Z"), + }); + // Another learner's score must not affect this learner's progress. + await submit(moduleOneQuiz, otherUser.id, { + score: 4, + submittedAt: new Date("2026-03-04T09:00:00.000Z"), + }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/users/me/courses/${course.id}/progress`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + + expect(data.modules).toEqual([ + { moduleId: "module-1", title: "Module One", status: "completed" }, + { moduleId: "module-2", title: "Module Two", status: "completed" }, + { moduleId: "module-3", title: "Module Three", status: "not_started" }, + ]); + expect(data.modulesCompleted).toBe(2); + expect(data.quizzesTaken).toBe(2); + expect(data.completionPercentage).toBe(67); + // (50% + 75%) / 2 + expect(data.averageScore).toBe(62.5); + // 30 minutes for module 1 + 45 minutes for module 2. + expect(data.timeSpentMinutes).toBe(75); + + // Quiz scores are newest-first and carry both raw score and percentage. + expect(data.quizScores).toHaveLength(2); + expect(data.quizScores[0]).toMatchObject({ + quizId: moduleTwoQuiz, + moduleId: "module-2", + score: 3, + totalQuestions: 4, + percentage: 75, + }); + expect(data.quizScores[1]).toMatchObject({ + quizId: moduleOneQuiz, + moduleId: "module-1", + score: 2, + totalQuestions: 4, + percentage: 50, + }); + expect(new Date(data.quizScores[0].submittedAt).toISOString()).toBe( + "2026-03-02T09:00:00.000Z", + ); + }); + + it("falls back to quiz-derived modules and five minutes per quiz without course metadata", async () => { + const user = await seedUser(); + const course = await seedCourse({ modules: [], courseModules: null }); + + const moduleAQuiz = await seedQuiz(course.id, "module-a", 2); + const moduleBQuiz = await seedQuiz(course.id, "module-b", 2); + await submit(moduleBQuiz, user.id, { score: 1 }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/users/me/courses/${course.id}/progress`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + + expect(data.modules).toEqual([ + { moduleId: "module-a", title: null, status: "not_started" }, + { moduleId: "module-b", title: null, status: "completed" }, + ]); + expect(data.modulesCompleted).toBe(1); + expect(data.quizzesTaken).toBe(1); + expect(data.completionPercentage).toBe(50); + expect(data.averageScore).toBe(50); + // No duration metadata: five minutes per quiz taken. + expect(data.timeSpentMinutes).toBe(5); + expect(data.quizScores).toHaveLength(1); + expect(data.quizScores[0]).toMatchObject({ + quizId: moduleBQuiz, + moduleId: "module-b", + percentage: 50, + }); + // The unused quiz must not appear as taken. + expect(data.quizScores.some((q: { quizId: string }) => q.quizId === moduleAQuiz)).toBe( + false, + ); + }); +}); diff --git a/tests/e2e/course-syllabus.test.ts b/tests/e2e/course-syllabus.test.ts new file mode 100644 index 0000000..4928eae --- /dev/null +++ b/tests/e2e/course-syllabus.test.ts @@ -0,0 +1,266 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { inArray } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { redis } from "../../src/config/redis.js"; +import { courses, quizzes } from "../../src/database/schema.js"; + +/** + * E2E coverage for GET /api/v1/courses/:id/syllabus (closes #627). + * + * The syllabus endpoint is public, so these tests only need a persisted + * course. They cover the authored `courseModules` path, the quiz-derived + * fallback, module ordering, learning-objective derivation, and the empty + * course case. + */ +describe("Course Syllabus API — GET /api/v1/courses/:id/syllabus (#627)", () => { + let app: Awaited>; + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + }); + + afterEach(async () => { + if (createdCourseIds.length > 0) { + await db.delete(courses).where(inArray(courses.id, createdCourseIds)); + createdCourseIds.length = 0; + } + await redis.flushdb(); + }); + + afterAll(async () => { + await app.close(); + }); + + function trackCourse(id: string): string { + createdCourseIds.push(id); + return id; + } + + async function seedCourse( + overrides: Partial = {}, + ): Promise { + const id = overrides.id ?? randomUUID(); + trackCourse(id); + + const [course] = await db + .insert(courses) + .values({ + id, + title: "Syllabus Test Course", + description: "A course used to exercise the syllabus endpoint.", + difficulty: "intermediate", + isActive: true, + ...overrides, + }) + .returning(); + + return course; + } + + async function seedQuiz( + courseId: string, + moduleId: string, + questions: Array<{ prompt: string; options: string[]; correctIndex: number }>, + ): Promise { + await db.insert(quizzes).values({ + courseId, + moduleId, + questions, + generatedFor: null, + }); + } + + it("rejects a malformed course id with 400", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/courses/not-a-uuid/syllabus", + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.payload).error).toContain("VALIDATION"); + }); + + it("returns 404 for a course that does not exist", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${randomUUID()}/syllabus`, + }); + + expect(response.statusCode).toBe(404); + const body = JSON.parse(response.payload); + expect(body.error).toBe("NOT_FOUND"); + }); + + it("returns 404 for an inactive course", async () => { + const course = await seedCourse({ isActive: false }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/syllabus`, + }); + + expect(response.statusCode).toBe(404); + }); + + it("returns authored modules in array order with metadata and learning objectives", async () => { + const course = await seedCourse({ + courseModules: [ + { + id: "module-zeta", + title: "Zeta", + description: "The final module.", + estimatedDurationMinutes: 45, + }, + { + id: "module-alpha", + title: "Alpha", + description: "The first module.", + estimatedDurationMinutes: 30, + }, + { + id: "module-mu", + title: "Mu", + estimatedDurationMinutes: 15, + }, + ], + }); + + await seedQuiz(course.id, "module-alpha", [ + { prompt: "Explain the alpha concept", options: ["A", "B"], correctIndex: 0 }, + { prompt: "Apply the alpha concept", options: ["A", "B"], correctIndex: 1 }, + ]); + await seedQuiz(course.id, "module-mu", [ + { prompt: "Describe mu", options: ["A", "B"], correctIndex: 0 }, + ]); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/syllabus`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + + const { data } = body; + expect(data.courseId).toBe(course.id); + expect(data.title).toBe(course.title); + expect(data.difficulty).toBe("intermediate"); + expect(data.totalEstimatedDurationMinutes).toBe(90); + expect(data.generatedAt).toBeDefined(); + + // The array order is the authored order; `order` is 1-based. + expect(data.modules.map((m: { id: string }) => m.id)).toEqual([ + "module-zeta", + "module-alpha", + "module-mu", + ]); + expect(data.modules.map((m: { order: number }) => m.order)).toEqual([1, 2, 3]); + + const alpha = data.modules[1]; + expect(alpha).toMatchObject({ + id: "module-alpha", + title: "Alpha", + description: "The first module.", + estimatedDurationMinutes: 30, + learningObjectives: [ + "Explain the alpha concept", + "Apply the alpha concept", + ], + }); + + const mu = data.modules[2]; + expect(mu.description).toBeNull(); + expect(mu.learningObjectives).toEqual(["Describe mu"]); + + // A module with no quizzes still appears, with an empty objective list. + const zeta = data.modules[0]; + expect(zeta.estimatedDurationMinutes).toBe(45); + expect(zeta.learningObjectives).toEqual([]); + }); + + it("derives modules from quizzes when no courseModules metadata exists", async () => { + const course = await seedCourse({ courseModules: null }); + + // Inserted out of alphabetical order on purpose: the fallback groups and + // orders by module id, so the response must still be sorted. + await seedQuiz(course.id, "module-zulu", [ + { prompt: "Zulu objective", options: ["A", "B"], correctIndex: 0 }, + ]); + await seedQuiz(course.id, "module-alpha", [ + { prompt: "Alpha objective", options: ["A", "B"], correctIndex: 0 }, + ]); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/syllabus`, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + + expect(data.modules.map((m: { id: string }) => m.id)).toEqual([ + "module-alpha", + "module-zulu", + ]); + expect(data.modules.map((m: { title: string }) => m.title)).toEqual([ + "module-alpha", + "module-zulu", + ]); + expect(data.modules.map((m: { order: number }) => m.order)).toEqual([1, 2]); + expect(data.modules[0]).toMatchObject({ + description: null, + estimatedDurationMinutes: null, + learningObjectives: ["Alpha objective"], + }); + expect(data.totalEstimatedDurationMinutes).toBeNull(); + }); + + it("returns an empty syllabus for a course with no authored modules or quizzes", async () => { + const course = await seedCourse({ courseModules: null }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/syllabus`, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + + expect(data.courseId).toBe(course.id); + expect(data.modules).toEqual([]); + expect(data.totalEstimatedDurationMinutes).toBeNull(); + }); + + it("never includes modules belonging to another course", async () => { + const target = await seedCourse({ + courseModules: [ + { id: "target-module", title: "Target module", estimatedDurationMinutes: 10 }, + ], + }); + const other = await seedCourse({ + courseModules: [ + { id: "other-module", title: "Other module", estimatedDurationMinutes: 99 }, + ], + }); + + await seedQuiz(other.id, "other-module", [ + { prompt: "Should not leak", options: ["A", "B"], correctIndex: 0 }, + ]); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${target.id}/syllabus`, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + expect(data.modules.map((m: { id: string }) => m.id)).toEqual(["target-module"]); + expect(data.totalEstimatedDurationMinutes).toBe(10); + }); +}); diff --git a/tests/e2e/enrollment-status.test.ts b/tests/e2e/enrollment-status.test.ts new file mode 100644 index 0000000..82e1136 --- /dev/null +++ b/tests/e2e/enrollment-status.test.ts @@ -0,0 +1,332 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { inArray } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { redis } from "../../src/config/redis.js"; +import { + courses, + enrollments, + quizzes, + quizSubmissions, + users, +} from "../../src/database/schema.js"; + +/** + * E2E coverage for GET /api/v1/courses/:id/enrollment-status (closes #628). + * + * Covers the unauthenticated, unknown-course, not-enrolled, enrolled, + * completed, module-completion, and quiz-aggregate paths against a real + * Fastify app and the test database. + */ +describe("Course Enrollment Status API — GET /api/v1/courses/:id/enrollment-status (#628)", () => { + let app: Awaited>; + + const createdUserIds: string[] = []; + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + }); + + afterEach(async () => { + if (createdCourseIds.length > 0) { + await db.delete(courses).where(inArray(courses.id, createdCourseIds)); + createdCourseIds.length = 0; + } + if (createdUserIds.length > 0) { + await db.delete(users).where(inArray(users.id, createdUserIds)); + createdUserIds.length = 0; + } + await redis.flushdb(); + }); + + afterAll(async () => { + await app.close(); + }); + + function stellarAddress(): string { + return `G${randomUUID().replace(/-/g, "").toUpperCase().padEnd(55, "A").slice(0, 55)}`; + } + + async function seedUser(): Promise { + const id = randomUUID(); + createdUserIds.push(id); + + const [user] = await db + .insert(users) + .values({ + id, + stellarAddress: stellarAddress(), + displayName: "Enrollment Status Test User", + }) + .returning(); + return user; + } + + async function seedCourse( + overrides: Partial = {}, + ): Promise { + const id = overrides.id ?? randomUUID(); + createdCourseIds.push(id); + + const [course] = await db + .insert(courses) + .values({ + id, + title: "Enrollment Status Test Course", + description: "A course used to exercise enrollment status.", + difficulty: "beginner", + isActive: true, + modules: [ + { id: "module-1", title: "Module One", description: "First", order: 1 }, + { id: "module-2", title: "Module Two", description: "Second", order: 2 }, + ], + ...overrides, + }) + .returning(); + return course; + } + + async function enroll( + userId: string, + courseId: string, + overrides: Partial = {}, + ): Promise { + await db.insert(enrollments).values({ userId, courseId, ...overrides }); + } + + async function seedQuiz( + courseId: string, + moduleId: string, + questionCount = 2, + ): Promise { + const [quiz] = await db + .insert(quizzes) + .values({ + courseId, + moduleId, + questions: Array.from({ length: questionCount }, (_, i) => ({ + prompt: `Question ${i + 1}`, + options: ["A", "B"], + correctIndex: 0, + })), + }) + .returning({ id: quizzes.id }); + return quiz.id; + } + + async function submit( + quizId: string, + userId: string, + overrides: Partial = {}, + ): Promise { + await db.insert(quizSubmissions).values({ + quizId, + userId, + answers: [], + score: 1, + ...overrides, + }); + } + + function tokenFor(user: typeof users.$inferSelect): string { + return app.jwt.sign({ sub: user.id, stellarAddress: user.stellarAddress }); + } + + it("rejects unauthenticated requests with 401", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${randomUUID()}/enrollment-status`, + }); + + expect(response.statusCode).toBe(401); + expect(JSON.parse(response.payload).error).toBe("UNAUTHORIZED"); + }); + + it("rejects a malformed course id with 400", async () => { + const user = await seedUser(); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/courses/not-a-uuid/enrollment-status", + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.payload).error).toContain("VALIDATION"); + }); + + it("returns 404 for a course that does not exist", async () => { + const user = await seedUser(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${randomUUID()}/enrollment-status`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("returns 404 for an inactive course", async () => { + const user = await seedUser(); + const course = await seedCourse({ isActive: false }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/enrollment-status`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("reports the not-enrolled state without inventing progress", async () => { + const user = await seedUser(); + const course = await seedCourse(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/enrollment-status`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + + expect(data).toMatchObject({ + courseId: course.id, + isEnrolled: false, + enrolledAt: null, + completedAt: null, + quizCount: 0, + averageScore: null, + }); + expect(data.moduleProgress).toEqual([ + { moduleId: "module-1", title: "Module One", completed: false }, + { moduleId: "module-2", title: "Module Two", completed: false }, + ]); + }); + + it("reports an enrolled but incomplete course with the enrollment timestamp", async () => { + const user = await seedUser(); + const course = await seedCourse(); + const enrolledAt = new Date("2026-01-02T10:00:00.000Z"); + await enroll(user.id, course.id, { enrolledAt }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/enrollment-status`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + expect(data.isEnrolled).toBe(true); + expect(data.enrolledAt).toBe(enrolledAt.toISOString()); + expect(data.completedAt).toBeNull(); + }); + + it("reports completedAt once the enrollment has been completed", async () => { + const user = await seedUser(); + const course = await seedCourse(); + const enrolledAt = new Date("2026-01-02T10:00:00.000Z"); + const completedAt = new Date("2026-01-05T18:30:00.000Z"); + await enroll(user.id, course.id, { enrolledAt, completedAt }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/enrollment-status`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + expect(data.isEnrolled).toBe(true); + expect(data.completedAt).toBe(completedAt.toISOString()); + }); + + it("tracks module completion and quiz aggregates from the caller's non-superseded submissions only", async () => { + const user = await seedUser(); + const otherUser = await seedUser(); + const course = await seedCourse(); + await enroll(user.id, course.id); + + const moduleOneQuiz = await seedQuiz(course.id, "module-1", 4); + const moduleTwoQuiz = await seedQuiz(course.id, "module-2", 4); + + // The caller completed module 1 with a score of 3. + await submit(moduleOneQuiz, user.id, { score: 3 }); + // A later, superseded module 2 attempt must not mark module 2 complete. + await submit(moduleTwoQuiz, user.id, { score: 1, superseded: true }); + // Another learner's submission must not contribute to this user's stats. + await submit(moduleOneQuiz, otherUser.id, { score: 4 }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/enrollment-status`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + + expect(data.moduleProgress).toEqual([ + { moduleId: "module-1", title: "Module One", completed: true }, + { moduleId: "module-2", title: "Module Two", completed: false }, + ]); + expect(data.quizCount).toBe(1); + expect(data.averageScore).toBe(3); + }); + + it("aggregates every non-superseded submission into quizCount and averageScore", async () => { + const user = await seedUser(); + const course = await seedCourse(); + await enroll(user.id, course.id); + + const firstQuiz = await seedQuiz(course.id, "module-1", 4); + const secondQuiz = await seedQuiz(course.id, "module-1", 4); + const supersededQuiz = await seedQuiz(course.id, "module-2", 4); + + await submit(firstQuiz, user.id, { score: 1 }); + await submit(secondQuiz, user.id, { score: 3 }); + await submit(supersededQuiz, user.id, { score: 4, superseded: true }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/enrollment-status`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + expect(data.quizCount).toBe(2); + // Raw score average: (1 + 3) / 2, not a percentage. + expect(data.averageScore).toBe(2); + }); + + it("falls back to quiz-derived module ids when the course has no authored modules", async () => { + const user = await seedUser(); + const course = await seedCourse({ modules: [], courseModules: null }); + await enroll(user.id, course.id); + + await seedQuiz(course.id, "module-b", 2); + const moduleAQuiz = await seedQuiz(course.id, "module-a", 2); + await submit(moduleAQuiz, user.id, { score: 2 }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/enrollment-status`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + expect(data.moduleProgress).toEqual([ + { moduleId: "module-a", title: null, completed: true }, + { moduleId: "module-b", title: null, completed: false }, + ]); + }); +}); diff --git a/tests/e2e/quiz-attempts.test.ts b/tests/e2e/quiz-attempts.test.ts new file mode 100644 index 0000000..bd86deb --- /dev/null +++ b/tests/e2e/quiz-attempts.test.ts @@ -0,0 +1,326 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { inArray } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { redis } from "../../src/config/redis.js"; +import { + courses, + quizzes, + quizSubmissions, + users, +} from "../../src/database/schema.js"; + +/** + * E2E coverage for GET /api/v1/courses/:id/modules/:moduleId/quiz-attempts + * (closes #629). The endpoint returns the authenticated learner's attempts + * for one module, oldest-first, with attempt numbers, pass/fail state, and + * the superseded flag left intact for history. + */ +describe("Quiz Attempts API — GET /api/v1/courses/:id/modules/:moduleId/quiz-attempts (#629)", () => { + let app: Awaited>; + + const createdUserIds: string[] = []; + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + }); + + afterEach(async () => { + if (createdCourseIds.length > 0) { + await db.delete(courses).where(inArray(courses.id, createdCourseIds)); + createdCourseIds.length = 0; + } + if (createdUserIds.length > 0) { + await db.delete(users).where(inArray(users.id, createdUserIds)); + createdUserIds.length = 0; + } + await redis.flushdb(); + }); + + afterAll(async () => { + await app.close(); + }); + + function stellarAddress(): string { + return `G${randomUUID().replace(/-/g, "").toUpperCase().padEnd(55, "A").slice(0, 55)}`; + } + + async function seedUser(): Promise { + const id = randomUUID(); + createdUserIds.push(id); + + const [user] = await db + .insert(users) + .values({ + id, + stellarAddress: stellarAddress(), + displayName: "Quiz Attempts Test User", + }) + .returning(); + return user; + } + + async function seedCourse( + overrides: Partial = {}, + ): Promise { + const id = overrides.id ?? randomUUID(); + createdCourseIds.push(id); + + const [course] = await db + .insert(courses) + .values({ + id, + title: "Quiz Attempts Test Course", + description: "A course used to exercise quiz attempt history.", + difficulty: "beginner", + isActive: true, + modules: [ + { id: "module-1", title: "Module One", description: "First", order: 1 }, + { id: "module-2", title: "Module Two", description: "Second", order: 2 }, + ], + ...overrides, + }) + .returning(); + return course; + } + + async function seedQuiz( + courseId: string, + moduleId: string, + questionCount = 2, + ): Promise { + const [quiz] = await db + .insert(quizzes) + .values({ + courseId, + moduleId, + questions: Array.from({ length: questionCount }, (_, i) => ({ + prompt: `Question ${i + 1}`, + options: ["A", "B"], + correctIndex: 0, + })), + }) + .returning({ id: quizzes.id }); + return quiz.id; + } + + async function submit( + quizId: string, + userId: string, + overrides: Partial = {}, + ): Promise { + const [submission] = await db + .insert(quizSubmissions) + .values({ + quizId, + userId, + answers: [], + score: 1, + ...overrides, + }) + .returning(); + return submission; + } + + function tokenFor(user: typeof users.$inferSelect): string { + return app.jwt.sign({ sub: user.id, stellarAddress: user.stellarAddress }); + } + + it("rejects unauthenticated requests with 401", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${randomUUID()}/modules/module-1/quiz-attempts`, + }); + + expect(response.statusCode).toBe(401); + expect(JSON.parse(response.payload).error).toBe("UNAUTHORIZED"); + }); + + it("rejects a malformed course id with 400", async () => { + const user = await seedUser(); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/courses/not-a-uuid/modules/module-1/quiz-attempts", + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.payload).error).toContain("VALIDATION"); + }); + + it("returns 404 for a course that does not exist", async () => { + const user = await seedUser(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${randomUUID()}/modules/module-1/quiz-attempts`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("returns 404 for an inactive course", async () => { + const user = await seedUser(); + const course = await seedCourse({ isActive: false }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/modules/module-1/quiz-attempts`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("returns an empty attempt history for a learner with no submissions", async () => { + const user = await seedUser(); + const course = await seedCourse(); + await seedQuiz(course.id, "module-1"); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/modules/module-1/quiz-attempts`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data).toMatchObject({ + courseId: course.id, + moduleId: "module-1", + attempts: [], + totalAttempts: 0, + }); + }); + + it("returns only the caller's attempts for the requested module, oldest-first", async () => { + const user = await seedUser(); + const otherUser = await seedUser(); + const course = await seedCourse(); + + const firstQuiz = await seedQuiz(course.id, "module-1"); + const secondQuiz = await seedQuiz(course.id, "module-1"); + const otherModuleQuiz = await seedQuiz(course.id, "module-2"); + + const first = await submit(firstQuiz, user.id, { + score: 1, + submittedAt: new Date("2026-01-01T09:00:00.000Z"), + }); + const second = await submit(secondQuiz, user.id, { + score: 2, + submittedAt: new Date("2026-01-02T09:00:00.000Z"), + }); + // Same quiz can be taken by another learner, and must not leak in. + await submit(firstQuiz, otherUser.id, { + score: 2, + submittedAt: new Date("2026-01-03T09:00:00.000Z"), + }); + // Attempts in other modules must not leak in either. + await submit(otherModuleQuiz, user.id, { + score: 2, + submittedAt: new Date("2026-01-04T09:00:00.000Z"), + }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/modules/module-1/quiz-attempts`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + + expect(data.totalAttempts).toBe(2); + expect(data.attempts.map((a: { submissionId: string }) => a.submissionId)).toEqual([ + first.id, + second.id, + ]); + expect(data.attempts.map((a: { attemptNumber: number }) => a.attemptNumber)).toEqual([ + 1, 2, + ]); + expect(data.attempts.map((a: { date: string }) => new Date(a.date).toISOString())).toEqual([ + "2026-01-01T09:00:00.000Z", + "2026-01-02T09:00:00.000Z", + ]); + }); + + it("exposes passed, failed, superseded, and unscored attempt states", async () => { + const user = await seedUser(); + const course = await seedCourse(); + + const passedQuiz = await seedQuiz(course.id, "module-1", 2); + const failedQuiz = await seedQuiz(course.id, "module-1", 3); + const supersededQuiz = await seedQuiz(course.id, "module-1", 2); + const unscoredQuiz = await seedQuiz(course.id, "module-1", 2); + + await submit(passedQuiz, user.id, { + score: 2, + submittedAt: new Date("2026-02-01T09:00:00.000Z"), + }); + await submit(failedQuiz, user.id, { + score: 1, + submittedAt: new Date("2026-02-02T09:00:00.000Z"), + }); + await submit(supersededQuiz, user.id, { + score: 2, + superseded: true, + submittedAt: new Date("2026-02-03T09:00:00.000Z"), + }); + await submit(unscoredQuiz, user.id, { + score: null, + submittedAt: new Date("2026-02-04T09:00:00.000Z"), + }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course.id}/modules/module-1/quiz-attempts`, + headers: { authorization: `Bearer ${tokenFor(user)}` }, + }); + + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + expect(data.totalAttempts).toBe(4); + + const [passed, failed, superseded, unscored] = data.attempts; + expect(passed).toMatchObject({ + attemptNumber: 1, + score: 2, + percentage: 100, + passed: true, + superseded: false, + }); + + // 1 correct out of 3 = 33%, below the 70% pass threshold. + expect(failed).toMatchObject({ + attemptNumber: 2, + score: 1, + percentage: 33, + passed: false, + superseded: false, + }); + + // Superseded attempts remain in history and keep their original result. + expect(superseded).toMatchObject({ + attemptNumber: 3, + score: 2, + percentage: 100, + passed: true, + superseded: true, + }); + + // An attempt with no score cannot be marked as passed. + expect(unscored).toMatchObject({ + attemptNumber: 4, + score: null, + percentage: null, + passed: false, + superseded: false, + }); + }); +});