From 6c150f753eeba8ca663587d1d7483a358c35e0ff Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 19 Dec 2025 11:18:35 +0100 Subject: [PATCH] test: add tests for leveling service --- src/modules/leveling/leveling.service.test.ts | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 src/modules/leveling/leveling.service.test.ts diff --git a/src/modules/leveling/leveling.service.test.ts b/src/modules/leveling/leveling.service.test.ts new file mode 100644 index 0000000..08e896f --- /dev/null +++ b/src/modules/leveling/leveling.service.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, mock, beforeEach, afterEach, setSystemTime } from "bun:test"; +import { levelingService } from "./leveling.service"; +import { users, userTimers } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; + +// 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.getXpForLevel(1)).toBe(100); + expect(levelingService.getXpForLevel(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: 20n, 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. + }); + }); +});