import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"; import { inventoryService } from "./inventory.service"; import { inventory, 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 }); }); });