diff --git a/shared/modules/trivia/trivia.service.test.ts b/shared/modules/trivia/trivia.service.test.ts index fe2f98c..2abc94c 100644 --- a/shared/modules/trivia/trivia.service.test.ts +++ b/shared/modules/trivia/trivia.service.test.ts @@ -1,13 +1,83 @@ -import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; +import { describe, it, expect, mock, beforeEach } from "bun:test"; import { triviaService } from "./trivia.service"; -import { DrizzleClient } from "@shared/db/DrizzleClient"; -import { users, userTimers } from "@db/schema"; -import { eq, and } from "drizzle-orm"; -import { config } from "@shared/lib/config"; +import { users, userTimers, transactions } from "@db/schema"; import { TimerType } from "@shared/lib/constants"; -// Mock fetch for OpenTDB API -const mockFetch = mock(() => Promise.resolve({ +// 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: [{ @@ -23,39 +93,25 @@ const mockFetch = mock(() => Promise.resolve({ ] }] }) -})); - -global.fetch = mockFetch as any; +})) as any; describe("TriviaService", () => { const TEST_USER_ID = "999999999"; const TEST_USERNAME = "testuser"; - beforeEach(async () => { - // Clean up test data - await DrizzleClient.delete(userTimers) - .where(eq(userTimers.userId, BigInt(TEST_USER_ID))); - - // Ensure test user exists with sufficient balance - await DrizzleClient.insert(users) - .values({ - id: BigInt(TEST_USER_ID), - username: TEST_USERNAME, - balance: 1000n, - xp: 0n, - }) - .onConflictDoUpdate({ - target: [users.id], - set: { - balance: 1000n, - } - }); - }); - - afterEach(async () => { - // Clean up - await DrizzleClient.delete(userTimers) - .where(eq(userTimers.userId, BigInt(TEST_USER_ID))); + 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", () => { @@ -66,176 +122,146 @@ describe("TriviaService", () => { expect(question.question).toBe('What is 2 + 2?'); expect(question.correctAnswer).toBe('4'); expect(question.incorrectAnswers).toHaveLength(3); - expect(question.type).toBe('multiple'); }); }); 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); - expect(result.nextAvailable).toBeUndefined(); }); it("should prevent playing when on cooldown", async () => { - const futureDate = new Date(Date.now() + 60000); - - await DrizzleClient.insert(userTimers).values({ - userId: BigInt(TEST_USER_ID), - type: TimerType.TRIVIA_COOLDOWN, - key: 'default', - expiresAt: futureDate, - }); + 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).toBeDefined(); + expect(result.nextAvailable).toBe(future); }); it("should allow playing when cooldown has expired", async () => { - const pastDate = new Date(Date.now() - 1000); - - await DrizzleClient.insert(userTimers).values({ - userId: BigInt(TEST_USER_ID), - type: TimerType.TRIVIA_COOLDOWN, - key: 'default', - expiresAt: pastDate, - }); + 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.sessionId).toContain(TEST_USER_ID); expect(session.userId).toBe(TEST_USER_ID); - expect(session.question).toBeDefined(); - expect(session.allAnswers).toHaveLength(4); - expect(session.entryFee).toBe(config.trivia.entryFee); - expect(session.potentialReward).toBeGreaterThan(0n); + expect(session.entryFee).toBe(50n); - // Verify balance deduction - const user = await DrizzleClient.query.users.findFirst({ - where: eq(users.id, BigInt(TEST_USER_ID)) - }); + // Check deduction + expect(mockUpdate).toHaveBeenCalledWith(users); + expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ + // sql templating makes exact match hard, checking general invocation + })); - expect(user?.balance).toBe(1000n - config.trivia.entryFee); + // Check transactions + expect(mockInsert).toHaveBeenCalledWith(transactions); - // Verify cooldown was set - const cooldown = await DrizzleClient.query.userTimers.findFirst({ - where: and( - eq(userTimers.userId, BigInt(TEST_USER_ID)), - eq(userTimers.type, TimerType.TRIVIA_COOLDOWN), - eq(userTimers.key, 'default') - ) - }); + // Check cooldown set + expect(mockInsert).toHaveBeenCalledWith(userTimers); + expect(mockOnConflictDoUpdate).toHaveBeenCalled(); - expect(cooldown).toBeDefined(); + // Check dashboard event + expect(mockRecordEvent).toHaveBeenCalled(); }); it("should throw error if user has insufficient balance", async () => { - // Set balance to less than entry fee - await DrizzleClient.update(users) - .set({ balance: 10n }) - .where(eq(users.id, BigInt(TEST_USER_ID))); + mockFindFirst + .mockResolvedValueOnce(undefined) // No cooldown + .mockResolvedValueOnce({ id: 1n, balance: 10n }); // Insufficient balance - await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME)) - .rejects.toThrow('Insufficient funds'); + expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME)) + .rejects.toThrow("Insufficient funds"); }); it("should throw error if user is on cooldown", async () => { - const futureDate = new Date(Date.now() + 60000); + mockFindFirst.mockResolvedValueOnce({ expiresAt: new Date(Date.now() + 60000) }); - await DrizzleClient.insert(userTimers).values({ - userId: BigInt(TEST_USER_ID), - type: TimerType.TRIVIA_COOLDOWN, - key: 'default', - expiresAt: futureDate, - }); - - await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME)) - .rejects.toThrow('cooldown'); + expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME)) + .rejects.toThrow("cooldown"); }); }); describe("submitAnswer", () => { it("should award prize for correct answer", async () => { - const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); - const balanceBefore = (await DrizzleClient.query.users.findFirst({ - where: eq(users.id, BigInt(TEST_USER_ID)) - }))!.balance!; + // 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); - const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true); + // 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(session.potentialReward); - expect(result.correctAnswer).toBe(session.question.correctAnswer); + expect(result.reward).toBe(100n); - // Verify balance increase - const user = await DrizzleClient.query.users.findFirst({ - where: eq(users.id, BigInt(TEST_USER_ID)) - }); - - expect(user?.balance).toBe(balanceBefore + session.potentialReward); + // 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 = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); - const balanceBefore = (await DrizzleClient.query.users.findFirst({ - where: eq(users.id, BigInt(TEST_USER_ID)) - }))!.balance!; + 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(session.sessionId, TEST_USER_ID, false); + const result = await triviaService.submitAnswer("test_session", TEST_USER_ID, false); expect(result.correct).toBe(false); expect(result.reward).toBe(0n); - expect(result.correctAnswer).toBe(session.question.correctAnswer); - // Verify balance unchanged (already deducted at start) - const user = await DrizzleClient.query.users.findFirst({ - where: eq(users.id, BigInt(TEST_USER_ID)) - }); - - expect(user?.balance).toBe(balanceBefore); + // No balance update + expect(mockUpdate).not.toHaveBeenCalled(); }); it("should throw error if session doesn't exist", async () => { - await expect(triviaService.submitAnswer("invalid_session", TEST_USER_ID, true)) - .rejects.toThrow('Session not found'); + expect(triviaService.submitAnswer("invalid", TEST_USER_ID, true)) + .rejects.toThrow("Session not found"); }); it("should prevent double submission", async () => { - const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); + const session = { + sessionId: "test_session", + userId: TEST_USER_ID, + question: { correctAnswer: "4" }, + potentialReward: 100n + }; + (triviaService as any).activeSessions.set("test_session", session); - await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true); + // Mock user for first success + mockFindFirst.mockResolvedValue({ id: 1n, balance: 950n }); - // Try to submit again - await expect(triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true)) - .rejects.toThrow('Session not found'); - }); - }); + await triviaService.submitAnswer("test_session", TEST_USER_ID, true); - describe("getSession", () => { - it("should retrieve active session", async () => { - const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); - const retrieved = triviaService.getSession(session.sessionId); - - expect(retrieved).toBeDefined(); - expect(retrieved?.sessionId).toBe(session.sessionId); - }); - - it("should return undefined for non-existent session", () => { - const retrieved = triviaService.getSession("invalid_session"); - - expect(retrieved).toBeUndefined(); + // Second try + expect(triviaService.submitAnswer("test_session", TEST_USER_ID, true)) + .rejects.toThrow("Session not found"); }); }); });