diff --git a/src/modules/inventory/inventory.service.test.ts b/src/modules/inventory/inventory.service.test.ts new file mode 100644 index 0000000..3fe8901 --- /dev/null +++ b/src/modules/inventory/inventory.service.test.ts @@ -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 + }); + }); +});