forked from syntaxbullet/AuroraBot-discord
test: add tests for leveling service
This commit is contained in:
210
src/modules/leveling/leveling.service.test.ts
Normal file
210
src/modules/leveling/leveling.service.test.ts
Normal file
@@ -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.
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user