Compare commits
8 Commits
bdb8456f34
...
1f7679e5a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f7679e5a1 | ||
|
|
4e228bb7a3 | ||
|
|
95d5202d7f | ||
|
|
6c150f753e | ||
|
|
c881b305f0 | ||
|
|
ae5ef4c802 | ||
|
|
2b365cb96d | ||
|
|
bcbbcaa6a4 |
209
src/modules/class/class.service.test.ts
Normal file
209
src/modules/class/class.service.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { classService } from "./class.service";
|
||||||
|
import { classes, users } from "@/db/schema";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
// Define mock functions
|
||||||
|
const mockFindMany = mock();
|
||||||
|
const mockFindFirst = mock();
|
||||||
|
const mockInsert = mock();
|
||||||
|
const mockUpdate = mock();
|
||||||
|
const mockDelete = mock();
|
||||||
|
const mockValues = mock();
|
||||||
|
const mockReturning = mock();
|
||||||
|
const mockSet = mock();
|
||||||
|
const mockWhere = mock();
|
||||||
|
|
||||||
|
// Chainable mock setup
|
||||||
|
mockInsert.mockReturnValue({ values: mockValues });
|
||||||
|
mockValues.mockReturnValue({ returning: mockReturning });
|
||||||
|
|
||||||
|
mockUpdate.mockReturnValue({ set: mockSet });
|
||||||
|
mockSet.mockReturnValue({ where: mockWhere });
|
||||||
|
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||||
|
|
||||||
|
mockDelete.mockReturnValue({ where: mockWhere }); // Fix for delete chaining if needed, usually delete(table).where(...)
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
mock.module("@/lib/DrizzleClient", () => {
|
||||||
|
return {
|
||||||
|
DrizzleClient: {
|
||||||
|
query: {
|
||||||
|
classes: {
|
||||||
|
findMany: mockFindMany,
|
||||||
|
findFirst: mockFindFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
transaction: async (cb: any) => {
|
||||||
|
return cb({
|
||||||
|
query: {
|
||||||
|
classes: {
|
||||||
|
findMany: mockFindMany,
|
||||||
|
findFirst: mockFindFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFindMany.mockReset();
|
||||||
|
mockFindFirst.mockReset();
|
||||||
|
mockInsert.mockClear();
|
||||||
|
mockUpdate.mockClear();
|
||||||
|
mockDelete.mockClear();
|
||||||
|
mockValues.mockClear();
|
||||||
|
mockReturning.mockClear();
|
||||||
|
mockSet.mockClear();
|
||||||
|
mockWhere.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllClasses", () => {
|
||||||
|
it("should return all classes", async () => {
|
||||||
|
const mockClasses = [{ id: 1n, name: "Warrior" }, { id: 2n, name: "Mage" }];
|
||||||
|
mockFindMany.mockResolvedValue(mockClasses);
|
||||||
|
|
||||||
|
const result = await classService.getAllClasses();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockClasses as any);
|
||||||
|
expect(mockFindMany).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assignClass", () => {
|
||||||
|
it("should assign class to user if class exists", async () => {
|
||||||
|
const mockClass = { id: 1n, name: "Warrior" };
|
||||||
|
const mockUser = { id: 123n, classId: 1n };
|
||||||
|
|
||||||
|
mockFindFirst.mockResolvedValue(mockClass);
|
||||||
|
mockReturning.mockResolvedValue([mockUser]);
|
||||||
|
|
||||||
|
const result = await classService.assignClass("123", 1n);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUser as any);
|
||||||
|
// Verify class check
|
||||||
|
expect(mockFindFirst).toHaveBeenCalledTimes(1);
|
||||||
|
// Verify update
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||||
|
expect(mockSet).toHaveBeenCalledWith({ classId: 1n });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if class not found", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
expect(classService.assignClass("123", 99n)).rejects.toThrow("Class not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getClassBalance", () => {
|
||||||
|
it("should return class balance", async () => {
|
||||||
|
const mockClass = { id: 1n, balance: 100n };
|
||||||
|
mockFindFirst.mockResolvedValue(mockClass);
|
||||||
|
|
||||||
|
const result = await classService.getClassBalance(1n);
|
||||||
|
|
||||||
|
expect(result).toBe(100n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0n if class has no balance or not found", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(null);
|
||||||
|
const result = await classService.getClassBalance(1n);
|
||||||
|
expect(result).toBe(0n);
|
||||||
|
|
||||||
|
mockFindFirst.mockResolvedValue({ id: 1n, balance: null });
|
||||||
|
const result2 = await classService.getClassBalance(1n);
|
||||||
|
expect(result2).toBe(0n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("modifyClassBalance", () => {
|
||||||
|
it("should modify class balance successfully", async () => {
|
||||||
|
const mockClass = { id: 1n, balance: 100n };
|
||||||
|
const updatedClass = { id: 1n, balance: 150n };
|
||||||
|
|
||||||
|
mockFindFirst.mockResolvedValue(mockClass);
|
||||||
|
mockReturning.mockResolvedValue([updatedClass]);
|
||||||
|
|
||||||
|
const result = await classService.modifyClassBalance(1n, 50n);
|
||||||
|
|
||||||
|
expect(result).toEqual(updatedClass as any);
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(classes);
|
||||||
|
// Note: sql template literal matching might be tricky, checking strict call might fail if not exact object ref
|
||||||
|
// We verify at least mockSet was called
|
||||||
|
expect(mockSet).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if class not found", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
|
expect(classService.modifyClassBalance(1n, 50n)).rejects.toThrow("Class not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if insufficient funds", async () => {
|
||||||
|
const mockClass = { id: 1n, balance: 10n };
|
||||||
|
mockFindFirst.mockResolvedValue(mockClass);
|
||||||
|
|
||||||
|
expect(classService.modifyClassBalance(1n, -20n)).rejects.toThrow("Insufficient class funds");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateClass", () => {
|
||||||
|
it("should update class details", async () => {
|
||||||
|
const updateData = { name: "Super Warrior" };
|
||||||
|
const updatedClass = { id: 1n, name: "Super Warrior" };
|
||||||
|
|
||||||
|
mockReturning.mockResolvedValue([updatedClass]);
|
||||||
|
|
||||||
|
const result = await classService.updateClass(1n, updateData);
|
||||||
|
|
||||||
|
expect(result).toEqual(updatedClass as any);
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(classes);
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(updateData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createClass", () => {
|
||||||
|
it("should create a new class", async () => {
|
||||||
|
const newClassData = { name: "Archer", description: "Bow user" };
|
||||||
|
const createdClass = { id: 3n, ...newClassData };
|
||||||
|
|
||||||
|
mockReturning.mockResolvedValue([createdClass]);
|
||||||
|
|
||||||
|
const result = await classService.createClass(newClassData as any);
|
||||||
|
|
||||||
|
expect(result).toEqual(createdClass as any);
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(classes);
|
||||||
|
expect(mockValues).toHaveBeenCalledWith(newClassData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteClass", () => {
|
||||||
|
it("should delete a class", async () => {
|
||||||
|
mockDelete.mockReturnValue({ where: mockWhere });
|
||||||
|
// The chain is delete(table).where(...) for delete
|
||||||
|
|
||||||
|
// Wait, in user.service.test.ts:
|
||||||
|
// mockDelete called without chain setup in the file provided?
|
||||||
|
// "mockDelete = mock()"
|
||||||
|
// And in mock: "delete: mockDelete"
|
||||||
|
// And in usage: "await txFn.delete(users).where(...)"
|
||||||
|
// So mockDelete must return an object with where.
|
||||||
|
|
||||||
|
mockDelete.mockReturnValue({ where: mockWhere });
|
||||||
|
|
||||||
|
await classService.deleteClass(1n);
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith(classes);
|
||||||
|
// We can't easily check 'where' arguments specifically without complex matcher if we don't return specific mock
|
||||||
|
expect(mockWhere).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
193
src/modules/economy/economy.service.test.ts
Normal file
193
src/modules/economy/economy.service.test.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test";
|
||||||
|
import { economyService } from "./economy.service";
|
||||||
|
import { users, userTimers, transactions } from "@/db/schema";
|
||||||
|
|
||||||
|
// Define mock functions
|
||||||
|
const mockFindMany = mock();
|
||||||
|
const mockFindFirst = mock();
|
||||||
|
const mockInsert = mock();
|
||||||
|
const mockUpdate = mock();
|
||||||
|
const mockDelete = mock();
|
||||||
|
const mockValues = mock();
|
||||||
|
const mockReturning = mock();
|
||||||
|
const mockSet = mock();
|
||||||
|
const mockWhere = mock();
|
||||||
|
const mockOnConflictDoUpdate = mock();
|
||||||
|
|
||||||
|
// Chainable mock setup
|
||||||
|
mockInsert.mockReturnValue({ values: mockValues });
|
||||||
|
mockValues.mockReturnValue({
|
||||||
|
returning: mockReturning,
|
||||||
|
onConflictDoUpdate: mockOnConflictDoUpdate // For claimDaily chain
|
||||||
|
});
|
||||||
|
mockOnConflictDoUpdate.mockResolvedValue({}); // Terminate the chain
|
||||||
|
|
||||||
|
mockUpdate.mockReturnValue({ set: mockSet });
|
||||||
|
mockSet.mockReturnValue({ where: mockWhere });
|
||||||
|
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
mock.module("@/lib/DrizzleClient", () => {
|
||||||
|
// Mock Transaction Object Structure
|
||||||
|
const createMockTx = () => ({
|
||||||
|
query: {
|
||||||
|
users: { findFirst: mockFindFirst },
|
||||||
|
userTimers: { findFirst: mockFindFirst },
|
||||||
|
},
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
DrizzleClient: {
|
||||||
|
query: {
|
||||||
|
users: { findFirst: mockFindFirst },
|
||||||
|
userTimers: { findFirst: mockFindFirst },
|
||||||
|
},
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
transaction: async (cb: any) => {
|
||||||
|
return cb(createMockTx());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Config
|
||||||
|
mock.module("@/lib/config", () => ({
|
||||||
|
config: {
|
||||||
|
economy: {
|
||||||
|
daily: {
|
||||||
|
amount: 100n,
|
||||||
|
streakBonus: 10n,
|
||||||
|
cooldownMs: 86400000, // 24 hours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("economyService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFindFirst.mockReset();
|
||||||
|
mockInsert.mockClear();
|
||||||
|
mockUpdate.mockClear();
|
||||||
|
mockDelete.mockClear();
|
||||||
|
mockValues.mockClear();
|
||||||
|
mockReturning.mockClear();
|
||||||
|
mockSet.mockClear();
|
||||||
|
mockWhere.mockClear();
|
||||||
|
mockOnConflictDoUpdate.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transfer", () => {
|
||||||
|
it("should transfer amount successfully", async () => {
|
||||||
|
const sender = { id: 1n, balance: 200n };
|
||||||
|
mockFindFirst.mockResolvedValue(sender);
|
||||||
|
|
||||||
|
const result = await economyService.transfer("1", "2", 50n);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, amount: 50n });
|
||||||
|
|
||||||
|
// Check sender update
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||||
|
// We can check if mockSet was called twice
|
||||||
|
expect(mockSet).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Check transactions created
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if amount is non-positive", async () => {
|
||||||
|
expect(economyService.transfer("1", "2", 0n)).rejects.toThrow("Amount must be positive");
|
||||||
|
expect(economyService.transfer("1", "2", -10n)).rejects.toThrow("Amount must be positive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if transferring to self", async () => {
|
||||||
|
expect(economyService.transfer("1", "1", 50n)).rejects.toThrow("Cannot transfer to self");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if sender not found", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
|
expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Sender not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if insufficient funds", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n });
|
||||||
|
expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Insufficient funds");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("claimDaily", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setSystemTime(new Date("2023-01-01T12:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should claim daily reward successfully", async () => {
|
||||||
|
const recentPast = new Date("2023-01-01T11:00:00Z"); // 1 hour ago
|
||||||
|
|
||||||
|
// First call finds cooldown (expired recently), second finds user
|
||||||
|
mockFindFirst
|
||||||
|
.mockResolvedValueOnce({ expiresAt: recentPast }) // Cooldown check - expired -> ready
|
||||||
|
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n }); // User check
|
||||||
|
|
||||||
|
const result = await economyService.claimDaily("1");
|
||||||
|
|
||||||
|
expect(result.claimed).toBe(true);
|
||||||
|
// Streak should increase: 5 + 1 = 6
|
||||||
|
expect(result.streak).toBe(6);
|
||||||
|
// Base 100 + (6-1)*10 = 150
|
||||||
|
expect(result.amount).toBe(150n);
|
||||||
|
|
||||||
|
// Check updates
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(userTimers);
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if cooldown is active", async () => {
|
||||||
|
const future = new Date("2023-01-02T12:00:00Z"); // +24h
|
||||||
|
mockFindFirst.mockResolvedValue({ expiresAt: future });
|
||||||
|
|
||||||
|
expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reset streak if missed a day (long time gap)", async () => {
|
||||||
|
// Expired 3 days ago
|
||||||
|
const past = new Date("2023-01-01T00:00:00Z"); // now is 12:00
|
||||||
|
// Wait, logic says: if (timeSinceReady > 24h)
|
||||||
|
// now - expiresAt.
|
||||||
|
// If cooldown expired 2022-12-30. Now is 2023-01-01. Gap is > 24h.
|
||||||
|
|
||||||
|
const expiredAt = new Date("2022-12-30T12:00:00Z");
|
||||||
|
|
||||||
|
mockFindFirst
|
||||||
|
.mockResolvedValueOnce({ expiresAt: expiredAt })
|
||||||
|
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5 });
|
||||||
|
|
||||||
|
const result = await economyService.claimDaily("1");
|
||||||
|
|
||||||
|
// timeSinceReady = 48h.
|
||||||
|
// streak = (5+1) - floor(48h / 24h) = 6 - 2 = 4.
|
||||||
|
expect(result.streak).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("modifyUserBalance", () => {
|
||||||
|
it("should add balance successfully", async () => {
|
||||||
|
mockReturning.mockResolvedValue([{ id: 1n, balance: 150n }]);
|
||||||
|
|
||||||
|
const result = await economyService.modifyUserBalance("1", 50n, "TEST", "Test add");
|
||||||
|
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if insufficient funds when negative", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n });
|
||||||
|
|
||||||
|
expect(economyService.modifyUserBalance("1", -50n, "TEST", "Test sub")).rejects.toThrow("Insufficient funds");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
217
src/modules/economy/lootdrop.service.test.ts
Normal file
217
src/modules/economy/lootdrop.service.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
|
import { lootdropService } from "./lootdrop.service";
|
||||||
|
import { lootdrops } from "@/db/schema";
|
||||||
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
|
import { economyService } from "./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("@/lib/DrizzleClient", () => {
|
||||||
|
return {
|
||||||
|
DrizzleClient: {
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
select: mockSelect,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Config
|
||||||
|
mock.module("@/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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
242
src/modules/inventory/inventory.service.test.ts
Normal file
242
src/modules/inventory/inventory.service.test.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
|
import { inventoryService } from "./inventory.service";
|
||||||
|
import { inventory, items, userTimers } from "@/db/schema";
|
||||||
|
// Helper to mock resolved value for spyOn
|
||||||
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
|
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mockFindFirst = mock();
|
||||||
|
const mockFindMany = mock();
|
||||||
|
const mockInsert = mock();
|
||||||
|
const mockUpdate = mock();
|
||||||
|
const mockDelete = mock();
|
||||||
|
const mockValues = mock();
|
||||||
|
const mockReturning = mock();
|
||||||
|
const mockSet = mock();
|
||||||
|
const mockWhere = mock();
|
||||||
|
const mockSelect = mock();
|
||||||
|
const mockFrom = mock();
|
||||||
|
const mockOnConflictDoUpdate = mock();
|
||||||
|
|
||||||
|
// Chain setup
|
||||||
|
mockInsert.mockReturnValue({ values: mockValues });
|
||||||
|
mockValues.mockReturnValue({
|
||||||
|
returning: mockReturning,
|
||||||
|
onConflictDoUpdate: mockOnConflictDoUpdate
|
||||||
|
});
|
||||||
|
mockOnConflictDoUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockUpdate.mockReturnValue({ set: mockSet });
|
||||||
|
mockSet.mockReturnValue({ where: mockWhere });
|
||||||
|
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||||
|
|
||||||
|
mockDelete.mockReturnValue({ where: mockWhere });
|
||||||
|
|
||||||
|
mockSelect.mockReturnValue({ from: mockFrom });
|
||||||
|
mockFrom.mockReturnValue({ where: mockWhere });
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
mock.module("@/lib/DrizzleClient", () => {
|
||||||
|
const createMockTx = () => ({
|
||||||
|
query: {
|
||||||
|
inventory: { findFirst: mockFindFirst, findMany: mockFindMany },
|
||||||
|
items: { findFirst: mockFindFirst },
|
||||||
|
userTimers: { findFirst: mockFindFirst },
|
||||||
|
},
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
select: mockSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
DrizzleClient: {
|
||||||
|
...createMockTx(),
|
||||||
|
transaction: async (cb: any) => cb(createMockTx()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("@/lib/config", () => ({
|
||||||
|
config: {
|
||||||
|
inventory: {
|
||||||
|
maxStackSize: 100n,
|
||||||
|
maxSlots: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("inventoryService", () => {
|
||||||
|
let mockModifyUserBalance: any;
|
||||||
|
let mockAddXp: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFindFirst.mockReset();
|
||||||
|
mockFindMany.mockReset();
|
||||||
|
mockInsert.mockClear();
|
||||||
|
mockUpdate.mockClear();
|
||||||
|
mockDelete.mockClear();
|
||||||
|
mockValues.mockClear();
|
||||||
|
mockReturning.mockClear();
|
||||||
|
mockSet.mockClear();
|
||||||
|
mockWhere.mockClear();
|
||||||
|
mockSelect.mockClear();
|
||||||
|
mockFrom.mockClear();
|
||||||
|
mockOnConflictDoUpdate.mockClear();
|
||||||
|
|
||||||
|
// Setup Spies
|
||||||
|
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
|
||||||
|
mockAddXp = spyOn(levelingService, 'addXp').mockResolvedValue({} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockModifyUserBalance.mockRestore();
|
||||||
|
mockAddXp.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addItem", () => {
|
||||||
|
it("should add new item if slot available", async () => {
|
||||||
|
// Check existing (none) -> Check count (0) -> Insert
|
||||||
|
mockFindFirst.mockResolvedValue(null);
|
||||||
|
const mockCountResult = mock().mockResolvedValue([{ count: 0 }]);
|
||||||
|
mockFrom.mockReturnValue({ where: mockCountResult });
|
||||||
|
|
||||||
|
mockReturning.mockResolvedValue([{ itemId: 1, quantity: 5n }]);
|
||||||
|
|
||||||
|
const result = await inventoryService.addItem("1", 1, 5n);
|
||||||
|
|
||||||
|
expect(result).toEqual({ itemId: 1, quantity: 5n } as any);
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(inventory);
|
||||||
|
expect(mockValues).toHaveBeenCalledWith({
|
||||||
|
userId: 1n,
|
||||||
|
itemId: 1,
|
||||||
|
quantity: 5n
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stack existing item up to limit", async () => {
|
||||||
|
// Check existing (found with 10)
|
||||||
|
mockFindFirst.mockResolvedValue({ quantity: 10n });
|
||||||
|
mockReturning.mockResolvedValue([{ itemId: 1, quantity: 15n }]);
|
||||||
|
|
||||||
|
const result = await inventoryService.addItem("1", 1, 5n);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result?.quantity).toBe(15n);
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(inventory);
|
||||||
|
expect(mockSet).toHaveBeenCalledWith({ quantity: 15n });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if max stack exceeded", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({ quantity: 99n });
|
||||||
|
// Max is 100
|
||||||
|
expect(inventoryService.addItem("1", 1, 5n)).rejects.toThrow("Cannot exceed max stack size");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if inventory full", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const mockCountResult = mock().mockResolvedValue([{ count: 10 }]); // Max slots 10
|
||||||
|
mockFrom.mockReturnValue({ where: mockCountResult });
|
||||||
|
|
||||||
|
expect(inventoryService.addItem("1", 1, 1n)).rejects.toThrow("Inventory full");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeItem", () => {
|
||||||
|
it("should decrease quantity if enough", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({ quantity: 10n });
|
||||||
|
mockReturning.mockResolvedValue([{ quantity: 5n }]);
|
||||||
|
|
||||||
|
await inventoryService.removeItem("1", 1, 5n);
|
||||||
|
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(inventory);
|
||||||
|
// mockSet uses sql template, hard to check exact value, checking call presence
|
||||||
|
expect(mockSet).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delete item if quantity becomes 0", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({ quantity: 5n });
|
||||||
|
|
||||||
|
const result = await inventoryService.removeItem("1", 1, 5n);
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith(inventory);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result?.quantity).toBe(0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if insufficient quantity", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({ quantity: 2n });
|
||||||
|
expect(inventoryService.removeItem("1", 1, 5n)).rejects.toThrow("Insufficient item quantity");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buyItem", () => {
|
||||||
|
it("should buy item successfully", async () => {
|
||||||
|
const mockItem = { id: 1, name: "Potion", price: 100n };
|
||||||
|
mockFindFirst.mockResolvedValue(mockItem);
|
||||||
|
|
||||||
|
// For addItem internal call, we need to mock findFirst again or ensure it works.
|
||||||
|
// DrizzleClient.transaction calls callback.
|
||||||
|
// buyItem calls findFirst for item.
|
||||||
|
// buyItem calls modifyUserBalance.
|
||||||
|
// buyItem calls addItem.
|
||||||
|
|
||||||
|
// addItem calls findFirst for inventory.
|
||||||
|
|
||||||
|
// So mockFindFirst needs to return specific values in sequence.
|
||||||
|
mockFindFirst
|
||||||
|
.mockResolvedValueOnce(mockItem) // Item check
|
||||||
|
.mockResolvedValueOnce(null); // addItem -> existing check (null = new)
|
||||||
|
|
||||||
|
// addItem -> count check
|
||||||
|
const mockCountResult = mock().mockResolvedValue([{ count: 0 }]);
|
||||||
|
mockFrom.mockReturnValue({ where: mockCountResult });
|
||||||
|
|
||||||
|
mockReturning.mockResolvedValue([{}]);
|
||||||
|
|
||||||
|
const result = await inventoryService.buyItem("1", 1, 2n);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", -200n, 'PURCHASE', expect.stringContaining("Bought 2x"), null, expect.anything());
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(inventory); // from addItem
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useItem", () => {
|
||||||
|
it("should apply effects and consume item", async () => {
|
||||||
|
const mockItem = {
|
||||||
|
id: 1,
|
||||||
|
name: "XP Potion",
|
||||||
|
usageData: {
|
||||||
|
consume: true,
|
||||||
|
effects: [
|
||||||
|
{ type: "ADD_XP", amount: 100 },
|
||||||
|
{ type: "XP_BOOST", durationMinutes: 60, multiplier: 2.0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// inventory entry
|
||||||
|
mockFindFirst.mockResolvedValue({ quantity: 1n, item: mockItem });
|
||||||
|
|
||||||
|
// For removeItem:
|
||||||
|
// removeItem calls findFirst (inventory).
|
||||||
|
// So sequence:
|
||||||
|
// 1. useItem -> findFirst (inventory + item)
|
||||||
|
// 2. removeItem -> findFirst (inventory)
|
||||||
|
|
||||||
|
mockFindFirst
|
||||||
|
.mockResolvedValueOnce({ quantity: 1n, item: mockItem }) // useItem check
|
||||||
|
.mockResolvedValueOnce({ quantity: 1n }); // removeItem check
|
||||||
|
|
||||||
|
const result = await inventoryService.useItem("1", 1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockAddXp).toHaveBeenCalledWith("1", 100n, expect.anything());
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(userTimers); // XP Boost
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
151
src/modules/quest/quest.service.test.ts
Normal file
151
src/modules/quest/quest.service.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
|
import { questService } from "./quest.service";
|
||||||
|
import { userQuests } from "@/db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
|
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mockFindFirst = mock();
|
||||||
|
const mockFindMany = mock();
|
||||||
|
const mockInsert = mock();
|
||||||
|
const mockUpdate = mock();
|
||||||
|
const mockDelete = mock();
|
||||||
|
const mockValues = mock();
|
||||||
|
const mockReturning = mock();
|
||||||
|
const mockSet = mock();
|
||||||
|
const mockWhere = mock();
|
||||||
|
const mockOnConflictDoNothing = mock();
|
||||||
|
|
||||||
|
// Chain setup
|
||||||
|
mockInsert.mockReturnValue({ values: mockValues });
|
||||||
|
mockValues.mockReturnValue({
|
||||||
|
onConflictDoNothing: mockOnConflictDoNothing
|
||||||
|
});
|
||||||
|
mockOnConflictDoNothing.mockReturnValue({ returning: mockReturning });
|
||||||
|
|
||||||
|
mockUpdate.mockReturnValue({ set: mockSet });
|
||||||
|
mockSet.mockReturnValue({ where: mockWhere });
|
||||||
|
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
mock.module("@/lib/DrizzleClient", () => {
|
||||||
|
const createMockTx = () => ({
|
||||||
|
query: {
|
||||||
|
userQuests: { findFirst: mockFindFirst, findMany: mockFindMany },
|
||||||
|
},
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
DrizzleClient: {
|
||||||
|
...createMockTx(),
|
||||||
|
transaction: async (cb: any) => cb(createMockTx()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("questService", () => {
|
||||||
|
let mockModifyUserBalance: any;
|
||||||
|
let mockAddXp: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFindFirst.mockReset();
|
||||||
|
mockFindMany.mockReset();
|
||||||
|
mockInsert.mockClear();
|
||||||
|
mockUpdate.mockClear();
|
||||||
|
mockValues.mockClear();
|
||||||
|
mockReturning.mockClear();
|
||||||
|
mockSet.mockClear();
|
||||||
|
mockWhere.mockClear();
|
||||||
|
mockOnConflictDoNothing.mockClear();
|
||||||
|
|
||||||
|
// Setup Spies
|
||||||
|
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
|
||||||
|
mockAddXp = spyOn(levelingService, 'addXp').mockResolvedValue({} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockModifyUserBalance.mockRestore();
|
||||||
|
mockAddXp.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assignQuest", () => {
|
||||||
|
it("should assign quest", async () => {
|
||||||
|
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101 }]);
|
||||||
|
|
||||||
|
const result = await questService.assignQuest("1", 101);
|
||||||
|
|
||||||
|
expect(result).toEqual([{ userId: 1n, questId: 101 }] as any);
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(userQuests);
|
||||||
|
expect(mockValues).toHaveBeenCalledWith({
|
||||||
|
userId: 1n,
|
||||||
|
questId: 101,
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateProgress", () => {
|
||||||
|
it("should update progress", async () => {
|
||||||
|
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 50 }]);
|
||||||
|
|
||||||
|
const result = await questService.updateProgress("1", 101, 50);
|
||||||
|
|
||||||
|
expect(result).toEqual([{ userId: 1n, questId: 101, progress: 50 }] as any);
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(userQuests);
|
||||||
|
expect(mockSet).toHaveBeenCalledWith({ progress: 50 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("completeQuest", () => {
|
||||||
|
it("should complete quest and grant rewards", async () => {
|
||||||
|
const mockUserQuest = {
|
||||||
|
userId: 1n,
|
||||||
|
questId: 101,
|
||||||
|
completedAt: null,
|
||||||
|
quest: {
|
||||||
|
rewards: { balance: 100, xp: 50 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mockFindFirst.mockResolvedValue(mockUserQuest);
|
||||||
|
|
||||||
|
const result = await questService.completeQuest("1", 101);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.rewards.balance).toBe(100n);
|
||||||
|
expect(result.rewards.xp).toBe(50n);
|
||||||
|
|
||||||
|
// Check updates
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(userQuests);
|
||||||
|
expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) });
|
||||||
|
|
||||||
|
// Check service calls
|
||||||
|
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", 100n, 'QUEST_REWARD', expect.any(String), null, expect.anything());
|
||||||
|
expect(mockAddXp).toHaveBeenCalledWith("1", 50n, expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if quest not assigned", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(null);
|
||||||
|
expect(questService.completeQuest("1", 101)).rejects.toThrow("Quest not assigned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if already completed", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({ completedAt: new Date() });
|
||||||
|
expect(questService.completeQuest("1", 101)).rejects.toThrow("Quest already completed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUserQuests", () => {
|
||||||
|
it("should return user quests", async () => {
|
||||||
|
const mockData = [{ questId: 1 }, { questId: 2 }];
|
||||||
|
mockFindMany.mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
const result = await questService.getUserQuests("1");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockData as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
181
src/modules/trade/trade.service.test.ts
Normal file
181
src/modules/trade/trade.service.test.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
|
import { TradeService } from "./trade.service";
|
||||||
|
import { itemTransactions } from "@/db/schema";
|
||||||
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mockInsert = mock();
|
||||||
|
const mockValues = mock();
|
||||||
|
|
||||||
|
mockInsert.mockReturnValue({ values: mockValues });
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
mock.module("@/lib/DrizzleClient", () => {
|
||||||
|
return {
|
||||||
|
DrizzleClient: {
|
||||||
|
transaction: async (cb: any) => {
|
||||||
|
const txMock = {
|
||||||
|
insert: mockInsert, // For transaction logs
|
||||||
|
};
|
||||||
|
return cb(txMock);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TradeService", () => {
|
||||||
|
const userA = { id: "1", username: "UserA" };
|
||||||
|
const userB = { id: "2", username: "UserB" };
|
||||||
|
|
||||||
|
let mockModifyUserBalance: any;
|
||||||
|
let mockAddItem: any;
|
||||||
|
let mockRemoveItem: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockInsert.mockClear();
|
||||||
|
mockValues.mockClear();
|
||||||
|
|
||||||
|
// Clear sessions
|
||||||
|
(TradeService as any).sessions.clear();
|
||||||
|
|
||||||
|
// Spies
|
||||||
|
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
|
||||||
|
mockAddItem = spyOn(inventoryService, 'addItem').mockResolvedValue({} as any);
|
||||||
|
mockRemoveItem = spyOn(inventoryService, 'removeItem').mockResolvedValue({} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockModifyUserBalance.mockRestore();
|
||||||
|
mockAddItem.mockRestore();
|
||||||
|
mockRemoveItem.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("createSession", () => {
|
||||||
|
it("should create a new session", () => {
|
||||||
|
const session = TradeService.createSession("thread1", userA, userB);
|
||||||
|
|
||||||
|
expect(session.threadId).toBe("thread1");
|
||||||
|
expect(session.state).toBe("NEGOTIATING");
|
||||||
|
expect(session.userA.id).toBe("1");
|
||||||
|
expect(session.userB.id).toBe("2");
|
||||||
|
expect(TradeService.getSession("thread1")).toBe(session);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateMoney", () => {
|
||||||
|
it("should update money offer", () => {
|
||||||
|
TradeService.createSession("thread1", userA, userB);
|
||||||
|
TradeService.updateMoney("thread1", "1", 100n);
|
||||||
|
|
||||||
|
const session = TradeService.getSession("thread1");
|
||||||
|
expect(session?.userA.offer.money).toBe(100n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unlock participants when offer changes", () => {
|
||||||
|
const session = TradeService.createSession("thread1", userA, userB);
|
||||||
|
session.userA.locked = true;
|
||||||
|
session.userB.locked = true;
|
||||||
|
|
||||||
|
TradeService.updateMoney("thread1", "1", 100n);
|
||||||
|
|
||||||
|
expect(session.userA.locked).toBe(false);
|
||||||
|
expect(session.userB.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if not in trade", () => {
|
||||||
|
TradeService.createSession("thread1", userA, userB);
|
||||||
|
expect(() => TradeService.updateMoney("thread1", "3", 100n)).toThrow("User not in trade");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addItem", () => {
|
||||||
|
it("should add item to offer", () => {
|
||||||
|
TradeService.createSession("thread1", userA, userB);
|
||||||
|
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
|
||||||
|
|
||||||
|
const session = TradeService.getSession("thread1");
|
||||||
|
expect(session?.userA.offer.items).toHaveLength(1);
|
||||||
|
expect(session?.userA.offer.items[0]).toEqual({ id: 10, name: "Sword", quantity: 1n });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stack items if already offered", () => {
|
||||||
|
TradeService.createSession("thread1", userA, userB);
|
||||||
|
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
|
||||||
|
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 2n);
|
||||||
|
|
||||||
|
const session = TradeService.getSession("thread1");
|
||||||
|
expect(session?.userA.offer.items[0]!.quantity).toBe(3n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeItem", () => {
|
||||||
|
it("should remove item from offer", () => {
|
||||||
|
const session = TradeService.createSession("thread1", userA, userB);
|
||||||
|
session.userA.offer.items.push({ id: 10, name: "Sword", quantity: 1n });
|
||||||
|
|
||||||
|
TradeService.removeItem("thread1", "1", 10);
|
||||||
|
|
||||||
|
expect(session.userA.offer.items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleLock", () => {
|
||||||
|
it("should toggle lock status", () => {
|
||||||
|
TradeService.createSession("thread1", userA, userB);
|
||||||
|
|
||||||
|
const locked1 = TradeService.toggleLock("thread1", "1");
|
||||||
|
expect(locked1).toBe(true);
|
||||||
|
|
||||||
|
const locked2 = TradeService.toggleLock("thread1", "1");
|
||||||
|
expect(locked2).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executeTrade", () => {
|
||||||
|
it("should execute trade successfully", async () => {
|
||||||
|
const session = TradeService.createSession("thread1", userA, userB);
|
||||||
|
|
||||||
|
// Setup offers
|
||||||
|
session.userA.offer.money = 100n;
|
||||||
|
session.userA.offer.items = [{ id: 10, name: "Sword", quantity: 1n }];
|
||||||
|
|
||||||
|
session.userB.offer.money = 50n; // B paying 50 back? Or just swap.
|
||||||
|
session.userB.offer.items = [];
|
||||||
|
|
||||||
|
// Lock both
|
||||||
|
session.userA.locked = true;
|
||||||
|
session.userB.locked = true;
|
||||||
|
|
||||||
|
await TradeService.executeTrade("thread1");
|
||||||
|
|
||||||
|
expect(session.state).toBe("COMPLETED");
|
||||||
|
|
||||||
|
// Verify Money Transfer A -> B (100)
|
||||||
|
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", -100n, 'TRADE_OUT', expect.any(String), "2", expect.anything());
|
||||||
|
expect(mockModifyUserBalance).toHaveBeenCalledWith("2", 100n, 'TRADE_IN', expect.any(String), "1", expect.anything());
|
||||||
|
|
||||||
|
// Verify Money Transfer B -> A (50)
|
||||||
|
expect(mockModifyUserBalance).toHaveBeenCalledWith("2", -50n, 'TRADE_OUT', expect.any(String), "1", expect.anything());
|
||||||
|
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", 50n, 'TRADE_IN', expect.any(String), "2", expect.anything());
|
||||||
|
|
||||||
|
// Verify Item Transfer A -> B (Sword)
|
||||||
|
expect(mockRemoveItem).toHaveBeenCalledWith("1", 10, 1n, expect.anything());
|
||||||
|
expect(mockAddItem).toHaveBeenCalledWith("2", 10, 1n, expect.anything());
|
||||||
|
|
||||||
|
// Verify DB Logs (Item Transaction)
|
||||||
|
// 2 calls (sender log, receiver log) for 1 item
|
||||||
|
expect(mockInsert).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(itemTransactions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if not locked", async () => {
|
||||||
|
const session = TradeService.createSession("thread1", userA, userB);
|
||||||
|
session.userA.locked = true;
|
||||||
|
// B not locked
|
||||||
|
|
||||||
|
expect(TradeService.executeTrade("thread1")).rejects.toThrow("Both players must accept");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user