import { beforeEach, describe, expect, it } from "bun:test"; import { eq } from "drizzle-orm"; import * as schema from "../../src/db/schema.ts"; import { globalItems, globalItemTags, items, tags, } from "../../src/db/schema.ts"; import { seedGlobalItems } from "../../src/db/seed-global-items.ts"; import { bulkUpsertGlobalItems, deleteGlobalItem, getGlobalItemWithOwnerCount, listGlobalItemsForAdmin, searchGlobalItems, updateGlobalItemById, upsertGlobalItem, } from "../../src/server/services/global-item.service.ts"; import { createTestDb } from "../helpers/db.ts"; type TestDb = Awaited>; async function insertManufacturer( db: TestDb["db"], name = "Apidura", slug = "apidura", ) { const [row] = await db .insert(schema.manufacturers) .values({ name, slug, website: `https://${slug}.com` }) .returning(); return row!; } async function insertGlobalItem( db: TestDb["db"], data: { manufacturerId: number; model: string; category?: string; weightGrams?: number; priceCents?: number; }, ) { const [row] = await db .insert(globalItems) .values({ manufacturerId: data.manufacturerId, model: data.model, category: data.category ?? null, weightGrams: data.weightGrams ?? null, priceCents: data.priceCents ?? null, }) .returning(); return row!; } async function insertItem( db: TestDb["db"], name: string, userId: number, opts?: { globalItemId?: number }, ) { const [row] = await db .insert(items) .values({ name, categoryId: 1, userId, globalItemId: opts?.globalItemId }) .returning(); return row; } async function insertTag(db: TestDb["db"], name: string) { const [row] = await db.insert(tags).values({ name }).returning(); return row; } async function tagGlobalItem( db: TestDb["db"], globalItemId: number, tagId: number, ) { await db.insert(globalItemTags).values({ globalItemId, tagId }); } describe("Global Item Service", () => { let db: TestDb["db"]; let userId: number; beforeEach(async () => { const testDb = await createTestDb(); db = testDb.db; userId = testDb.userId; }); describe("searchGlobalItems", () => { it("returns all global items when no query provided", async () => { const m1 = await insertManufacturer( db, "Revelate Designs", "revelate-designs", ); const m2 = await insertManufacturer(db, "Apidura", "apidura"); await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System", }); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack", }); const results = await searchGlobalItems(db); expect(results).toHaveLength(2); }); it("returns items matching brand (case-insensitive)", async () => { const m1 = await insertManufacturer( db, "Revelate Designs", "revelate-designs", ); const m2 = await insertManufacturer(db, "Apidura", "apidura"); await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System", }); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack", }); const results = await searchGlobalItems(db, "revelate"); expect(results).toHaveLength(1); expect(results[0].brand).toBe("Revelate Designs"); }); it("returns items matching model (case-insensitive)", async () => { const m1 = await insertManufacturer( db, "Revelate Designs", "revelate-designs", ); const m2 = await insertManufacturer(db, "Apidura", "apidura"); await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System", }); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack", }); const results = await searchGlobalItems(db, "HANDLEBAR"); expect(results).toHaveLength(1); expect(results[0].model).toBe("Handlebar Pack"); }); it("does not match everything with wildcard chars", async () => { const m1 = await insertManufacturer( db, "Revelate Designs", "revelate-designs", ); const m2 = await insertManufacturer(db, "Apidura", "apidura"); await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System", }); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack", }); const results = await searchGlobalItems(db, "100%"); expect(results).toHaveLength(0); }); it("returns all items when no tags provided", async () => { const m1 = await insertManufacturer( db, "Revelate Designs", "revelate-designs", ); const m2 = await insertManufacturer(db, "Apidura", "apidura"); await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System", }); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack", }); const results = await searchGlobalItems(db, undefined, undefined); expect(results).toHaveLength(2); }); it("filters by single tag", async () => { const m1 = await insertManufacturer( db, "Revelate Designs", "revelate-designs", ); const m2 = await insertManufacturer(db, "Apidura", "apidura"); const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System", }); const _gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack", }); const tag = await insertTag(db, "ultralight"); await tagGlobalItem(db, gi1.id, tag.id); const results = await searchGlobalItems(db, undefined, ["ultralight"]); expect(results).toHaveLength(1); expect(results[0].brand).toBe("Revelate Designs"); }); it("filters by multiple tags with AND logic", async () => { const m1 = await insertManufacturer( db, "Revelate Designs", "revelate-designs", ); const m2 = await insertManufacturer(db, "Apidura", "apidura"); const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System", }); const gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack", }); const tagUL = await insertTag(db, "ultralight"); const tagBP = await insertTag(db, "bikepacking"); // gi1 has both tags await tagGlobalItem(db, gi1.id, tagUL.id); await tagGlobalItem(db, gi1.id, tagBP.id); // gi2 has only bikepacking await tagGlobalItem(db, gi2.id, tagBP.id); const results = await searchGlobalItems(db, undefined, [ "ultralight", "bikepacking", ]); expect(results).toHaveLength(1); expect(results[0].brand).toBe("Revelate Designs"); }); it("combines text search and tag filtering", async () => { const m = await insertManufacturer( db, "Revelate Designs", "revelate-designs", ); const gi1 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Terrapin System", }); const gi2 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Spinelock", }); const tag = await insertTag(db, "bikepacking"); await tagGlobalItem(db, gi1.id, tag.id); await tagGlobalItem(db, gi2.id, tag.id); // Both tagged bikepacking, but only one matches "terrapin" const results = await searchGlobalItems(db, "terrapin", ["bikepacking"]); expect(results).toHaveLength(1); expect(results[0].model).toBe("Terrapin System"); }); }); describe("getGlobalItemWithOwnerCount", () => { it("returns item with ownerCount 0 when no items reference it", async () => { const m = await insertManufacturer(db, "MSR", "msr"); const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2", }); const result = await getGlobalItemWithOwnerCount(db, gi.id); expect(result).not.toBeNull(); expect(result!.ownerCount).toBe(0); expect(result!.brand).toBe("MSR"); }); it("returns ownerCount matching number of items with globalItemId", async () => { const m = await insertManufacturer(db, "MSR", "msr"); const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2", }); await insertItem(db, "My Stove", userId, { globalItemId: gi.id }); await insertItem(db, "Another Stove", userId, { globalItemId: gi.id, }); const result = await getGlobalItemWithOwnerCount(db, gi.id); expect(result).not.toBeNull(); expect(result!.ownerCount).toBe(2); }); it("returns null for non-existent id", async () => { const result = await getGlobalItemWithOwnerCount(db, 9999); expect(result).toBeNull(); }); }); describe("seedGlobalItems", () => { it("inserts seed data on first call", async () => { await seedGlobalItems(db); const all = await db.select().from(globalItems); expect(all.length).toBeGreaterThan(0); }); it("is idempotent on second call", async () => { await seedGlobalItems(db); const countAfterFirst = (await db.select().from(globalItems)).length; await seedGlobalItems(db); const countAfterSecond = (await db.select().from(globalItems)).length; expect(countAfterSecond).toBe(countAfterFirst); }); }); describe("upsert operations", () => { it("upsertGlobalItem creates new item and returns { item, created: true }", async () => { await insertManufacturer(db, "Revelate Designs", "revelate-designs"); const result = await upsertGlobalItem(db, { manufacturerSlug: "revelate-designs", model: "Terrapin System", category: "Bags", weightGrams: 210, }); expect(result.created).toBe(true); expect(result.item.id).toBeDefined(); expect(result.item.model).toBe("Terrapin System"); }); it("upsertGlobalItem updates existing item on (manufacturerId, model) conflict and returns { item, created: false }", async () => { await insertManufacturer(db, "MSR", "msr"); await upsertGlobalItem(db, { manufacturerSlug: "msr", model: "PocketRocket 2", weightGrams: 83, }); const second = await upsertGlobalItem(db, { manufacturerSlug: "msr", model: "PocketRocket 2", weightGrams: 90, }); expect(second.created).toBe(false); expect(second.item.weightGrams).toBe(90); // Only one row should exist const all = await db.select().from(globalItems); expect(all).toHaveLength(1); }); it("upsertGlobalItem persists sourceUrl, imageCredit, imageSourceUrl", async () => { await insertManufacturer(db, "Apidura", "apidura"); const result = await upsertGlobalItem(db, { manufacturerSlug: "apidura", model: "Handlebar Pack", sourceUrl: "https://apidura.com/shop/handlebar-pack/", imageCredit: "Apidura Ltd", imageSourceUrl: "https://apidura.com/images/handlebar-pack.jpg", }); expect(result.item.sourceUrl).toBe( "https://apidura.com/shop/handlebar-pack/", ); expect(result.item.imageCredit).toBe("Apidura Ltd"); expect(result.item.imageSourceUrl).toBe( "https://apidura.com/images/handlebar-pack.jpg", ); }); it("upsertGlobalItem with tags creates tags and links them", async () => { await insertManufacturer(db, "Therm-a-Rest", "therm-a-rest"); const result = await upsertGlobalItem(db, { manufacturerSlug: "therm-a-rest", model: "NeoAir XLite", tags: ["sleeping-pad", "ultralight"], }); expect(result.created).toBe(true); const linkedTags = await db .select({ name: tags.name }) .from(globalItemTags) .innerJoin(tags, eq(globalItemTags.tagId, tags.id)) .where(eq(globalItemTags.globalItemId, result.item.id)); expect(linkedTags).toHaveLength(2); const tagNames = linkedTags.map((t) => t.name).sort(); expect(tagNames).toEqual(["sleeping-pad", "ultralight"]); }); it("upsertGlobalItem without tags leaves existing tags untouched", async () => { await insertManufacturer(db, "Sea to Summit", "sea-to-summit"); // Create item with tags const first = await upsertGlobalItem(db, { manufacturerSlug: "sea-to-summit", model: "Spark III", tags: ["sleeping-bag"], }); // Upsert without tags await upsertGlobalItem(db, { manufacturerSlug: "sea-to-summit", model: "Spark III", weightGrams: 450, }); // Tags should remain const linkedTags = await db .select() .from(globalItemTags) .where(eq(globalItemTags.globalItemId, first.item.id)); expect(linkedTags).toHaveLength(1); }); it("upsertGlobalItem with empty tags array clears existing tags", async () => { await insertManufacturer(db, "Big Agnes", "big-agnes"); // Create item with tags const first = await upsertGlobalItem(db, { manufacturerSlug: "big-agnes", model: "Copper Spur HV UL2", tags: ["tent", "ultralight"], }); // Upsert with empty tags await upsertGlobalItem(db, { manufacturerSlug: "big-agnes", model: "Copper Spur HV UL2", tags: [], }); // Tags should be cleared const linkedTags = await db .select() .from(globalItemTags) .where(eq(globalItemTags.globalItemId, first.item.id)); expect(linkedTags).toHaveLength(0); }); it("bulkUpsertGlobalItems processes array and returns correct created/updated counts", async () => { await insertManufacturer(db, "Petzl", "petzl"); await insertManufacturer(db, "Black Diamond", "black-diamond"); const result = await bulkUpsertGlobalItems(db, [ { manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87 }, { manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95, }, { manufacturerSlug: "black-diamond", model: "Spot 350", weightGrams: 90, }, ]); expect(result.created).toBe(3); expect(result.updated).toBe(0); expect(result.items).toHaveLength(3); }); it("bulkUpsertGlobalItems handles mix of new and existing items", async () => { await insertManufacturer(db, "Petzl", "petzl"); await insertManufacturer(db, "Black Diamond", "black-diamond"); // Pre-insert one item await upsertGlobalItem(db, { manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87, }); const result = await bulkUpsertGlobalItems(db, [ { manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 90 }, // existing { manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95, }, // new ]); expect(result.created).toBe(1); expect(result.updated).toBe(1); expect(result.items).toHaveLength(2); }); }); }); describe("listGlobalItemsForAdmin", () => { let db: TestDb["db"]; beforeEach(async () => { ({ db } = await createTestDb()); }); it("returns empty result when no items exist", async () => { const result = await listGlobalItemsForAdmin(db); expect(result.items).toHaveLength(0); expect(result.total).toBe(0); expect(result.hasMore).toBe(false); }); it("returns paginated items with total count", async () => { const mfr = await insertManufacturer(db); await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Alpha" }); await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Beta" }); await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Gamma" }); const result = await listGlobalItemsForAdmin(db, { limit: 2, offset: 0 }); expect(result.items).toHaveLength(2); expect(result.total).toBe(3); expect(result.hasMore).toBe(true); expect(result.nextOffset).toBe(2); }); it("filters by query string (brand/model)", async () => { const mfr = await insertManufacturer(db, "Salsa", "salsa"); await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Woodsmoke 700", }); const mfr2 = await insertManufacturer(db, "Apidura", "apidura"); await insertGlobalItem(db, { manufacturerId: mfr2.id, model: "Racing Saddle Bag", }); const result = await listGlobalItemsForAdmin(db, { query: "salsa" }); expect(result.items).toHaveLength(1); expect(result.items[0]!.model).toBe("Woodsmoke 700"); }); it("includes tags and ownerCount per item", async () => { const mfr = await insertManufacturer(db); const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Test Item", }); const tag = await insertTag(db, "bikepacking"); await tagGlobalItem(db, globalItem.id, tag.id!); // Insert a user and item linking to the global item const [user] = await db .insert(schema.users) .values({ logtoSub: "test-sub" }) .returning(); await insertItem(db, "My Test Item", user!.id, { globalItemId: globalItem.id, }); const result = await listGlobalItemsForAdmin(db); expect(result.items).toHaveLength(1); expect(result.items[0]!.tags).toContain("bikepacking"); expect(result.items[0]!.ownerCount).toBe(1); }); }); describe("updateGlobalItemById", () => { let db: TestDb["db"]; beforeEach(async () => { ({ db } = await createTestDb()); }); it("returns null for non-existent item", async () => { const result = await updateGlobalItemById(db, 99999, { model: "Ghost" }); expect(result).toBeNull(); }); it("updates model field by id", async () => { const mfr = await insertManufacturer(db); const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Original", }); await updateGlobalItemById(db, globalItem.id, { model: "Updated" }); const updated = await getGlobalItemWithOwnerCount(db, globalItem.id); expect(updated?.model).toBe("Updated"); }); it("syncs tags when tags array provided", async () => { const mfr = await insertManufacturer(db); const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Item", }); await updateGlobalItemById(db, globalItem.id, { tags: ["cycling", "gravel"], }); const result = await listGlobalItemsForAdmin(db); const found = result.items.find((i) => i.id === globalItem.id); expect(found?.tags).toContain("cycling"); expect(found?.tags).toContain("gravel"); }); }); describe("deleteGlobalItem", () => { let db: TestDb["db"]; beforeEach(async () => { ({ db } = await createTestDb()); }); it("returns false for non-existent item", async () => { const result = await deleteGlobalItem(db, 99999); expect(result).toBe(false); }); it("deletes item and returns true", async () => { const mfr = await insertManufacturer(db); const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "To Delete", }); const result = await deleteGlobalItem(db, globalItem.id); expect(result).toBe(true); const found = await getGlobalItemWithOwnerCount(db, globalItem.id); expect(found).toBeNull(); }); it("nullifies items.globalItemId before deleting", async () => { const mfr = await insertManufacturer(db); const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Owned Item", }); const [user] = await db .insert(schema.users) .values({ logtoSub: "delete-test-sub" }) .returning(); const userItem = await insertItem(db, "User Item", user!.id, { globalItemId: globalItem.id, }); await deleteGlobalItem(db, globalItem.id); const [afterDelete] = await db .select({ globalItemId: items.globalItemId }) .from(items) .where(eq(items.id, userItem!.id)); expect(afterDelete?.globalItemId).toBeNull(); }); it("removes globalItemTags before deleting", async () => { const mfr = await insertManufacturer(db); const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Delete", }); const tag = await insertTag(db, "delete-tag"); await tagGlobalItem(db, globalItem.id, tag.id!); await deleteGlobalItem(db, globalItem.id); const remainingTags = await db .select() .from(globalItemTags) .where(eq(globalItemTags.globalItemId, globalItem.id)); expect(remainingTags).toHaveLength(0); }); });