cpns / packages /api /src /__tests__ /attempt.integration.test.ts
pramudya-yb
Rename project from Labas to Pram
2b62b0c
Raw
History Blame Contribute Delete
10.4 kB
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); // wait for rate limit window (3000ms)
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);
});
});