refactor: initial moves
This commit is contained in:
282
shared/modules/inventory/inventory.service.test.ts
Normal file
282
shared/modules/inventory/inventory.service.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { inventory, userTimers } from "@db/schema";
|
||||
// Helper to mock resolved value for spyOn
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { levelingService } from "@shared/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();
|
||||
const mockInnerJoin = mock();
|
||||
const mockLimit = 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, innerJoin: mockInnerJoin });
|
||||
mockInnerJoin.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
|
||||
mockLimit.mockResolvedValue([]);
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@shared/db/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
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutocompleteItems", () => {
|
||||
it("should return formatted autocomplete results with rarity", async () => {
|
||||
const mockItems = [
|
||||
{
|
||||
item: { id: 1, name: "Common Sword", rarity: "Common", usageData: { effects: [{}] } },
|
||||
quantity: 5n
|
||||
},
|
||||
{
|
||||
item: { id: 2, name: "Epic Shield", rarity: "Epic", usageData: { effects: [{}] } },
|
||||
quantity: 1n
|
||||
}
|
||||
];
|
||||
|
||||
mockLimit.mockResolvedValue(mockItems);
|
||||
|
||||
// Restore mocks that might have been polluted by other tests
|
||||
mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
|
||||
|
||||
const result = await inventoryService.getAutocompleteItems("1", "Sw");
|
||||
|
||||
expect(mockSelect).toHaveBeenCalled();
|
||||
expect(mockFrom).toHaveBeenCalledWith(inventory);
|
||||
expect(mockInnerJoin).toHaveBeenCalled(); // checks join
|
||||
expect(mockWhere).toHaveBeenCalled(); // checks filters
|
||||
expect(mockLimit).toHaveBeenCalledWith(20);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.name).toBe("Common Sword (5) [Common]");
|
||||
expect(result[0]?.value).toBe(1);
|
||||
expect(result[1]?.name).toBe("Epic Shield (1) [Epic]");
|
||||
expect(result[1]?.value).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
209
shared/modules/inventory/inventory.service.ts
Normal file
209
shared/modules/inventory/inventory.service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { inventory, items, users, userTimers } from "@db/schema";
|
||||
import { eq, and, sql, count, ilike } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import { config } from "@/lib/config";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction, ItemUsageData } from "@shared/lib/types";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
|
||||
|
||||
|
||||
export const inventoryService = {
|
||||
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
// Check if item exists in inventory
|
||||
const existing = await txFn.query.inventory.findFirst({
|
||||
where: and(
|
||||
eq(inventory.userId, BigInt(userId)),
|
||||
eq(inventory.itemId, itemId)
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const newQuantity = (existing.quantity ?? 0n) + quantity;
|
||||
if (newQuantity > config.inventory.maxStackSize) {
|
||||
throw new UserError(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
|
||||
}
|
||||
|
||||
const [entry] = await txFn.update(inventory)
|
||||
.set({
|
||||
quantity: newQuantity,
|
||||
})
|
||||
.where(and(
|
||||
eq(inventory.userId, BigInt(userId)),
|
||||
eq(inventory.itemId, itemId)
|
||||
))
|
||||
.returning();
|
||||
return entry;
|
||||
} else {
|
||||
// Check Slot Limit
|
||||
const [inventoryCount] = await txFn
|
||||
.select({ count: count() })
|
||||
.from(inventory)
|
||||
.where(eq(inventory.userId, BigInt(userId)));
|
||||
|
||||
if (inventoryCount && inventoryCount.count >= config.inventory.maxSlots) {
|
||||
throw new UserError(`Inventory full (Max ${config.inventory.maxSlots} slots)`);
|
||||
}
|
||||
|
||||
if (quantity > config.inventory.maxStackSize) {
|
||||
throw new UserError(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
|
||||
}
|
||||
|
||||
const [entry] = await txFn.insert(inventory)
|
||||
.values({
|
||||
userId: BigInt(userId),
|
||||
itemId: itemId,
|
||||
quantity: quantity,
|
||||
})
|
||||
.returning();
|
||||
return entry;
|
||||
}
|
||||
}, tx);
|
||||
},
|
||||
|
||||
removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const existing = await txFn.query.inventory.findFirst({
|
||||
where: and(
|
||||
eq(inventory.userId, BigInt(userId)),
|
||||
eq(inventory.itemId, itemId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing || (existing.quantity ?? 0n) < quantity) {
|
||||
throw new UserError("Insufficient item quantity");
|
||||
}
|
||||
|
||||
if ((existing.quantity ?? 0n) === quantity) {
|
||||
// Delete if quantity becomes 0
|
||||
await txFn.delete(inventory)
|
||||
.where(and(
|
||||
eq(inventory.userId, BigInt(userId)),
|
||||
eq(inventory.itemId, itemId)
|
||||
));
|
||||
return { itemId, quantity: 0n, userId: BigInt(userId) };
|
||||
} else {
|
||||
const [entry] = await txFn.update(inventory)
|
||||
.set({
|
||||
quantity: sql`${inventory.quantity} - ${quantity}`,
|
||||
})
|
||||
.where(and(
|
||||
eq(inventory.userId, BigInt(userId)),
|
||||
eq(inventory.itemId, itemId)
|
||||
))
|
||||
.returning();
|
||||
return entry;
|
||||
}
|
||||
}, tx);
|
||||
},
|
||||
|
||||
getInventory: async (userId: string) => {
|
||||
return await DrizzleClient.query.inventory.findMany({
|
||||
where: eq(inventory.userId, BigInt(userId)),
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const item = await txFn.query.items.findFirst({
|
||||
where: eq(items.id, itemId),
|
||||
});
|
||||
|
||||
if (!item) throw new UserError("Item not found");
|
||||
if (!item.price) throw new UserError("Item is not for sale");
|
||||
|
||||
const totalPrice = item.price * quantity;
|
||||
|
||||
// Deduct Balance using economy service (passing tx ensures atomicity)
|
||||
await economyService.modifyUserBalance(userId, -totalPrice, TransactionType.PURCHASE, `Bought ${quantity}x ${item.name}`, null, txFn);
|
||||
|
||||
await inventoryService.addItem(userId, itemId, quantity, txFn);
|
||||
|
||||
return { success: true, item, totalPrice };
|
||||
}, tx);
|
||||
},
|
||||
|
||||
getItem: async (itemId: number) => {
|
||||
return await DrizzleClient.query.items.findFirst({
|
||||
where: eq(items.id, itemId),
|
||||
});
|
||||
},
|
||||
|
||||
useItem: async (userId: string, itemId: number, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
// 1. Check Ownership & Quantity
|
||||
const entry = await txFn.query.inventory.findFirst({
|
||||
where: and(
|
||||
eq(inventory.userId, BigInt(userId)),
|
||||
eq(inventory.itemId, itemId)
|
||||
),
|
||||
with: { item: true }
|
||||
});
|
||||
|
||||
if (!entry || (entry.quantity ?? 0n) < 1n) {
|
||||
throw new UserError("You do not own this item.");
|
||||
}
|
||||
|
||||
const item = entry.item;
|
||||
const usageData = item.usageData as ItemUsageData | null;
|
||||
|
||||
if (!usageData || !usageData.effects || usageData.effects.length === 0) {
|
||||
throw new UserError("This item cannot be used.");
|
||||
}
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
// 2. Apply Effects
|
||||
const { effectHandlers } = await import("./effects/registry");
|
||||
|
||||
for (const effect of usageData.effects) {
|
||||
const handler = effectHandlers[effect.type];
|
||||
if (handler) {
|
||||
const result = await handler(userId, effect, txFn);
|
||||
results.push(result);
|
||||
} else {
|
||||
console.warn(`No handler found for effect type: ${effect.type}`);
|
||||
results.push(`Effect ${effect.type} applied (no description)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Consume
|
||||
if (usageData.consume) {
|
||||
await inventoryService.removeItem(userId, itemId, 1n, txFn);
|
||||
}
|
||||
|
||||
return { success: true, results, usageData, item };
|
||||
}, tx);
|
||||
},
|
||||
|
||||
getAutocompleteItems: async (userId: string, query: string) => {
|
||||
const entries = await DrizzleClient.select({
|
||||
quantity: inventory.quantity,
|
||||
item: items
|
||||
})
|
||||
.from(inventory)
|
||||
.innerJoin(items, eq(inventory.itemId, items.id))
|
||||
.where(and(
|
||||
eq(inventory.userId, BigInt(userId)),
|
||||
ilike(items.name, `%${query}%`)
|
||||
))
|
||||
.limit(20);
|
||||
|
||||
const filtered = entries.filter((entry: any) => {
|
||||
const usageData = entry.item.usageData as ItemUsageData | null;
|
||||
return usageData && usageData.effects && usageData.effects.length > 0;
|
||||
});
|
||||
|
||||
return filtered.map((entry: any) => ({
|
||||
name: `${entry.item.name} (${entry.quantity}) [${entry.item.rarity || 'Common'}]`,
|
||||
value: entry.item.id
|
||||
}));
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user