diff --git a/src/audit/index.ts b/src/audit/index.ts index 5247716..a2cf9fa 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -21,13 +21,16 @@ type AuditEvent = | "course.published" | "course.duplicated" | "course.reviewed" + | "course.archived" + | "course.enrollment_dropped" + | "course.waitlist.joined" + | "course.waitlist.left" + | "course.waitlist.notified" | "user.account_deleted" | "user.data_exported" | "course.module.created" | "course.module.updated" | "course.module.deleted" - | "course.waitlist.joined" - | "course.waitlist.left" | "webhook.created" | "webhook.updated" | "webhook.deleted" diff --git a/src/middleware/rate-limit.ts b/src/middleware/rate-limit.ts index 282617a..8790565 100644 --- a/src/middleware/rate-limit.ts +++ b/src/middleware/rate-limit.ts @@ -70,3 +70,21 @@ export const batchMintRateLimit: RateLimitOptions = { }, errorResponseBuilder, }; + +/** + * Batch quiz generation (#308) can fan out into up to MAX_BATCH_GENERATE_MODULES + * sequential AI service calls per request — each module's own per-module/hour + * counter (assertGenerationAllowed) already caps the underlying AI load, but + * this route-level limit additionally caps how often the batch endpoint + * itself can be hit, mirroring batchMintRateLimit's rationale for other + * multi-step endpoints. + */ +export const quizBatchGenerationRateLimit: RateLimitOptions = { + max: 5, + timeWindow: "1 minute", + keyGenerator: (request: FastifyRequest) => { + const authReq = request as any; + return authReq.authUser?.id ?? request.ip; + }, + errorResponseBuilder, +}; diff --git a/src/modules/courses/course.controller.ts b/src/modules/courses/course.controller.ts index 3460e74..dc28c66 100644 --- a/src/modules/courses/course.controller.ts +++ b/src/modules/courses/course.controller.ts @@ -99,6 +99,38 @@ export class CourseController { }); } + /** + * GET /api/v1/courses/:id/leaderboard + * Top performers for a course, ranked by average quiz score (#324, #311). + * Restored here — course.service.ts's getLeaderboard was left intact but + * this passthrough was collateral damage of an unrelated upstream merge + * (#440) that stripped several CourseController methods. + */ + async leaderboard( + request: FastifyRequest<{ Params: CourseIdParams }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const leaderboard = await courseService.getLeaderboard(id); + + reply.send({ success: true, data: leaderboard }); + } + + /** + * DELETE /api/v1/courses/:id/enroll + * Drop the caller's enrollment in a course (#310). + */ + async dropEnrollment( + request: FastifyRequest<{ Params: CourseIdParams }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const { authUser } = request as AuthenticatedRequest; + await courseService.dropEnrollment(authUser.id, id); + + reply.send({ success: true, message: "Enrollment dropped" }); + } + /** * GET /api/courses/:id/modules * List a course's modules with the authenticated (enrolled) user's diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index fa1abf4..cc71708 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -192,6 +192,20 @@ export async function courseRoutes(app: FastifyInstance): Promise { (request, reply) => courseController.batchEnroll(request, reply) ); + app.delete<{ Params: { id: string } }>( + "/:id/enroll", + { + preHandler: [authGuard, validate({ params: courseIdParamsSchema })], + schema: { + description: "Drop the caller's enrollment in a course (#310)", + tags: ["courses"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + } as FastifySchema, + }, + (request, reply) => courseController.dropEnrollment(request, reply) + ); + app.get<{ Params: { id: string } }>( "/:id/prerequisites", { diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index 8821300..a3d0cb2 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -36,6 +36,7 @@ import type { CourseModuleMetadata, UpdateCourseBody, CourseModuleWithProgress, + CourseLeaderboardEntry, CreateModuleBody, UpdateModuleBody, ListReviewsQuery, @@ -47,6 +48,8 @@ import type { } from "./course.types.js"; const POPULAR_COURSES_TTL_SECONDS = 300; +const LEADERBOARD_TTL_SECONDS = 300; +const LEADERBOARD_SIZE = 20; export class CourseService { async getStats(): Promise { @@ -547,6 +550,88 @@ export class CourseService { return { contentHashMismatch }; } + /** + * Drop the caller's enrollment in a course (#310) — the companion action + * to enroll() that this codebase didn't previously have, needed to give + * "a spot opens up" any concrete meaning. The row is hard-deleted + * (matching the "drop a course" language already used in enroll()'s cap + * error) rather than soft-cancelled, since nothing in this codebase reads + * a cancelled-but-not-deleted enrollment. + */ + async dropEnrollment(userId: string, courseId: string): Promise { + await withLock(`enroll:${userId}:${courseId}`, async () => { + const [deleted] = await db + .delete(enrollments) + .where( + and(eq(enrollments.userId, userId), eq(enrollments.courseId, courseId)), + ) + .returning(); + + if (!deleted) { + throw new NotFoundError("Enrollment"); + } + + const invalidations = await Promise.allSettled([ + cacheDel(cacheKey("courses", "detail", courseId)), + cacheDel(cacheKey("courses", "stats")), + cacheDel(cacheKey("user", "progress", userId)), + cacheDel(cacheKey("user", "enrollments", userId)), + cacheInvalidatePattern(cacheKeyPattern("user", "activity", userId)), + ]); + const failed = invalidations.filter((r) => r.status === "rejected"); + if (failed.length > 0) { + logger.warn( + { userId, courseId, failedCount: failed.length }, + "Post-drop cache invalidation had failures — affected views may serve stale data until their TTL expires", + ); + } + }); + + await auditLog("course.enrollment_dropped", { userId, courseId }); + logger.info({ userId, courseId }, "Enrollment dropped"); + + // Runs after the enroll lock releases — notifying a waitlisted user + // should never extend how long the enrollment lock for this drop is + // held. + await this.notifyNextWaitlisted(courseId); + } + + /** + * Identify the user at the head of a course's waitlist so they can be + * told a spot opened up (#310) — called after dropEnrollment(). Uses the + * existing waitlistService (added for #320/#323) rather than reading the + * table directly; that service already owns join/leave/position + * bookkeeping. The identified user stays on the waitlist (not removed) + * until they actually enroll, at which point enroll()'s existing call to + * waitlistService.removeFromWaitlist takes them off. + * + * There's currently no user-facing notifications table to write to (it + * was dropped from schema.ts by an unrelated upstream change) — this + * records the event via the audit log instead, so the signal isn't lost + * and can be wired into a real notification channel once one exists + * again. Best-effort: a failure here never fails the caller's drop. + */ + private async notifyNextWaitlisted(courseId: string): Promise { + try { + const next = await waitlistService.getNextOnWaitlist(courseId); + if (!next) return; + + await auditLog("course.waitlist.notified", { + userId: next.userId, + courseId, + }); + logger.info( + { userId: next.userId, courseId }, + "Identified next waitlisted user for an open spot", + ); + } catch (err) { + logger.warn( + { err, courseId }, + "Failed to identify next waitlisted user — the enrollment drop itself still succeeded", + ); + } + } + /** * Active courses ordered by enrollment count descending, for discovery * (#293). Cached separately from listCourses() since the sort/shape diff --git a/src/modules/quizzes/quiz.controller.ts b/src/modules/quizzes/quiz.controller.ts index 9ff26ed..cc5aec1 100644 --- a/src/modules/quizzes/quiz.controller.ts +++ b/src/modules/quizzes/quiz.controller.ts @@ -3,6 +3,7 @@ import { quizService } from "./quiz.service.js"; import type { AuthenticatedRequest } from "../../middleware/auth.js"; import type { GenerateQuizBody, + GenerateQuizBatchBody, SubmitQuizBody, QuizIdParams, QuizStatsQuery, @@ -24,6 +25,21 @@ export class QuizController { reply.status(201).send({ success: true, data: quiz }); } + /** + * POST /api/v1/quizzes/generate-batch + * Generate quizzes for multiple modules of a course in one request (#308). + */ + async generateBatch( + request: FastifyRequest<{ Body: GenerateQuizBatchBody }>, + reply: FastifyReply + ): Promise { + const { authUser } = request as AuthenticatedRequest; + const data = request.body; + const results = await quizService.generateQuizBatch(authUser.id, data); + + reply.status(201).send({ success: true, data: results }); + } + /** * POST /api/quizzes/:id/submit * Submit answers for a quiz. diff --git a/src/modules/quizzes/quiz.routes.ts b/src/modules/quizzes/quiz.routes.ts index 9b64ec9..74a1348 100644 --- a/src/modules/quizzes/quiz.routes.ts +++ b/src/modules/quizzes/quiz.routes.ts @@ -2,12 +2,15 @@ import type { FastifyInstance, FastifySchema } from "fastify"; import { quizController } from "./quiz.controller.js"; import { authGuard } from "../../middleware/auth.js"; import { validate } from "../../middleware/validation.js"; +import { quizBatchGenerationRateLimit } from "../../middleware/rate-limit.js"; import { config } from "../../config/index.js"; import { generateQuizSchema, + generateQuizBatchSchema, submitQuizSchema, quizIdParamsSchema, quizStatsQuerySchema, + MAX_BATCH_GENERATE_MODULES, } from "./quiz.types.js"; /** @@ -65,6 +68,40 @@ export async function quizRoutes(app: FastifyInstance): Promise { (request, reply) => quizController.generate(request, reply) ); + app.post<{ Body: import("./quiz.types.js").GenerateQuizBatchBody }>( + "/generate-batch", + { + // Modules are generated sequentially (#308), so the worst case is + // roughly MAX_BATCH_GENERATE_MODULES times a single generation. + config: { + timeoutMs: config.QUIZ_GENERATION_TIMEOUT_MS * MAX_BATCH_GENERATE_MODULES, + rateLimit: quizBatchGenerationRateLimit, + }, + preHandler: [validate({ body: generateQuizBatchSchema })], + schema: { + description: "Generate quizzes for multiple modules of a course in one request", + tags: ["quizzes"], + security: [{ bearerAuth: [] }], + body: { + type: "object", + required: ["courseId", "moduleIds"], + properties: { + courseId: { type: "string", format: "uuid" }, + moduleIds: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + maxItems: MAX_BATCH_GENERATE_MODULES, + }, + difficulty: { type: "string", enum: ["beginner", "intermediate", "advanced"] }, + numQuestions: { type: "integer", minimum: 1, maximum: 20 }, + }, + }, + } as FastifySchema, + }, + (request, reply) => quizController.generateBatch(request, reply) + ); + app.post<{ Params: { id: string }, Body: import("./quiz.types.js").SubmitQuizBody }>( "/:id/submit", { diff --git a/src/modules/quizzes/quiz.service.ts b/src/modules/quizzes/quiz.service.ts index e2b698d..e3b2b0a 100644 --- a/src/modules/quizzes/quiz.service.ts +++ b/src/modules/quizzes/quiz.service.ts @@ -32,8 +32,10 @@ import { MAX_RETRIES_PER_MODULE_PER_DAY, MAX_QUIZ_GENERATIONS_PER_MODULE_PER_HOUR, type GenerateQuizBody, + type GenerateQuizBatchBody, type SubmitQuizBody, type QuizWithQuestions, + type QuizBatchGenerateEntry, type QuizSubmissionResult, type QuizQuestion, type QuizStats, @@ -128,6 +130,47 @@ export class QuizService { }; } + /** + * Generate quizzes for several modules of the same course in one request + * (#308). Each module goes through the same generateQuiz path — enrollment + * check, per-module rate limit, existing-quiz short-circuit, AI generation + * with placeholder fallback — sequentially rather than in parallel so a + * batch request can't fan out into a burst of concurrent AI service calls. + * One module failing (e.g. its own per-module generation rate limit) is + * reported in that module's entry rather than aborting the rest of the + * batch. + */ + async generateQuizBatch( + userId: string, + data: GenerateQuizBatchBody + ): Promise { + const results: QuizBatchGenerateEntry[] = []; + + for (const moduleId of data.moduleIds) { + try { + const quiz = await this.generateQuiz(userId, { + courseId: data.courseId, + moduleId, + difficulty: data.difficulty, + numQuestions: data.numQuestions, + }); + results.push({ moduleId, success: true, quiz }); + } catch (err) { + logger.warn( + { err, courseId: data.courseId, moduleId }, + "Batch quiz generation failed for module" + ); + results.push({ + moduleId, + success: false, + error: err instanceof Error ? err.message : "Quiz generation failed", + }); + } + } + + return results; + } + /** * Submit answers for a quiz and calculate the score. * Uses distributed locking + database transaction with row-level lock diff --git a/src/modules/quizzes/quiz.types.ts b/src/modules/quizzes/quiz.types.ts index dc2f938..72578d4 100644 --- a/src/modules/quizzes/quiz.types.ts +++ b/src/modules/quizzes/quiz.types.ts @@ -31,6 +31,21 @@ export const generateQuizSchema = z.object({ numQuestions: z.coerce.number().int().min(1).max(20).optional(), }); +// Capped at 10 modules per batch request (#308) — generation runs +// sequentially against the AI service, so this also bounds the route's +// worst-case timeout (see QUIZ_BATCH_GENERATION_TIMEOUT_MS). +export const MAX_BATCH_GENERATE_MODULES = 10; + +export const generateQuizBatchSchema = z.object({ + courseId: z.string().uuid("Invalid course ID"), + moduleIds: z + .array(z.string().min(1)) + .min(1, "At least one module ID is required") + .max(MAX_BATCH_GENERATE_MODULES, `Too many modules (max ${MAX_BATCH_GENERATE_MODULES})`), + difficulty: z.enum(["beginner", "intermediate", "advanced"]).optional(), + numQuestions: z.coerce.number().int().min(1).max(20).optional(), +}); + export const submitQuizSchema = z.object({ answers: z .array( @@ -55,6 +70,7 @@ export const quizStatsQuerySchema = z.object({ // ─── Types ────────────────────────────────────────────────────────────────── export type GenerateQuizBody = z.infer; +export type GenerateQuizBatchBody = z.infer; export type SubmitQuizBody = z.infer; export type QuizIdParams = z.infer; export type QuizStatsQuery = z.infer; @@ -76,6 +92,13 @@ export interface QuizWithQuestions { createdAt: Date; } +/** One entry of POST /api/v1/quizzes/generate-batch's response (#308). Each + * module is generated independently, so one module's failure (e.g. hitting + * its own per-module rate limit) doesn't block the others in the batch. */ +export type QuizBatchGenerateEntry = + | { moduleId: string; success: true; quiz: QuizWithQuestions } + | { moduleId: string; success: false; error: string }; + export interface QuizSubmissionResult { id: string; score: number; diff --git a/src/test/course-waitlist.test.ts b/src/test/course-waitlist.test.ts new file mode 100644 index 0000000..f2d57f2 --- /dev/null +++ b/src/test/course-waitlist.test.ts @@ -0,0 +1,145 @@ +/** + * Tests for dropEnrollment and the waitlist-notification gap-fill (#310). + * + * Join/leave/status for the waitlist itself (POST/DELETE/GET + * /api/v1/courses/:id/waitlist) are already covered by + * tests/e2e/course-waitlist.test.ts against WaitlistService (added + * alongside #320/#323). This file covers the piece that was still + * missing: CourseService.dropEnrollment — the companion action to + * enroll() needed to give "a spot opens up" concrete meaning — and that + * it identifies the head of the waitlist via WaitlistService and records + * it (there's currently no notifications table to write a user-facing + * notification to, so this asserts the audit-log record instead). + */ +import { test, describe, expect, beforeEach, afterEach } from "vitest"; +import { courseService } from "../modules/courses/course.service.js"; +import { waitlistService } from "../modules/courses/waitlist.service.js"; +import { NotFoundError } from "../utils/errors.js"; +import { db } from "../config/database.js"; +import { redis } from "../config/redis.js"; +import { + courses, + enrollments, + users, + enrollmentWaitlist, + auditLogs, +} from "../database/schema.js"; +import { eq, inArray, and, desc } from "drizzle-orm"; + +describe("CourseService.dropEnrollment + waitlist notification gap-fill (#310)", () => { + const courseId = "d9999999-2222-4b92-b60d-8848db490a22"; + + const userAId = "d9999999-1111-4ef8-bb6d-6bb9bd380a01"; + const userBId = "d9999999-1111-4ef8-bb6d-6bb9bd380a02"; + const userIds = [userAId, userBId]; + + let infraAvailable = true; + + beforeEach(async () => { + try { + await redis.flushdb(); + + await db + .insert(users) + .values([ + { id: userAId, stellarAddress: "GWAITLIST0000000000000000000000000000000000000000000A", displayName: "Waitlist A" }, + { id: userBId, stellarAddress: "GWAITLIST0000000000000000000000000000000000000000000B", displayName: "Waitlist B" }, + ]) + .onConflictDoNothing(); + + await db + .insert(courses) + .values({ + id: courseId, + title: "Waitlist Test Course", + description: "For #310 tests", + difficulty: "beginner", + isActive: true, + }) + .onConflictDoNothing(); + } catch { + infraAvailable = false; + } + }); + + afterEach(async () => { + if (!infraAvailable) return; + await db.delete(auditLogs).where(eq(auditLogs.event, "course.waitlist.notified")); + await db.delete(auditLogs).where(eq(auditLogs.event, "course.enrollment_dropped")); + await db.delete(enrollmentWaitlist).where(eq(enrollmentWaitlist.courseId, courseId)); + await db.delete(enrollments).where(eq(enrollments.courseId, courseId)); + await db.delete(courses).where(eq(courses.id, courseId)); + await db.delete(users).where(inArray(users.id, userIds)); + }); + + test("throws NotFoundError dropping an enrollment that doesn't exist", async () => { + if (!infraAvailable) return; + await expect(courseService.dropEnrollment(userAId, courseId)).rejects.toThrow( + NotFoundError, + ); + }); + + test("deletes the enrollment row", async () => { + if (!infraAvailable) return; + await db.insert(enrollments).values({ userId: userAId, courseId }).onConflictDoNothing(); + + await courseService.dropEnrollment(userAId, courseId); + + const [enrollment] = await db + .select() + .from(enrollments) + .where(eq(enrollments.userId, userAId)); + expect(enrollment).toBeUndefined(); + }); + + test("identifies the waitlist head via WaitlistService and records it in the audit log", async () => { + if (!infraAvailable) return; + await db.insert(enrollments).values({ userId: userAId, courseId }).onConflictDoNothing(); + await waitlistService.joinWaitlist(userBId, courseId); + + await courseService.dropEnrollment(userAId, courseId); + + const [entry] = await db + .select() + .from(auditLogs) + .where(and(eq(auditLogs.event, "course.waitlist.notified"))) + .orderBy(desc(auditLogs.createdAt)) + .limit(1); + + expect(entry).toBeDefined(); + expect(entry.fields).toMatchObject({ userId: userBId, courseId }); + + // userB stays queued — they're only removed once they actually enroll. + const stillWaiting = await db + .select() + .from(enrollmentWaitlist) + .where(eq(enrollmentWaitlist.userId, userBId)); + expect(stillWaiting).toHaveLength(1); + }); + + test("does not throw and records nothing when the waitlist is empty", async () => { + if (!infraAvailable) return; + await db.insert(enrollments).values({ userId: userAId, courseId }).onConflictDoNothing(); + + await expect(courseService.dropEnrollment(userAId, courseId)).resolves.toBeUndefined(); + + const notified = await db + .select() + .from(auditLogs) + .where(eq(auditLogs.event, "course.waitlist.notified")); + expect(notified).toHaveLength(0); + }); + + test("enrolling removes the user from the waitlist (WaitlistService.removeFromWaitlist, called by enroll())", async () => { + if (!infraAvailable) return; + await waitlistService.joinWaitlist(userAId, courseId); + + await courseService.enroll(userAId, courseId); + + const remaining = await db + .select() + .from(enrollmentWaitlist) + .where(eq(enrollmentWaitlist.userId, userAId)); + expect(remaining).toHaveLength(0); + }); +}); diff --git a/src/test/quiz-generate-batch.test.ts b/src/test/quiz-generate-batch.test.ts new file mode 100644 index 0000000..859f44e --- /dev/null +++ b/src/test/quiz-generate-batch.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for POST /api/v1/quizzes/generate-batch (#308). + */ +import { test, describe, expect, beforeEach, afterEach } from "vitest"; +import { db } from "../config/database.js"; +import { redis } from "../config/redis.js"; +import { quizService } from "../modules/quizzes/quiz.service.js"; +import { ForbiddenError } from "../utils/errors.js"; +import { courses, enrollments, users, quizzes } from "../database/schema.js"; +import { eq } from "drizzle-orm"; + +describe("POST /api/v1/quizzes/generate-batch (#308)", () => { + const userId = "b8888888-1111-4ef8-bb6d-6bb9bd380a11"; + const stellarAddress = "GBATCHGEN0000000000000000000000000000000000000000000A"; + + const courseId = "b8888888-2222-4b92-b60d-8848db490a22"; + const moduleOneId = "batch-module-1"; + const moduleTwoId = "batch-module-2"; + + let infraAvailable = true; + + beforeEach(async () => { + try { + await redis.flushdb(); + + await db + .insert(users) + .values({ id: userId, stellarAddress, displayName: "Batch Gen Test User" }) + .onConflictDoNothing(); + + await db + .insert(courses) + .values({ + id: courseId, + title: "Batch Generation Test Course", + description: "For #308 tests", + difficulty: "beginner", + isActive: true, + }) + .onConflictDoNothing(); + + await db + .insert(enrollments) + .values({ userId, courseId }) + .onConflictDoNothing(); + } catch { + infraAvailable = false; + } + }); + + afterEach(async () => { + if (!infraAvailable) return; + await db.delete(quizzes).where(eq(quizzes.courseId, courseId)); + await db.delete(enrollments).where(eq(enrollments.userId, userId)); + await db.delete(courses).where(eq(courses.id, courseId)); + await db.delete(users).where(eq(users.id, userId)); + }); + + test("generates a quiz for each requested module independently", async () => { + if (!infraAvailable) return; + + const results = await quizService.generateQuizBatch(userId, { + courseId, + moduleIds: [moduleOneId, moduleTwoId], + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ moduleId: moduleOneId, success: true }); + expect(results[1]).toMatchObject({ moduleId: moduleTwoId, success: true }); + expect(results[0].success && results[0].quiz.moduleId).toBe(moduleOneId); + expect(results[1].success && results[1].quiz.moduleId).toBe(moduleTwoId); + }); + + test("a failure on one module doesn't block the rest of the batch", async () => { + if (!infraAvailable) return; + + // A user who isn't enrolled fails generateQuiz's enrollment check for + // every module — each entry should report the failure independently + // rather than the whole batch throwing. + const notEnrolledUserId = "b8888888-3333-4b92-b60d-8848db490a33"; + await db + .insert(users) + .values({ + id: notEnrolledUserId, + stellarAddress: "GBATCHGEN0000000000000000000000000000000000000000000B", + displayName: "Not Enrolled", + }) + .onConflictDoNothing(); + + const results = await quizService.generateQuizBatch(notEnrolledUserId, { + courseId, + moduleIds: [moduleOneId, moduleTwoId], + }); + + expect(results).toHaveLength(2); + for (const result of results) { + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("enrolled"); + } + } + + await db.delete(users).where(eq(users.id, notEnrolledUserId)); + }); + + test("throwing generateQuiz directly still surfaces ForbiddenError (sanity check for the batch's error message)", async () => { + if (!infraAvailable) return; + + const notEnrolledUserId = "b8888888-4444-4b92-b60d-8848db490a44"; + await expect( + quizService.generateQuiz(notEnrolledUserId, { courseId, moduleId: moduleOneId }), + ).rejects.toThrow(ForbiddenError); + }); + + test("reuses an existing quiz for a module rather than regenerating it", async () => { + if (!infraAvailable) return; + + const [existingQuiz] = await db + .insert(quizzes) + .values({ + courseId, + moduleId: moduleOneId, + questions: [{ id: "q1", text: "2+2?", options: ["3", "4"], correctIndex: 1 }], + generatedFor: userId, + }) + .returning(); + + const results = await quizService.generateQuizBatch(userId, { + courseId, + moduleIds: [moduleOneId], + }); + + expect(results[0]).toMatchObject({ moduleId: moduleOneId, success: true }); + expect(results[0].success && results[0].quiz.id).toBe(existingQuiz.id); + }); +});