import { beforeEach, describe, expect, it } from "bun:test"; import { globalItems } from "../../src/db/schema.ts"; import { createItem, deleteItem, duplicateItem, getAllItems, getItemById, updateItem, } from "../../src/server/services/item.service.ts"; import { createSecondTestUser, createTestDb } from "../helpers/db.ts"; describe("Item Service", () => { let db: any; let userId: number; beforeEach(async () => { ({ db, userId } = await createTestDb()); }); describe("createItem", () => { it("creates item with all fields, returns item with id and timestamps", async () => { const item = await createItem(db, userId, { name: "Tent", weightGrams: 1200, priceCents: 35000, categoryId: 1, notes: "Ultralight 2-person", productUrl: "https://example.com/tent", }); expect(item).toBeDefined(); expect(item?.id).toBeGreaterThan(0); expect(item?.name).toBe("Tent"); expect(item?.weightGrams).toBe(1200); expect(item?.priceCents).toBe(35000); expect(item?.categoryId).toBe(1); expect(item?.notes).toBe("Ultralight 2-person"); expect(item?.productUrl).toBe("https://example.com/tent"); expect(item?.createdAt).toBeDefined(); expect(item?.updatedAt).toBeDefined(); }); it("only name and categoryId are required, other fields optional", async () => { const item = await createItem(db, userId, { name: "Spork", categoryId: 1, }); expect(item).toBeDefined(); expect(item?.name).toBe("Spork"); expect(item?.weightGrams).toBeNull(); expect(item?.priceCents).toBeNull(); expect(item?.notes).toBeNull(); expect(item?.productUrl).toBeNull(); }); }); describe("getAllItems", () => { it("returns all items with category info joined", async () => { await createItem(db, userId, { name: "Tent", categoryId: 1 }); await createItem(db, userId, { name: "Sleeping Bag", categoryId: 1 }); const all = await getAllItems(db, userId); expect(all).toHaveLength(2); expect(all[0].categoryName).toBe("Uncategorized"); expect(all[0].categoryIcon).toBeDefined(); }); }); describe("getItemById", () => { it("returns single item or null", async () => { const created = await createItem(db, userId, { name: "Tent", categoryId: 1, }); const found = await getItemById(db, userId, created?.id); expect(found).toBeDefined(); expect(found?.name).toBe("Tent"); const notFound = await getItemById(db, userId, 9999); expect(notFound).toBeNull(); }); }); describe("updateItem", () => { it("updates specified fields, sets updatedAt", async () => { const created = await createItem(db, userId, { name: "Tent", weightGrams: 1200, categoryId: 1, }); const updated = await updateItem(db, userId, created?.id, { name: "Big Agnes Tent", weightGrams: 1100, }); expect(updated).toBeDefined(); expect(updated?.name).toBe("Big Agnes Tent"); expect(updated?.weightGrams).toBe(1100); }); it("returns null for non-existent id", async () => { const result = await updateItem(db, userId, 9999, { name: "Ghost" }); expect(result).toBeNull(); }); }); describe("duplicateItem", () => { it("creates a copy with '(copy)' suffix in name", async () => { const original = await createItem(db, userId, { name: "Tent", weightGrams: 1200, priceCents: 35000, categoryId: 1, notes: "Ultralight", productUrl: "https://example.com/tent", }); const copy = await duplicateItem(db, userId, original?.id); expect(copy).toBeDefined(); expect(copy?.name).toBe("Tent (copy)"); expect(copy?.weightGrams).toBe(1200); expect(copy?.priceCents).toBe(35000); expect(copy?.categoryId).toBe(1); expect(copy?.notes).toBe("Ultralight"); expect(copy?.productUrl).toBe("https://example.com/tent"); }); it("copy has a different ID from the original", async () => { const original = await createItem(db, userId, { name: "Helmet", categoryId: 1, }); const copy = await duplicateItem(db, userId, original?.id); expect(copy?.id).not.toBe(original?.id); }); it("returns null for non-existent item", async () => { const result = await duplicateItem(db, userId, 9999); expect(result).toBeNull(); }); }); describe("deleteItem", () => { it("removes item from DB, returns deleted item", async () => { const created = await createItem(db, userId, { name: "Tent", categoryId: 1, imageFilename: "tent.jpg", }); const deleted = await deleteItem(db, userId, created?.id); expect(deleted).toBeDefined(); expect(deleted?.name).toBe("Tent"); expect(deleted?.imageFilename).toBe("tent.jpg"); // Verify it's gone const found = await getItemById(db, userId, created?.id); expect(found).toBeNull(); }); it("returns null for non-existent id", async () => { const result = await deleteItem(db, userId, 9999); expect(result).toBeNull(); }); }); describe("reference items (globalItemId)", () => { async function insertGlobalItem( testDb: any, data: { brand: string; model: string; weightGrams?: number; priceCents?: number; imageUrl?: string; }, ) { const [row] = await testDb.insert(globalItems).values(data).returning(); return row; } it("createItem with globalItemId creates a reference item with globalItemId set", async () => { const gi = await insertGlobalItem(db, { brand: "Big Agnes", model: "Copper Spur HV UL2", weightGrams: 1270, priceCents: 44995, }); const item = await createItem(db, userId, { name: "placeholder", categoryId: 1, globalItemId: gi.id, }); expect(item).toBeDefined(); expect(item?.globalItemId).toBe(gi.id); }); it("createItem with globalItemId stores brand+model as fallback name", async () => { const gi = await insertGlobalItem(db, { brand: "MSR", model: "Hubba Hubba", weightGrams: 1540, }); const item = await createItem(db, userId, { name: "placeholder", categoryId: 1, globalItemId: gi.id, }); expect(item?.name).toBe("MSR Hubba Hubba"); }); it("getAllItems returns merged name from global item for reference items", async () => { const gi = await insertGlobalItem(db, { brand: "Nemo", model: "Tensor", weightGrams: 425, priceCents: 17995, }); await createItem(db, userId, { name: "placeholder", categoryId: 1, globalItemId: gi.id, }); const all = await getAllItems(db, userId); expect(all).toHaveLength(1); expect(all[0].name).toBe("Nemo Tensor"); }); it("getAllItems returns merged weightGrams from global item for reference items", async () => { const gi = await insertGlobalItem(db, { brand: "Nemo", model: "Tensor", weightGrams: 425, priceCents: 17995, }); await createItem(db, userId, { name: "placeholder", categoryId: 1, globalItemId: gi.id, }); const all = await getAllItems(db, userId); expect(all[0].weightGrams).toBe(425); }); it("getAllItems returns merged priceCents from global item for reference items", async () => { const gi = await insertGlobalItem(db, { brand: "Nemo", model: "Tensor", weightGrams: 425, priceCents: 17995, }); await createItem(db, userId, { name: "placeholder", categoryId: 1, globalItemId: gi.id, }); const all = await getAllItems(db, userId); expect(all[0].priceCents).toBe(17995); }); it("getAllItems returns item's own imageFilename when set, ignoring global imageUrl", async () => { const gi = await insertGlobalItem(db, { brand: "Nemo", model: "Tensor", imageUrl: "https://example.com/global.jpg", }); await createItem(db, userId, { name: "placeholder", categoryId: 1, globalItemId: gi.id, imageFilename: "local.jpg", }); const all = await getAllItems(db, userId); expect(all[0].imageFilename).toBe("local.jpg"); }); it("getAllItems falls back to global imageUrl when item has no imageFilename", async () => { const gi = await insertGlobalItem(db, { brand: "Nemo", model: "Tensor", imageUrl: "https://example.com/global.jpg", }); await createItem(db, userId, { name: "placeholder", categoryId: 1, globalItemId: gi.id, }); const all = await getAllItems(db, userId); expect(all[0].imageFilename).toBe("https://example.com/global.jpg"); }); it("getAllItems returns standalone item data unchanged", async () => { await createItem(db, userId, { name: "My Manual Tent", weightGrams: 1500, priceCents: 25000, categoryId: 1, }); const all = await getAllItems(db, userId); expect(all).toHaveLength(1); expect(all[0].name).toBe("My Manual Tent"); expect(all[0].weightGrams).toBe(1500); expect(all[0].priceCents).toBe(25000); expect(all[0].globalItemId).toBeNull(); }); it("getItemById returns merged data for a reference item", async () => { const gi = await insertGlobalItem(db, { brand: "Thermarest", model: "NeoAir XLite", weightGrams: 340, priceCents: 20995, }); const created = await createItem(db, userId, { name: "placeholder", categoryId: 1, globalItemId: gi.id, }); const item = await getItemById(db, userId, created?.id); expect(item).toBeDefined(); expect(item?.name).toBe("Thermarest NeoAir XLite"); expect(item?.weightGrams).toBe(340); expect(item?.priceCents).toBe(20995); expect(item?.globalItemId).toBe(gi.id); }); it("duplicateItem on a reference item preserves globalItemId", async () => { const gi = await insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2", weightGrams: 73, priceCents: 4995, }); const original = await createItem(db, userId, { name: "placeholder", categoryId: 1, globalItemId: gi.id, }); const copy = await duplicateItem(db, userId, original?.id); expect(copy).toBeDefined(); expect(copy?.globalItemId).toBe(gi.id); }); it("createItem with purchasePriceCents stores the value", async () => { const item = await createItem(db, userId, { name: "Discounted Tent", categoryId: 1, purchasePriceCents: 29999, }); expect(item?.purchasePriceCents).toBe(29999); }); }); describe("cross-user isolation", () => { it("user cannot see other user's items", async () => { const userId2 = await createSecondTestUser(db); await createItem(db, userId, { name: "User 1 Tent", categoryId: 1, }); // User 2 needs their own Uncategorized category; createSecondTestUser seeds one const user2Categories = await db.query.categories.findMany({ where: (cats: any, { eq }: any) => eq(cats.userId, userId2), }); const user2CatId = user2Categories[0].id; await createItem(db, userId2, { name: "User 2 Bag", categoryId: user2CatId, }); const user1Items = await getAllItems(db, userId); const user2Items = await getAllItems(db, userId2); expect(user1Items).toHaveLength(1); expect(user1Items[0].name).toBe("User 1 Tent"); expect(user2Items).toHaveLength(1); expect(user2Items[0].name).toBe("User 2 Bag"); }); it("getItemById returns null for another user's item", async () => { const created = await createItem(db, userId, { name: "My Item", categoryId: 1, }); const userId2 = await createSecondTestUser(db); const result = await getItemById(db, userId2, created?.id); expect(result).toBeNull(); }); }); });