import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"; import { lootdropService, channelActivity, channelCooldowns } from "@shared/modules/economy/lootdrop.service"; import { lootdrops } from "@db/schema"; import { economyService } from "@shared/modules/economy/economy.service"; // Mock dependencies BEFORE using service functionality const mockInsert = mock(); const mockUpdate = mock(); const mockDelete = mock(); const mockSelect = mock(); const mockValues = mock(); const mockReturning = mock(); const mockSet = mock(); const mockWhere = mock(); const mockFrom = mock(); // Mock setup mockInsert.mockReturnValue({ values: mockValues }); mockUpdate.mockReturnValue({ set: mockSet }); mockSet.mockReturnValue({ where: mockWhere }); mockWhere.mockReturnValue({ returning: mockReturning }); mockSelect.mockReturnValue({ from: mockFrom }); mockFrom.mockReturnValue({ where: mockWhere }); // Mock DrizzleClient mock.module("@shared/db/DrizzleClient", () => { return { DrizzleClient: { insert: mockInsert, update: mockUpdate, delete: mockDelete, select: mockSelect, } }; }); // Mock Config mock.module("@shared/lib/config", () => ({ config: { lootdrop: { activityWindowMs: 60000, minMessages: 3, spawnChance: 0.5, cooldownMs: 10000, reward: { min: 10, max: 100, currency: "GOLD" } } } })); describe("lootdropService", () => { let originalRandom: any; let mockModifyUserBalance: any; beforeEach(() => { mockInsert.mockClear(); mockUpdate.mockClear(); mockDelete.mockClear(); mockValues.mockClear(); mockReturning.mockClear(); mockSet.mockClear(); mockWhere.mockClear(); mockSelect.mockClear(); mockFrom.mockClear(); // Reset internal state channelActivity.clear(); channelCooldowns.clear(); // Mock Math.random originalRandom = Math.random; // Spy mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any); }); afterEach(() => { Math.random = originalRandom; mockModifyUserBalance.mockRestore(); }); describe("trackActivity", () => { it("should track activity but not spawn if minMessages not reached", () => { const result1 = lootdropService.trackActivity("chan1"); const result2 = lootdropService.trackActivity("chan1"); expect(result1.shouldSpawn).toBe(false); expect(result2.shouldSpawn).toBe(false); }); it("should spawn lootdrop if minMessages reached and chance hits", () => { Math.random = () => 0.01; // Force hit (0.01 < 0.5) // Send 3 messages lootdropService.trackActivity("chan1"); lootdropService.trackActivity("chan1"); const result = lootdropService.trackActivity("chan1"); expect(result.shouldSpawn).toBe(true); }); it("should not spawn if chance fails", () => { Math.random = () => 0.99; // Force fail (0.99 > 0.5) lootdropService.trackActivity("chan1"); lootdropService.trackActivity("chan1"); const result = lootdropService.trackActivity("chan1"); expect(result.shouldSpawn).toBe(false); }); it("should respect cooldowns", () => { Math.random = () => 0.01; // Force hit // Trigger spawn lootdropService.trackActivity("chan1"); lootdropService.trackActivity("chan1"); const result1 = lootdropService.trackActivity("chan1"); expect(result1.shouldSpawn).toBe(true); // Try again immediately (cooldown active) lootdropService.trackActivity("chan1"); lootdropService.trackActivity("chan1"); const result2 = lootdropService.trackActivity("chan1"); expect(result2.shouldSpawn).toBe(false); }); }); describe("calculateReward", () => { it("should return override values when provided", () => { const result = lootdropService.calculateReward(500, "SILVER"); expect(result.reward).toBe(500); expect(result.currency).toBe("SILVER"); }); it("should return random reward within range when no override", () => { const result = lootdropService.calculateReward(); expect(result.reward).toBeGreaterThanOrEqual(10); expect(result.reward).toBeLessThanOrEqual(100); expect(result.currency).toBe("GOLD"); }); }); describe("persistLootdrop", () => { it("should insert lootdrop into database", async () => { await lootdropService.persistLootdrop("msg1", "chan1", 50, "GOLD"); expect(mockInsert).toHaveBeenCalledWith(lootdrops); expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ messageId: "msg1", channelId: "chan1", rewardAmount: 50, currency: "GOLD" })); }); }); describe("tryClaim", () => { it("should claim successfully if available", async () => { mockReturning.mockResolvedValue([{ messageId: "1001", rewardAmount: 50, currency: "GOLD", channelId: "100" }]); const result = await lootdropService.tryClaim("1001", "123", "UserOne"); expect(result.success).toBe(true); expect(result.amount).toBe(50); expect(mockModifyUserBalance).toHaveBeenCalledWith("123", 50n, "LOOTDROP_CLAIM", expect.any(String)); }); it("should fail if already claimed", async () => { // Update returns empty (failed condition) mockReturning.mockResolvedValue([]); // Select check returns non-empty (exists) const mockWhereSelect = mock().mockResolvedValue([{ messageId: "1001", claimedBy: 123n }]); mockFrom.mockReturnValue({ where: mockWhereSelect }); const result = await lootdropService.tryClaim("1001", "123", "UserOne"); expect(result.success).toBe(false); expect(result.error).toBe("This lootdrop has already been claimed."); }); it("should fail if expired/not found", async () => { mockReturning.mockResolvedValue([]); const mockWhereSelect = mock().mockResolvedValue([]); // Empty result mockFrom.mockReturnValue({ where: mockWhereSelect }); const result = await lootdropService.tryClaim("1001", "123", "UserOne"); expect(result.success).toBe(false); expect(result.error).toBe("This lootdrop has expired."); }); }); });