From 9093a2c8f60db20f401934a52ab1915e7457b908 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 10 Apr 2026 10:56:54 +0200 Subject: [PATCH] test(25-01): add failing tests for upsertGlobalItem and bulkUpsertGlobalItems - Import upsertGlobalItem and bulkUpsertGlobalItems (not yet exported) - Tests cover: create, conflict update, attribution fields, tag sync - Tests cover: empty tags clear, tags omitted leaves untouched - Tests cover: bulk upsert counts (created vs updated) --- tests/services/global-item.service.test.ts | 152 +++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/tests/services/global-item.service.test.ts b/tests/services/global-item.service.test.ts index 2b848ce..6433b6d 100644 --- a/tests/services/global-item.service.test.ts +++ b/tests/services/global-item.service.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; import { globalItems, globalItemTags, @@ -7,8 +8,10 @@ import { } from "../../src/db/schema.ts"; import { seedGlobalItems } from "../../src/db/seed-global-items.ts"; import { + bulkUpsertGlobalItems, getGlobalItemWithOwnerCount, searchGlobalItems, + upsertGlobalItem, } from "../../src/server/services/global-item.service.ts"; import { createTestDb } from "../helpers/db.ts"; @@ -263,4 +266,153 @@ describe("Global Item Service", () => { expect(countAfterSecond).toBe(countAfterFirst); }); }); + + describe("upsert operations", () => { + it("upsertGlobalItem creates new item and returns { item, created: true }", async () => { + const result = await upsertGlobalItem(db, { + brand: "Revelate Designs", + model: "Terrapin System", + category: "Bags", + weightGrams: 210, + }); + + expect(result.created).toBe(true); + expect(result.item.id).toBeDefined(); + expect(result.item.brand).toBe("Revelate Designs"); + expect(result.item.model).toBe("Terrapin System"); + }); + + it("upsertGlobalItem updates existing item on (brand, model) conflict and returns { item, created: false }", async () => { + await upsertGlobalItem(db, { + brand: "MSR", + model: "PocketRocket 2", + weightGrams: 83, + }); + + const second = await upsertGlobalItem(db, { + brand: "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 () => { + const result = await upsertGlobalItem(db, { + brand: "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 () => { + const result = await upsertGlobalItem(db, { + brand: "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 () => { + // Create item with tags + const first = await upsertGlobalItem(db, { + brand: "Sea to Summit", + model: "Spark III", + tags: ["sleeping-bag"], + }); + + // Upsert without tags + await upsertGlobalItem(db, { + brand: "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 () => { + // Create item with tags + const first = await upsertGlobalItem(db, { + brand: "Big Agnes", + model: "Copper Spur HV UL2", + tags: ["tent", "ultralight"], + }); + + // Upsert with empty tags + await upsertGlobalItem(db, { + brand: "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 () => { + const result = await bulkUpsertGlobalItems(db, [ + { brand: "Petzl", model: "Actik Core", weightGrams: 87 }, + { brand: "Black Diamond", model: "Spot 400", weightGrams: 95 }, + { brand: "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 () => { + // Pre-insert one item + await upsertGlobalItem(db, { + brand: "Petzl", + model: "Actik Core", + weightGrams: 87, + }); + + const result = await bulkUpsertGlobalItems(db, [ + { brand: "Petzl", model: "Actik Core", weightGrams: 90 }, // existing + { brand: "Black Diamond", model: "Spot 400", weightGrams: 95 }, // new + ]); + + expect(result.created).toBe(1); + expect(result.updated).toBe(1); + expect(result.items).toHaveLength(2); + }); + }); });