Files
discord-rpg-concept/src/modules/inventory/inventory.service.test.ts

243 lines
8.6 KiB
TypeScript

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
});
});
});