import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test"; import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; import { users, userTimers, transactions } from "@db/schema"; // Define mock functions const mockFindFirst = mock(); const mockInsert = mock(); const mockUpdate = mock(); const mockValues = mock(); const mockReturning = mock(); const mockSet = mock(); const mockWhere = mock(); // Chainable mock setup mockInsert.mockReturnValue({ values: mockValues }); mockValues.mockReturnValue({ returning: mockReturning }); mockUpdate.mockReturnValue({ set: mockSet }); mockSet.mockReturnValue({ where: mockWhere }); mockWhere.mockReturnValue({ returning: mockReturning }); // Mock DrizzleClient mock.module("@shared/db/DrizzleClient", () => { const createMockTx = () => ({ query: { users: { findFirst: mockFindFirst }, userTimers: { findFirst: mockFindFirst }, }, insert: mockInsert, update: mockUpdate, }); return { DrizzleClient: { query: { users: { findFirst: mockFindFirst }, userTimers: { findFirst: mockFindFirst }, }, insert: mockInsert, update: mockUpdate, transaction: async (cb: any) => { return cb(createMockTx()); } }, }; }); // Mock withTransaction mock.module("@/lib/db", () => ({ withTransaction: async (cb: any, tx?: any) => { if (tx) return cb(tx); return cb({ query: { users: { findFirst: mockFindFirst }, userTimers: { findFirst: mockFindFirst }, }, insert: mockInsert, update: mockUpdate, }); } })); // Mock Config mock.module("@shared/lib/config", () => ({ config: { economy: { exam: { multMin: 1.0, multMax: 2.0, } } } })); // Mock User Service mock.module("@shared/modules/user/user.service", () => ({ userService: { getOrCreateUser: mock() } })); // Mock Dashboard Service mock.module("@shared/modules/dashboard/dashboard.service", () => ({ dashboardService: { recordEvent: mock() } })); describe("ExamService", () => { beforeEach(() => { mockFindFirst.mockReset(); mockInsert.mockClear(); mockUpdate.mockClear(); mockValues.mockClear(); mockReturning.mockClear(); mockSet.mockClear(); mockWhere.mockClear(); }); describe("getExamStatus", () => { it("should return NOT_REGISTERED if no timer exists", async () => { mockFindFirst.mockResolvedValue(undefined); const status = await examService.getExamStatus("1"); expect(status.status).toBe(ExamStatus.NOT_REGISTERED); }); it("should return COOLDOWN if now < expiresAt", async () => { const now = new Date("2024-01-10T12:00:00Z"); setSystemTime(now); const future = new Date("2024-01-11T00:00:00Z"); mockFindFirst.mockResolvedValue({ expiresAt: future, metadata: { examDay: 3, lastXp: "100" } }); const status = await examService.getExamStatus("1"); expect(status.status).toBe(ExamStatus.COOLDOWN); expect(status.nextExamAt?.getTime()).toBe(future.setHours(0,0,0,0)); }); it("should return MISSED if it is the wrong day", async () => { const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) setSystemTime(now); const past = new Date("2024-01-10T00:00:00Z"); // Wednesday (3) last week mockFindFirst.mockResolvedValue({ expiresAt: past, metadata: { examDay: 3, lastXp: "100" } // Registered for Wednesday }); const status = await examService.getExamStatus("1"); expect(status.status).toBe(ExamStatus.MISSED); expect(status.examDay).toBe(3); }); it("should return AVAILABLE if it is the correct day", async () => { const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3) setSystemTime(now); const past = new Date("2024-01-10T00:00:00Z"); mockFindFirst.mockResolvedValue({ expiresAt: past, metadata: { examDay: 3, lastXp: "100" } }); const status = await examService.getExamStatus("1"); expect(status.status).toBe(ExamStatus.AVAILABLE); expect(status.examDay).toBe(3); expect(status.lastXp).toBe(100n); }); }); describe("registerForExam", () => { it("should create user and timer correctly", async () => { const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) setSystemTime(now); const { userService } = await import("@shared/modules/user/user.service"); (userService.getOrCreateUser as any).mockResolvedValue({ id: 1n, xp: 500n }); const result = await examService.registerForExam("1", "testuser"); expect(result.status).toBe(ExamStatus.NOT_REGISTERED); expect(result.examDay).toBe(1); expect(mockInsert).toHaveBeenCalledWith(userTimers); expect(mockInsert).toHaveBeenCalledTimes(1); }); }); describe("takeExam", () => { it("should return NOT_REGISTERED if not registered", async () => { mockFindFirst.mockResolvedValueOnce({ id: 1n }) // user check .mockResolvedValueOnce(undefined); // timer check const result = await examService.takeExam("1"); expect(result.status).toBe(ExamStatus.NOT_REGISTERED); }); it("should handle missed exam and schedule for next exam day", async () => { const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) setSystemTime(now); const past = new Date("2024-01-10T00:00:00Z"); mockFindFirst.mockResolvedValueOnce({ id: 1n, xp: 600n }) // user .mockResolvedValueOnce({ expiresAt: past, metadata: { examDay: 3, lastXp: "500" } // Registered for Wednesday }); // timer const result = await examService.takeExam("1"); expect(result.status).toBe(ExamStatus.MISSED); expect(result.examDay).toBe(3); // Should set next exam to next Wednesday // Monday (1) + 2 days = Wednesday (3) const expected = new Date("2024-01-17T00:00:00Z"); expect(result.nextExamAt!.getTime()).toBe(expected.getTime()); expect(mockUpdate).toHaveBeenCalledWith(userTimers); }); it("should calculate rewards and update state when passed", async () => { const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3) setSystemTime(now); const past = new Date("2024-01-10T00:00:00Z"); mockFindFirst.mockResolvedValueOnce({ id: 1n, username: "testuser", xp: 1000n, balance: 0n }) // user .mockResolvedValueOnce({ expiresAt: past, metadata: { examDay: 3, lastXp: "500" } }); // timer const result = await examService.takeExam("1"); expect(result.status).toBe(ExamStatus.AVAILABLE); expect(result.xpDiff).toBe(500n); // Multiplier is between 1.0 and 2.0 based on mock config expect(result.multiplier).toBeGreaterThanOrEqual(1.0); expect(result.multiplier).toBeLessThanOrEqual(2.0); expect(result.reward).toBeGreaterThanOrEqual(500n); expect(result.reward).toBeLessThanOrEqual(1000n); expect(mockUpdate).toHaveBeenCalledWith(userTimers); expect(mockUpdate).toHaveBeenCalledWith(users); // Verify transaction expect(mockInsert).toHaveBeenCalledWith(transactions); expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ amount: result.reward, userId: 1n, type: expect.anything() })); }); }); });