import { describe, it, expect, mock, beforeEach, afterEach, setSystemTime } from "bun:test"; import { levelingService } from "./leveling.service"; import { users, userTimers } from "@/db/schema"; // Mock dependencies const mockFindFirst = mock(); const mockUpdate = mock(); const mockSet = mock(); const mockWhere = mock(); const mockReturning = mock(); const mockInsert = mock(); const mockValues = mock(); const mockOnConflictDoUpdate = mock(); // Chain setup mockUpdate.mockReturnValue({ set: mockSet }); mockSet.mockReturnValue({ where: mockWhere }); mockWhere.mockReturnValue({ returning: mockReturning }); mockInsert.mockReturnValue({ values: mockValues }); mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }); mockOnConflictDoUpdate.mockResolvedValue({}); mock.module("@/lib/DrizzleClient", () => { const createMockTx = () => ({ query: { users: { findFirst: mockFindFirst }, userTimers: { findFirst: mockFindFirst }, }, update: mockUpdate, insert: mockInsert, }); return { DrizzleClient: { ...createMockTx(), transaction: async (cb: any) => cb(createMockTx()), } }; }); mock.module("@/lib/config", () => ({ config: { leveling: { base: 100, exponent: 1.5, chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 } } } })); describe("levelingService", () => { let originalRandom: any; beforeEach(() => { mockFindFirst.mockReset(); mockUpdate.mockClear(); mockSet.mockClear(); mockWhere.mockClear(); mockReturning.mockClear(); mockInsert.mockClear(); mockValues.mockClear(); mockOnConflictDoUpdate.mockClear(); originalRandom = Math.random; }); afterEach(() => { Math.random = originalRandom; }); describe("getXpForLevel", () => { it("should calculate correct XP", () => { // base 100, exp 1.5 // lvl 1: 100 * 1^1.5 = 100 // lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282 expect(levelingService.getXpForNextLevel(1)).toBe(100); expect(levelingService.getXpForNextLevel(2)).toBe(282); }); }); describe("addXp", () => { it("should add XP without level up", async () => { // User current: level 1, xp 0 // Add 50 // Next level (1) needed: 100. (Note: Logic in service seems to use currentLevel for calculation of next step. // Service implementation: // let xpForNextLevel = ... getXpForLevel(currentLevel) // wait, if I am level 1, I need X XP to reach level 2? // Service code: // while (newXp >= xpForNextLevel) { ... currentLevel++ } // So if I am level 1, calling getXpForLevel(1) returns 100. // If I have 100 XP, I level up to 2. mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 }); mockReturning.mockResolvedValue([{ xp: 50n, level: 1 }]); const result = await levelingService.addXp("1", 50n); expect(result.levelUp).toBe(false); expect(result.currentLevel).toBe(1); expect(mockUpdate).toHaveBeenCalledWith(users); expect(mockSet).toHaveBeenCalledWith({ xp: 50n, level: 1 }); }); it("should level up if XP sufficient", async () => { // Current: Lvl 1, XP 0. Next Lvl needed: 100. // Add 120. // newXp = 120. // 120 >= 100. // newXp -= 100 -> 20. // currentLevel -> 2. // Next needed for Lvl 2 -> 282. // 20 < 282. Loop ends. mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 }); mockReturning.mockResolvedValue([{ xp: 20n, level: 2 }]); const result = await levelingService.addXp("1", 120n); expect(result.levelUp).toBe(true); expect(result.currentLevel).toBe(2); expect(mockSet).toHaveBeenCalledWith({ xp: 120n, level: 2 }); }); it("should handle multiple level ups", async () => { // Lvl 1 (100 needed). Lvl 2 (282 needed). Total for Lvl 3 = 100 + 282 = 382. // Add 400. // 400 >= 100 -> rem 300, Lvl 2. // 300 >= 282 -> rem 18, Lvl 3. mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 }); mockReturning.mockResolvedValue([{ xp: 18n, level: 3 }]); const result = await levelingService.addXp("1", 400n); expect(result.currentLevel).toBe(3); }); it("should throw if user not found", async () => { mockFindFirst.mockResolvedValue(undefined); expect(levelingService.addXp("1", 50n)).rejects.toThrow("User not found"); }); }); describe("processChatXp", () => { beforeEach(() => { setSystemTime(new Date("2023-01-01T12:00:00Z")); }); it("should award XP if no cooldown", async () => { mockFindFirst .mockResolvedValueOnce(undefined) // Cooldown check .mockResolvedValueOnce(undefined) // XP Boost check .mockResolvedValueOnce({ xp: 0n, level: 1 }); // addXp -> getUser mockReturning.mockResolvedValue([{ xp: 15n, level: 1 }]); // addXp -> update Math.random = () => 0.5; // mid range? 10-20. // floor(0.5 * (20 - 10 + 1)) + 10 = floor(0.5 * 11) + 10 = floor(5.5) + 10 = 15. const result = await levelingService.processChatXp("1"); expect(result.awarded).toBe(true); expect((result as any).amount).toBe(15n); expect(mockInsert).toHaveBeenCalledWith(userTimers); // Cooldown set }); it("should respect cooldown", async () => { const future = new Date("2023-01-01T12:00:10Z"); mockFindFirst.mockResolvedValue({ expiresAt: future }); const result = await levelingService.processChatXp("1"); expect(result.awarded).toBe(false); expect(result.reason).toBe("cooldown"); expect(mockUpdate).not.toHaveBeenCalled(); }); it("should apply XP boost", async () => { const now = new Date(); const future = new Date(now.getTime() + 10000); mockFindFirst .mockResolvedValueOnce(undefined) // Cooldown .mockResolvedValueOnce({ expiresAt: future, metadata: { multiplier: 2.0 } }) // Boost .mockResolvedValueOnce({ xp: 0n, level: 1 }); // User Math.random = () => 0.0; // Min value = 10. // Boost 2x -> 20. mockReturning.mockResolvedValue([{ xp: 20n, level: 1 }]); const result = await levelingService.processChatXp("1"); // Check if amount passed to addXp was boosted // Wait, result.amount is the returned amount from addXp ?? // processChatXp returns { awarded: true, amount, ...resultFromAddXp } // So result.amount is the calculated amount. expect((result as any).amount).toBe(20n); // Implementation: amount = floor(amount * multiplier) // min 10 * 2 = 20. }); }); });