Move terminal.service.ts and prune.service.ts entirely to bot/modules/ since they are Discord-specific. Split lootdrop.service.ts: pure logic (activity tracking, DB ops, claim) stays in shared/, Discord operations (message sending, channel interactions) move to bot/modules/economy/ lootdrop.handler.ts. Move effect registry/handlers/types from bot/ to shared/modules/inventory/ since they contain no Discord.js imports and are needed by inventory.service.ts in shared. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
6.8 KiB
TypeScript
206 lines
6.8 KiB
TypeScript
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.");
|
|
});
|
|
});
|
|
});
|