forked from syntaxbullet/AuroraBot-discord
test: add tests for inventory service
This commit is contained in:
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 } from "bun:test";
|
||||||
|
import { inventoryService } from "./inventory.service";
|
||||||
|
import { inventory, items, userTimers } from "@/db/schema";
|
||||||
|
import { app } from "@/index"; // We don't need app here, removed.
|
||||||
|
|
||||||
|
// 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()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockModifyUserBalance = mock();
|
||||||
|
mock.module("@/modules/economy/economy.service", () => ({
|
||||||
|
economyService: {
|
||||||
|
modifyUserBalance: mockModifyUserBalance
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAddXp = mock();
|
||||||
|
mock.module("@/modules/leveling/leveling.service", () => ({
|
||||||
|
levelingService: {
|
||||||
|
addXp: mockAddXp
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@/lib/config", () => ({
|
||||||
|
config: {
|
||||||
|
inventory: {
|
||||||
|
maxStackSize: 100n,
|
||||||
|
maxSlots: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("inventoryService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFindFirst.mockReset();
|
||||||
|
mockFindMany.mockReset();
|
||||||
|
mockInsert.mockClear();
|
||||||
|
mockUpdate.mockClear();
|
||||||
|
mockDelete.mockClear();
|
||||||
|
mockValues.mockClear();
|
||||||
|
mockReturning.mockClear();
|
||||||
|
mockSet.mockClear();
|
||||||
|
mockWhere.mockClear();
|
||||||
|
mockSelect.mockClear();
|
||||||
|
mockFrom.mockClear();
|
||||||
|
mockModifyUserBalance.mockClear();
|
||||||
|
mockAddXp.mockClear();
|
||||||
|
mockOnConflictDoUpdate.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
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.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.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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user