import { describe, it, expect, mock, beforeEach } from "bun:test"; import { triviaService } from "./trivia.service"; import { users, userTimers, transactions } from "@db/schema"; import { TimerType } from "@shared/lib/constants"; // Define mock functions const mockFindFirst = mock(); const mockFindMany = mock(() => Promise.resolve([])); const mockInsert = mock(); const mockUpdate = mock(); const mockDelete = mock(); const mockValues = mock(); const mockReturning = mock(); const mockSet = mock(); const mockWhere = mock(); const mockOnConflictDoUpdate = mock(); const mockRecordEvent = mock(() => Promise.resolve()); // Chain setup mockInsert.mockReturnValue({ values: mockValues }); mockValues.mockReturnValue({ returning: mockReturning, onConflictDoUpdate: mockOnConflictDoUpdate }); mockOnConflictDoUpdate.mockResolvedValue({}); 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 }, userQuests: { findMany: mockFindMany }, }, insert: mockInsert, update: mockUpdate, delete: mockDelete, }); return { DrizzleClient: { query: { users: { findFirst: mockFindFirst }, userTimers: { findFirst: mockFindFirst }, }, insert: mockInsert, update: mockUpdate, delete: mockDelete, transaction: async (cb: any) => cb(createMockTx()) } }; }); // Mock Config mock.module("@shared/lib/config", () => ({ config: { trivia: { entryFee: 50n, rewardMultiplier: 2.0, timeoutSeconds: 300, cooldownMs: 60000, categories: [9], difficulty: 'medium' } } })); // Mock Dashboard Service mock.module("@shared/modules/dashboard/dashboard.service", () => ({ dashboardService: { recordEvent: mockRecordEvent } })); // Mock fetch for OpenTDB global.fetch = mock(() => Promise.resolve({ json: () => Promise.resolve({ response_code: 0, results: [{ category: Buffer.from('General Knowledge').toString('base64'), type: 'multiple', difficulty: Buffer.from('medium').toString('base64'), question: Buffer.from('What is 2 + 2?').toString('base64'), correct_answer: Buffer.from('4').toString('base64'), incorrect_answers: [ Buffer.from('3').toString('base64'), Buffer.from('5').toString('base64'), Buffer.from('22').toString('base64'), ] }] }) })) as any; describe("TriviaService", () => { const TEST_USER_ID = "999999999"; const TEST_USERNAME = "testuser"; beforeEach(() => { mockFindFirst.mockReset(); mockInsert.mockClear(); mockUpdate.mockClear(); mockDelete.mockClear(); mockValues.mockClear(); mockReturning.mockClear(); mockSet.mockClear(); mockWhere.mockClear(); mockOnConflictDoUpdate.mockClear(); mockRecordEvent.mockClear(); // Clear active sessions (triviaService as any).activeSessions.clear(); }); describe("fetchQuestion", () => { it("should fetch and decode a trivia question", async () => { const question = await triviaService.fetchQuestion(); expect(question).toBeDefined(); expect(question.question).toBe('What is 2 + 2?'); expect(question.correctAnswer).toBe('4'); expect(question.incorrectAnswers).toHaveLength(3); }); }); describe("canPlayTrivia", () => { it("should allow playing when no cooldown exists", async () => { mockFindFirst.mockResolvedValue(undefined); const result = await triviaService.canPlayTrivia(TEST_USER_ID); expect(result.canPlay).toBe(true); }); it("should prevent playing when on cooldown", async () => { const future = new Date(Date.now() + 60000); mockFindFirst.mockResolvedValue({ expiresAt: future }); const result = await triviaService.canPlayTrivia(TEST_USER_ID); expect(result.canPlay).toBe(false); expect(result.nextAvailable).toBe(future); }); it("should allow playing when cooldown has expired", async () => { const past = new Date(Date.now() - 1000); mockFindFirst.mockResolvedValue({ expiresAt: past }); const result = await triviaService.canPlayTrivia(TEST_USER_ID); expect(result.canPlay).toBe(true); }); }); describe("startTrivia", () => { it("should start a trivia session and deduct entry fee", async () => { // Mock cooldown check (first call) and balance check (second call) mockFindFirst .mockResolvedValueOnce(undefined) // No cooldown .mockResolvedValueOnce({ id: 1n, balance: 1000n }); // User balance const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); expect(session).toBeDefined(); expect(session.userId).toBe(TEST_USER_ID); expect(session.entryFee).toBe(50n); // Check deduction expect(mockUpdate).toHaveBeenCalledWith(users); expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ // sql templating makes exact match hard, checking general invocation })); // Check transactions expect(mockInsert).toHaveBeenCalledWith(transactions); // Check cooldown set expect(mockInsert).toHaveBeenCalledWith(userTimers); expect(mockOnConflictDoUpdate).toHaveBeenCalled(); // Check dashboard event expect(mockRecordEvent).toHaveBeenCalled(); }); it("should throw error if user has insufficient balance", async () => { mockFindFirst .mockResolvedValueOnce(undefined) // No cooldown .mockResolvedValueOnce({ id: 1n, balance: 10n }); // Insufficient balance expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME)) .rejects.toThrow("Insufficient funds"); }); it("should throw error if user is on cooldown", async () => { mockFindFirst.mockResolvedValueOnce({ expiresAt: new Date(Date.now() + 60000) }); expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME)) .rejects.toThrow("cooldown"); }); }); describe("submitAnswer", () => { it("should award prize for correct answer", async () => { // Setup an active session manually const session = { sessionId: "test_session", userId: TEST_USER_ID, question: { correctAnswer: "4" }, potentialReward: 100n }; (triviaService as any).activeSessions.set("test_session", session); // Mock user balance fetch for reward update mockFindFirst.mockResolvedValue({ id: 1n, balance: 950n }); const result = await triviaService.submitAnswer("test_session", TEST_USER_ID, true); expect(result.correct).toBe(true); expect(result.reward).toBe(100n); // Verify balance update expect(mockUpdate).toHaveBeenCalledWith(users); expect(mockInsert).toHaveBeenCalledWith(transactions); expect(mockRecordEvent).toHaveBeenCalled(); }); it("should not award prize for incorrect answer", async () => { const session = { sessionId: "test_session", userId: TEST_USER_ID, question: { correctAnswer: "4" }, potentialReward: 100n }; (triviaService as any).activeSessions.set("test_session", session); const result = await triviaService.submitAnswer("test_session", TEST_USER_ID, false); expect(result.correct).toBe(false); expect(result.reward).toBe(0n); // No balance update expect(mockUpdate).not.toHaveBeenCalled(); }); it("should throw error if session doesn't exist", async () => { expect(triviaService.submitAnswer("invalid", TEST_USER_ID, true)) .rejects.toThrow("Session not found"); }); it("should prevent double submission", async () => { const session = { sessionId: "test_session", userId: TEST_USER_ID, question: { correctAnswer: "4" }, potentialReward: 100n }; (triviaService as any).activeSessions.set("test_session", session); // Mock user for first success mockFindFirst.mockResolvedValue({ id: 1n, balance: 950n }); await triviaService.submitAnswer("test_session", TEST_USER_ID, true); // Second try expect(triviaService.submitAnswer("test_session", TEST_USER_ID, true)) .rejects.toThrow("Session not found"); }); }); });