| import { describe, expect, it, beforeAll, afterAll, mock } from "bun:test"; |
| import { drizzle } from "drizzle-orm/pglite"; |
| import * as schema from "../../../db/src/schema"; |
| import { closeTestPGlite, getTestPGlite } from "./test-setup"; |
|
|
| mock.module("@pram/env/server", () => ({ |
| env: { |
| DATABASE_URL: "postgres://localhost:5432/test", |
| BETTER_AUTH_SECRET: "a".repeat(32), |
| BETTER_AUTH_URL: "http://localhost:3000", |
| CORS_ORIGIN: "http://localhost:5173", |
| API_KEY_ENCRYPTION_KEY: "z".repeat(32), |
| REDIS_URL: "redis://localhost:6379", |
| SMTP_HOST: "localhost", |
| SMTP_USER: "test", |
| SMTP_PASS: "test", |
| SMTP_FROM: "test@test.com", |
| }, |
| })); |
|
|
| describe("attempt router", () => { |
| let attemptRouter: any; |
| let packageId: string; |
| let questionIds: string[]; |
| let testDb: any; |
|
|
| function makeCaller(userId: string) { |
| return attemptRouter.createCaller({ |
| session: { user: { id: userId }, expiresAt: new Date() }, |
| auth: null, |
| }); |
| } |
|
|
| async function createUserAndCaller(label: string) { |
| const id = `user-${label}-${Date.now()}`; |
| await testDb.insert(schema.user).values({ id, name: `User ${label}`, email: `${label}@test.com` }); |
| return { id, caller: makeCaller(id) }; |
| } |
|
|
| beforeAll(async () => { |
| const pg = await getTestPGlite(); |
| testDb = drizzle(pg, { schema }); |
|
|
| mock.module("@pram/db", () => ({ |
| __esModule: true, |
| ...schema, |
| db: testDb, |
| })); |
|
|
| const mod = await import("../routers/attempt"); |
| attemptRouter = mod.attemptRouter; |
|
|
| await testDb.insert(schema.examType).values([ |
| { id: "IELTS", name: "IELTS", language: "English" }, |
| ]); |
|
|
| await testDb.insert(schema.sectionType).values([ |
| { id: "READING", name: "Reading" }, |
| ]); |
|
|
| const ownerId = "owner-id"; |
| await testDb.insert(schema.user).values({ id: ownerId, name: "Owner", email: "owner@test.com" }); |
| const [pkg] = await testDb.insert(schema.testPackage).values({ |
| title: "IELTS Reading Test", |
| examTypeId: "IELTS", |
| creatorUserId: ownerId, |
| isPublic: true, |
| }).returning(); |
| packageId = pkg.id; |
|
|
| const [sec] = await testDb.insert(schema.packageSection).values({ |
| packageId: pkg.id, |
| sectionTypeId: "READING", |
| title: "Reading Section", |
| orderIndex: 0, |
| }).returning(); |
|
|
| const qs = await testDb.insert(schema.question).values([ |
| { |
| examTypeId: "IELTS", sectionTypeId: "READING", format: "multiple_choice", |
| passageText: "P".repeat(60), questionText: "What is the main idea?", |
| options: [{ key: "A", text: "Option A" }, { key: "B", text: "Option B" }, { key: "C", text: "Option C" }, { key: "D", text: "Option D" }], |
| correctAnswer: "A", explanation: "Because A is correct", difficulty: 2, |
| skillTags: ["main_idea"], creatorUserId: ownerId, isPublic: true, |
| }, |
| { |
| examTypeId: "IELTS", sectionTypeId: "READING", format: "true_false_not_given", |
| passageText: "P".repeat(60), questionText: "The author states that...", |
| correctAnswer: "TRUE", explanation: "It says so", difficulty: 2, |
| skillTags: ["detail"], creatorUserId: ownerId, isPublic: true, |
| }, |
| { |
| examTypeId: "IELTS", sectionTypeId: "READING", format: "fill_blank", |
| passageText: "P".repeat(60), questionText: "The answer is ___", |
| correctAnswer: "paradigm", explanation: "Context clue", difficulty: 3, |
| skillTags: ["vocabulary"], creatorUserId: ownerId, isPublic: true, |
| }, |
| ]).returning(); |
| questionIds = qs.map((q: any) => q.id); |
|
|
| await testDb.insert(schema.sectionQuestion).values([ |
| { sectionId: sec.id, questionId: qs[0]!.id, orderIndex: 0 }, |
| { sectionId: sec.id, questionId: qs[1]!.id, orderIndex: 1 }, |
| { sectionId: sec.id, questionId: qs[2]!.id, orderIndex: 2 }, |
| ]); |
| }); |
|
|
| afterAll(async () => { |
| await closeTestPGlite(); |
| }); |
|
|
| it("start creates a new attempt", async () => { |
| const { caller } = await createUserAndCaller("start"); |
| const result = await caller.start({ packageId }); |
| expect(result).toHaveProperty("attemptId"); |
| }); |
|
|
| it("start rejects duplicate in-progress attempt", { timeout: 15000 }, async () => { |
| const { caller } = await createUserAndCaller("dup"); |
| await caller.start({ packageId }); |
| await Bun.sleep(3100); |
| expect(caller.start({ packageId })).rejects.toThrow("sedang berjalan"); |
| }); |
|
|
| it("getById returns attempt with sections and questions", async () => { |
| const { caller } = await createUserAndCaller("getid"); |
| const { attemptId } = await caller.start({ packageId }); |
| const result = await caller.getById({ id: attemptId }); |
| expect(result.status).toBe("in_progress"); |
| expect(result.sections).toHaveLength(1); |
| expect(result.sections[0].questions).toHaveLength(3); |
| }); |
|
|
| it("getById strips correctAnswer during in_progress", async () => { |
| const { caller } = await createUserAndCaller("strip"); |
| const { attemptId } = await caller.start({ packageId }); |
| const result = await caller.getById({ id: attemptId }); |
| for (const s of result.sections) { |
| for (const q of s.questions) { |
| expect(q.correctAnswer).toBeUndefined(); |
| expect(q.explanation).toBeUndefined(); |
| } |
| } |
| }); |
|
|
| it("submitAnswer stores user answer", async () => { |
| const { caller } = await createUserAndCaller("submit"); |
| const { attemptId } = await caller.start({ packageId }); |
| const attempt = await caller.getById({ id: attemptId }); |
| const sectionResultId = attempt.sections[0].sectionResultId; |
|
|
| const result = await caller.submitAnswer({ |
| attemptId, sectionResultId, |
| questionId: questionIds[0]!, userAnswer: "A", |
| }); |
| expect(result.success).toBe(true); |
| }); |
|
|
| it("finish scores correct answers", { timeout: 30000 }, async () => { |
| const { caller } = await createUserAndCaller("score"); |
| const { attemptId } = await caller.start({ packageId }); |
| await Bun.sleep(4500); |
| const attempt = await caller.getById({ id: attemptId }); |
| const sectionResultId = attempt.sections[0].sectionResultId; |
|
|
| await caller.submitAnswer({ attemptId, sectionResultId, questionId: questionIds[0]!, userAnswer: "A" }); |
| await Bun.sleep(600); |
| await caller.submitAnswer({ attemptId, sectionResultId, questionId: questionIds[1]!, userAnswer: "FALSE" }); |
| await Bun.sleep(600); |
| await caller.submitAnswer({ attemptId, sectionResultId, questionId: questionIds[2]!, userAnswer: "wrong" }); |
|
|
| const result = await caller.finish({ attemptId }); |
| expect(result.totalScore).toBe(1); |
| expect(result.maxScore).toBe(3); |
| expect(result.percentage).toBe(33); |
| }); |
|
|
| it("finish sums maxScore across multiple sections", { timeout: 30000 }, async () => { |
| const ownerId = "multi-section-owner"; |
| await testDb.insert(schema.user).values({ |
| id: ownerId, |
| name: "Multi Section Owner", |
| email: `multi-section-${Date.now()}@test.com`, |
| }).onConflictDoNothing(); |
|
|
| const [pkg] = await testDb.insert(schema.testPackage).values({ |
| title: "Multi Section Test", |
| examTypeId: "IELTS", |
| creatorUserId: ownerId, |
| isPublic: true, |
| }).returning(); |
|
|
| const sections = await testDb.insert(schema.packageSection).values([ |
| { |
| packageId: pkg.id, |
| sectionTypeId: "READING", |
| title: "Reading Part 1", |
| orderIndex: 0, |
| }, |
| { |
| packageId: pkg.id, |
| sectionTypeId: "READING", |
| title: "Reading Part 2", |
| orderIndex: 1, |
| }, |
| ]).returning(); |
|
|
| const makeQuestion = (idx: number) => ({ |
| examTypeId: "IELTS", |
| sectionTypeId: "READING", |
| format: "multiple_choice" as const, |
| passageText: `Passage ${idx} `.repeat(10), |
| questionText: `Question ${idx}?`, |
| options: [ |
| { key: "A", text: "Option A" }, |
| { key: "B", text: "Option B" }, |
| { key: "C", text: "Option C" }, |
| { key: "D", text: "Option D" }, |
| ], |
| correctAnswer: "A", |
| explanation: "Because A is correct", |
| difficulty: 2, |
| skillTags: ["main_idea"], |
| creatorUserId: ownerId, |
| isPublic: true, |
| }); |
|
|
| const sectionOneQuestions = await testDb.insert(schema.question).values( |
| Array.from({ length: 10 }, (_, idx) => makeQuestion(idx + 1)), |
| ).returning(); |
| const sectionTwoQuestions = await testDb.insert(schema.question).values( |
| Array.from({ length: 10 }, (_, idx) => makeQuestion(idx + 11)), |
| ).returning(); |
|
|
| await testDb.insert(schema.sectionQuestion).values([ |
| ...sectionOneQuestions.map((q, orderIndex) => ({ |
| sectionId: sections[0]!.id, |
| questionId: q.id, |
| orderIndex, |
| })), |
| ...sectionTwoQuestions.map((q, orderIndex) => ({ |
| sectionId: sections[1]!.id, |
| questionId: q.id, |
| orderIndex, |
| })), |
| ]); |
|
|
| const { caller } = await createUserAndCaller("multisection"); |
| const { attemptId } = await caller.start({ packageId: pkg.id }); |
| await Bun.sleep(4500); |
|
|
| const result = await caller.finish({ attemptId }); |
| expect(result.maxScore).toBe(20); |
| expect(typeof result.maxScore).toBe("number"); |
| }); |
|
|
| it("finish rejects attempts under 5 seconds", async () => { |
| const { caller } = await createUserAndCaller("fastfinish"); |
| const { attemptId } = await caller.start({ packageId }); |
| expect(caller.finish({ attemptId })).rejects.toThrow("terlalu cepat"); |
| }); |
|
|
| it("getActiveAttempt returns in-progress attempt", async () => { |
| const { caller } = await createUserAndCaller("active"); |
| const { attemptId } = await caller.start({ packageId }); |
| const result = await caller.getActiveAttempt({ packageId }); |
| expect(result).not.toBeNull(); |
| expect(result.id).toBe(attemptId); |
| expect(result.status).toBe("in_progress"); |
| }); |
|
|
| it("abandon marks attempt as abandoned", async () => { |
| const { caller } = await createUserAndCaller("abandon"); |
| const { attemptId } = await caller.start({ packageId }); |
| const result = await caller.abandon({ attemptId }); |
| expect(result.success).toBe(true); |
| }); |
|
|
| it("myAttempts returns paginated list", async () => { |
| const { caller } = await createUserAndCaller("mylist"); |
| await caller.start({ packageId }); |
| const result = await caller.myAttempts(); |
| expect(result.attempts.length).toBeGreaterThan(0); |
| expect(result.total).toBeGreaterThan(0); |
| }); |
| }); |
|
|