import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"; import { lootdropService } 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 (lootdropService as any).channelActivity = new Map(); (lootdropService as any).channelCooldowns = new Map(); // Mock Math.random originalRandom = Math.random; // Spy mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any); }); afterEach(() => { Math.random = originalRandom; mockModifyUserBalance.mockRestore(); }); describe("processMessage", () => { it("should track activity but not spawn if minMessages not reached", async () => { const mockChannel = { id: "chan1", send: mock() }; const mockMessage = { author: { bot: false }, guild: {}, channel: mockChannel }; await lootdropService.processMessage(mockMessage as any); await lootdropService.processMessage(mockMessage as any); // Expect no spawn attempt expect(mockChannel.send).not.toHaveBeenCalled(); // Internal state check if possible, or just behavior }); it("should spawn lootdrop if minMessages reached and chance hits", async () => { const mockChannel = { id: "chan1", send: mock() }; const mockMessage = { author: { bot: false }, guild: {}, channel: mockChannel }; mockChannel.send.mockResolvedValue({ id: "msg1" }); Math.random = () => 0.01; // Force hit (0.01 < 0.5) // Send 3 messages await lootdropService.processMessage(mockMessage as any); await lootdropService.processMessage(mockMessage as any); await lootdropService.processMessage(mockMessage as any); expect(mockChannel.send).toHaveBeenCalled(); expect(mockInsert).toHaveBeenCalledWith(lootdrops); // Verify DB insert expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ channelId: "chan1", messageId: "msg1", currency: "GOLD" })); }); it("should not spawn if chance fails", async () => { const mockChannel = { id: "chan1", send: mock() }; const mockMessage = { author: { bot: false }, guild: {}, channel: mockChannel }; Math.random = () => 0.99; // Force fail (0.99 > 0.5) await lootdropService.processMessage(mockMessage as any); await lootdropService.processMessage(mockMessage as any); await lootdropService.processMessage(mockMessage as any); expect(mockChannel.send).not.toHaveBeenCalled(); }); it("should respect cooldowns", async () => { const mockChannel = { id: "chan1", send: mock() }; const mockMessage = { author: { bot: false }, guild: {}, channel: mockChannel }; mockChannel.send.mockResolvedValue({ id: "msg1" }); Math.random = () => 0.01; // Force hit // Trigger spawn await lootdropService.processMessage(mockMessage as any); await lootdropService.processMessage(mockMessage as any); await lootdropService.processMessage(mockMessage as any); expect(mockChannel.send).toHaveBeenCalledTimes(1); mockChannel.send.mockClear(); // Try again immediately (cooldown active) await lootdropService.processMessage(mockMessage as any); await lootdropService.processMessage(mockMessage as any); await lootdropService.processMessage(mockMessage as any); expect(mockChannel.send).not.toHaveBeenCalled(); }); }); 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."); }); }); });