268 lines
9.3 KiB
TypeScript
268 lines
9.3 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|