feat: global-item service uses manufacturerSlug, joins manufacturers for brand

This commit is contained in:
2026-04-18 16:21:25 +02:00
parent 8ff680ef92
commit 5037350aa0
2 changed files with 147 additions and 138 deletions

View File

@@ -1,17 +1,20 @@
import type { SQL } from "drizzle-orm"; import type { SQL } from "drizzle-orm";
import { and, count, eq, ilike, or, sql } from "drizzle-orm"; import { and, count, eq, ilike, or, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts"; import { globalItems, globalItemTags, items, manufacturers, tags } from "../../db/schema.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0]; type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
/** async function resolveManufacturerId(db: Db | TxDb, slug: string): Promise<number> {
* Search global items by brand or model and/or tag names. const [m] = await (db as Db)
* Text search uses ILIKE for case-insensitive matching (PostgreSQL). .select({ id: manufacturers.id })
* Tag filtering uses AND logic -- items must have ALL specified tags. .from(manufacturers)
* Escapes % and _ wildcard characters in user input. .where(eq(manufacturers.slug, slug));
*/ if (!m) throw new Error(`Manufacturer not found: ${slug}`);
return m.id;
}
export async function searchGlobalItems( export async function searchGlobalItems(
db: Db = prodDb, db: Db = prodDb,
query?: string, query?: string,
@@ -23,7 +26,7 @@ export async function searchGlobalItems(
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
const pattern = `%${escaped}%`; const pattern = `%${escaped}%`;
conditions.push( conditions.push(
or(ilike(globalItems.brand, pattern), ilike(globalItems.model, pattern))!, or(ilike(manufacturers.name, pattern), ilike(globalItems.model, pattern))!,
); );
} }
@@ -43,24 +46,59 @@ export async function searchGlobalItems(
); );
} }
const baseQuery = db
.select({
id: globalItems.id,
manufacturerId: globalItems.manufacturerId,
brand: manufacturers.name,
model: globalItems.model,
category: globalItems.category,
weightGrams: globalItems.weightGrams,
priceCents: globalItems.priceCents,
imageUrl: globalItems.imageUrl,
description: globalItems.description,
sourceUrl: globalItems.sourceUrl,
imageCredit: globalItems.imageCredit,
imageSourceUrl: globalItems.imageSourceUrl,
dominantColor: globalItems.dominantColor,
cropZoom: globalItems.cropZoom,
cropX: globalItems.cropX,
cropY: globalItems.cropY,
createdAt: globalItems.createdAt,
})
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id));
if (conditions.length === 0) { if (conditions.length === 0) {
return db.select().from(globalItems); return baseQuery;
} }
return db return baseQuery.where(and(...conditions));
.select()
.from(globalItems)
.where(and(...conditions));
} }
/**
* Get a single global item by ID with the count of user items referencing it
* via items.globalItemId.
*/
export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) { export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
const [item] = await db const [item] = await db
.select() .select({
id: globalItems.id,
manufacturerId: globalItems.manufacturerId,
brand: manufacturers.name,
model: globalItems.model,
category: globalItems.category,
weightGrams: globalItems.weightGrams,
priceCents: globalItems.priceCents,
imageUrl: globalItems.imageUrl,
description: globalItems.description,
sourceUrl: globalItems.sourceUrl,
imageCredit: globalItems.imageCredit,
imageSourceUrl: globalItems.imageSourceUrl,
dominantColor: globalItems.dominantColor,
cropZoom: globalItems.cropZoom,
cropX: globalItems.cropX,
cropY: globalItems.cropY,
createdAt: globalItems.createdAt,
})
.from(globalItems) .from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(eq(globalItems.id, id)); .where(eq(globalItems.id, id));
if (!item) return null; if (!item) return null;
@@ -73,10 +111,6 @@ export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
return { ...item, ownerCount: result?.ownerCount ?? 0 }; return { ...item, ownerCount: result?.ownerCount ?? 0 };
} }
/**
* Sync tags for a global item: delete existing, re-insert provided tag names.
* Creates tags that don't exist yet (create-if-not-exists).
*/
async function syncGlobalItemTags( async function syncGlobalItemTags(
tx: TxDb, tx: TxDb,
globalItemId: number, globalItemId: number,
@@ -97,15 +131,10 @@ async function syncGlobalItemTags(
} }
} }
/**
* Upsert a single global item by (brand, model).
* Creates if not exists, updates all non-key fields if exists.
* Tag sync: provided → sync; undefined → leave untouched; [] → clear all tags.
*/
export async function upsertGlobalItem( export async function upsertGlobalItem(
db: Db, db: Db,
data: { data: {
brand: string; manufacturerSlug: string;
model: string; model: string;
category?: string; category?: string;
weightGrams?: number; weightGrams?: number;
@@ -118,23 +147,25 @@ export async function upsertGlobalItem(
tags?: string[]; tags?: string[];
}, },
) { ) {
const manufacturerId = await resolveManufacturerId(db, data.manufacturerSlug);
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const [existing] = await tx const [existing] = await tx
.select({ id: globalItems.id }) .select({ id: globalItems.id })
.from(globalItems) .from(globalItems)
.where( .where(
and( and(
eq(globalItems.brand, data.brand), eq(globalItems.manufacturerId, manufacturerId),
eq(globalItems.model, data.model), eq(globalItems.model, data.model),
), ),
); );
const { tags: tagNames, ...itemData } = data; const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
const [item] = await tx const [item] = await tx
.insert(globalItems) .insert(globalItems)
.values({ .values({
brand: itemData.brand, manufacturerId,
model: itemData.model, model: itemData.model,
category: itemData.category ?? null, category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null, weightGrams: itemData.weightGrams ?? null,
@@ -146,7 +177,7 @@ export async function upsertGlobalItem(
imageSourceUrl: itemData.imageSourceUrl ?? null, imageSourceUrl: itemData.imageSourceUrl ?? null,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [globalItems.brand, globalItems.model], target: [globalItems.manufacturerId, globalItems.model],
set: { set: {
category: itemData.category ?? null, category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null, weightGrams: itemData.weightGrams ?? null,
@@ -161,22 +192,17 @@ export async function upsertGlobalItem(
.returning(); .returning();
if (tagNames !== undefined) { if (tagNames !== undefined) {
await syncGlobalItemTags(tx, item.id, tagNames); await syncGlobalItemTags(tx, item!.id, tagNames);
} }
return { item, created: !existing }; return { item: item!, created: !existing };
}); });
} }
/**
* Bulk upsert global items in a single transaction.
* Returns { created, updated, items } with accurate counts.
* Rolls back entirely if any item fails.
*/
export async function bulkUpsertGlobalItems( export async function bulkUpsertGlobalItems(
db: Db, db: Db,
itemsData: Array<{ itemsData: Array<{
brand: string; manufacturerSlug: string;
model: string; model: string;
category?: string; category?: string;
weightGrams?: number; weightGrams?: number;
@@ -195,22 +221,24 @@ export async function bulkUpsertGlobalItems(
const resultItems = []; const resultItems = [];
for (const data of itemsData) { for (const data of itemsData) {
const manufacturerId = await resolveManufacturerId(tx as unknown as Db, data.manufacturerSlug);
const [existing] = await tx const [existing] = await tx
.select({ id: globalItems.id }) .select({ id: globalItems.id })
.from(globalItems) .from(globalItems)
.where( .where(
and( and(
eq(globalItems.brand, data.brand), eq(globalItems.manufacturerId, manufacturerId),
eq(globalItems.model, data.model), eq(globalItems.model, data.model),
), ),
); );
const { tags: tagNames, ...itemData } = data; const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
const [item] = await tx const [item] = await tx
.insert(globalItems) .insert(globalItems)
.values({ .values({
brand: itemData.brand, manufacturerId,
model: itemData.model, model: itemData.model,
category: itemData.category ?? null, category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null, weightGrams: itemData.weightGrams ?? null,
@@ -222,7 +250,7 @@ export async function bulkUpsertGlobalItems(
imageSourceUrl: itemData.imageSourceUrl ?? null, imageSourceUrl: itemData.imageSourceUrl ?? null,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [globalItems.brand, globalItems.model], target: [globalItems.manufacturerId, globalItems.model],
set: { set: {
category: itemData.category ?? null, category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null, weightGrams: itemData.weightGrams ?? null,
@@ -237,7 +265,7 @@ export async function bulkUpsertGlobalItems(
.returning(); .returning();
if (tagNames !== undefined) { if (tagNames !== undefined) {
await syncGlobalItemTags(tx, item.id, tagNames); await syncGlobalItemTags(tx, item!.id, tagNames);
} }
if (existing) { if (existing) {
@@ -245,7 +273,7 @@ export async function bulkUpsertGlobalItems(
} else { } else {
created++; created++;
} }
resultItems.push(item); resultItems.push(item!);
} }
return { created, updated, items: resultItems }; return { created, updated, items: resultItems };

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as schema from "../../src/db/schema.ts";
import { import {
globalItems, globalItems,
globalItemTags, globalItemTags,
@@ -17,10 +18,18 @@ import { createTestDb } from "../helpers/db.ts";
type TestDb = Awaited<ReturnType<typeof createTestDb>>; type TestDb = Awaited<ReturnType<typeof createTestDb>>;
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( async function insertGlobalItem(
db: TestDb["db"], db: TestDb["db"],
data: { data: {
brand: string; manufacturerId: number;
model: string; model: string;
category?: string; category?: string;
weightGrams?: number; weightGrams?: number;
@@ -30,14 +39,14 @@ async function insertGlobalItem(
const [row] = await db const [row] = await db
.insert(globalItems) .insert(globalItems)
.values({ .values({
brand: data.brand, manufacturerId: data.manufacturerId,
model: data.model, model: data.model,
category: data.category ?? null, category: data.category ?? null,
weightGrams: data.weightGrams ?? null, weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null, priceCents: data.priceCents ?? null,
}) })
.returning(); .returning();
return row; return row!;
} }
async function insertItem( async function insertItem(
@@ -78,28 +87,20 @@ describe("Global Item Service", () => {
describe("searchGlobalItems", () => { describe("searchGlobalItems", () => {
it("returns all global items when no query provided", async () => { it("returns all global items when no query provided", async () => {
await insertGlobalItem(db, { const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
brand: "Revelate Designs", const m2 = await insertManufacturer(db, "Apidura", "apidura");
model: "Terrapin System", await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
}); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db); const results = await searchGlobalItems(db);
expect(results).toHaveLength(2); expect(results).toHaveLength(2);
}); });
it("returns items matching brand (case-insensitive)", async () => { it("returns items matching brand (case-insensitive)", async () => {
await insertGlobalItem(db, { const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
brand: "Revelate Designs", const m2 = await insertManufacturer(db, "Apidura", "apidura");
model: "Terrapin System", await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
}); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, "revelate"); const results = await searchGlobalItems(db, "revelate");
expect(results).toHaveLength(1); expect(results).toHaveLength(1);
@@ -107,14 +108,10 @@ describe("Global Item Service", () => {
}); });
it("returns items matching model (case-insensitive)", async () => { it("returns items matching model (case-insensitive)", async () => {
await insertGlobalItem(db, { const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
brand: "Revelate Designs", const m2 = await insertManufacturer(db, "Apidura", "apidura");
model: "Terrapin System", await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
}); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, "HANDLEBAR"); const results = await searchGlobalItems(db, "HANDLEBAR");
expect(results).toHaveLength(1); expect(results).toHaveLength(1);
@@ -122,42 +119,30 @@ describe("Global Item Service", () => {
}); });
it("does not match everything with wildcard chars", async () => { it("does not match everything with wildcard chars", async () => {
await insertGlobalItem(db, { const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
brand: "Revelate Designs", const m2 = await insertManufacturer(db, "Apidura", "apidura");
model: "Terrapin System", await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
}); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, "100%"); const results = await searchGlobalItems(db, "100%");
expect(results).toHaveLength(0); expect(results).toHaveLength(0);
}); });
it("returns all items when no tags provided", async () => { it("returns all items when no tags provided", async () => {
await insertGlobalItem(db, { const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
brand: "Revelate Designs", const m2 = await insertManufacturer(db, "Apidura", "apidura");
model: "Terrapin System", await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
}); await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, undefined, undefined); const results = await searchGlobalItems(db, undefined, undefined);
expect(results).toHaveLength(2); expect(results).toHaveLength(2);
}); });
it("filters by single tag", async () => { it("filters by single tag", async () => {
const gi1 = await insertGlobalItem(db, { const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
brand: "Revelate Designs", const m2 = await insertManufacturer(db, "Apidura", "apidura");
model: "Terrapin System", const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
}); const _gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const _gi2 = await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const tag = await insertTag(db, "ultralight"); const tag = await insertTag(db, "ultralight");
await tagGlobalItem(db, gi1.id, tag.id); await tagGlobalItem(db, gi1.id, tag.id);
@@ -168,14 +153,10 @@ describe("Global Item Service", () => {
}); });
it("filters by multiple tags with AND logic", async () => { it("filters by multiple tags with AND logic", async () => {
const gi1 = await insertGlobalItem(db, { const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
brand: "Revelate Designs", const m2 = await insertManufacturer(db, "Apidura", "apidura");
model: "Terrapin System", const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
}); const gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const gi2 = await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const tagUL = await insertTag(db, "ultralight"); const tagUL = await insertTag(db, "ultralight");
const tagBP = await insertTag(db, "bikepacking"); const tagBP = await insertTag(db, "bikepacking");
@@ -194,14 +175,9 @@ describe("Global Item Service", () => {
}); });
it("combines text search and tag filtering", async () => { it("combines text search and tag filtering", async () => {
const gi1 = await insertGlobalItem(db, { const m = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
brand: "Revelate Designs", const gi1 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Terrapin System" });
model: "Terrapin System", const gi2 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Spinelock" });
});
const gi2 = await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Spinelock",
});
const tag = await insertTag(db, "bikepacking"); const tag = await insertTag(db, "bikepacking");
await tagGlobalItem(db, gi1.id, tag.id); await tagGlobalItem(db, gi1.id, tag.id);
@@ -216,10 +192,8 @@ describe("Global Item Service", () => {
describe("getGlobalItemWithOwnerCount", () => { describe("getGlobalItemWithOwnerCount", () => {
it("returns item with ownerCount 0 when no items reference it", async () => { it("returns item with ownerCount 0 when no items reference it", async () => {
const gi = await insertGlobalItem(db, { const m = await insertManufacturer(db, "MSR", "msr");
brand: "MSR", const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
model: "PocketRocket 2",
});
const result = await getGlobalItemWithOwnerCount(db, gi.id); const result = await getGlobalItemWithOwnerCount(db, gi.id);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
@@ -228,10 +202,8 @@ describe("Global Item Service", () => {
}); });
it("returns ownerCount matching number of items with globalItemId", async () => { it("returns ownerCount matching number of items with globalItemId", async () => {
const gi = await insertGlobalItem(db, { const m = await insertManufacturer(db, "MSR", "msr");
brand: "MSR", const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
model: "PocketRocket 2",
});
await insertItem(db, "My Stove", userId, { globalItemId: gi.id }); await insertItem(db, "My Stove", userId, { globalItemId: gi.id });
await insertItem(db, "Another Stove", userId, { await insertItem(db, "Another Stove", userId, {
@@ -269,8 +241,9 @@ describe("Global Item Service", () => {
describe("upsert operations", () => { describe("upsert operations", () => {
it("upsertGlobalItem creates new item and returns { item, created: true }", async () => { it("upsertGlobalItem creates new item and returns { item, created: true }", async () => {
await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const result = await upsertGlobalItem(db, { const result = await upsertGlobalItem(db, {
brand: "Revelate Designs", manufacturerSlug: "revelate-designs",
model: "Terrapin System", model: "Terrapin System",
category: "Bags", category: "Bags",
weightGrams: 210, weightGrams: 210,
@@ -278,19 +251,19 @@ describe("Global Item Service", () => {
expect(result.created).toBe(true); expect(result.created).toBe(true);
expect(result.item.id).toBeDefined(); expect(result.item.id).toBeDefined();
expect(result.item.brand).toBe("Revelate Designs");
expect(result.item.model).toBe("Terrapin System"); expect(result.item.model).toBe("Terrapin System");
}); });
it("upsertGlobalItem updates existing item on (brand, model) conflict and returns { item, created: false }", async () => { it("upsertGlobalItem updates existing item on (manufacturerId, model) conflict and returns { item, created: false }", async () => {
await insertManufacturer(db, "MSR", "msr");
await upsertGlobalItem(db, { await upsertGlobalItem(db, {
brand: "MSR", manufacturerSlug: "msr",
model: "PocketRocket 2", model: "PocketRocket 2",
weightGrams: 83, weightGrams: 83,
}); });
const second = await upsertGlobalItem(db, { const second = await upsertGlobalItem(db, {
brand: "MSR", manufacturerSlug: "msr",
model: "PocketRocket 2", model: "PocketRocket 2",
weightGrams: 90, weightGrams: 90,
}); });
@@ -304,8 +277,9 @@ describe("Global Item Service", () => {
}); });
it("upsertGlobalItem persists sourceUrl, imageCredit, imageSourceUrl", async () => { it("upsertGlobalItem persists sourceUrl, imageCredit, imageSourceUrl", async () => {
await insertManufacturer(db, "Apidura", "apidura");
const result = await upsertGlobalItem(db, { const result = await upsertGlobalItem(db, {
brand: "Apidura", manufacturerSlug: "apidura",
model: "Handlebar Pack", model: "Handlebar Pack",
sourceUrl: "https://apidura.com/shop/handlebar-pack/", sourceUrl: "https://apidura.com/shop/handlebar-pack/",
imageCredit: "Apidura Ltd", imageCredit: "Apidura Ltd",
@@ -322,8 +296,9 @@ describe("Global Item Service", () => {
}); });
it("upsertGlobalItem with tags creates tags and links them", async () => { it("upsertGlobalItem with tags creates tags and links them", async () => {
await insertManufacturer(db, "Therm-a-Rest", "therm-a-rest");
const result = await upsertGlobalItem(db, { const result = await upsertGlobalItem(db, {
brand: "Therm-a-Rest", manufacturerSlug: "therm-a-rest",
model: "NeoAir XLite", model: "NeoAir XLite",
tags: ["sleeping-pad", "ultralight"], tags: ["sleeping-pad", "ultralight"],
}); });
@@ -342,16 +317,17 @@ describe("Global Item Service", () => {
}); });
it("upsertGlobalItem without tags leaves existing tags untouched", async () => { it("upsertGlobalItem without tags leaves existing tags untouched", async () => {
await insertManufacturer(db, "Sea to Summit", "sea-to-summit");
// Create item with tags // Create item with tags
const first = await upsertGlobalItem(db, { const first = await upsertGlobalItem(db, {
brand: "Sea to Summit", manufacturerSlug: "sea-to-summit",
model: "Spark III", model: "Spark III",
tags: ["sleeping-bag"], tags: ["sleeping-bag"],
}); });
// Upsert without tags // Upsert without tags
await upsertGlobalItem(db, { await upsertGlobalItem(db, {
brand: "Sea to Summit", manufacturerSlug: "sea-to-summit",
model: "Spark III", model: "Spark III",
weightGrams: 450, weightGrams: 450,
}); });
@@ -366,16 +342,17 @@ describe("Global Item Service", () => {
}); });
it("upsertGlobalItem with empty tags array clears existing tags", async () => { it("upsertGlobalItem with empty tags array clears existing tags", async () => {
await insertManufacturer(db, "Big Agnes", "big-agnes");
// Create item with tags // Create item with tags
const first = await upsertGlobalItem(db, { const first = await upsertGlobalItem(db, {
brand: "Big Agnes", manufacturerSlug: "big-agnes",
model: "Copper Spur HV UL2", model: "Copper Spur HV UL2",
tags: ["tent", "ultralight"], tags: ["tent", "ultralight"],
}); });
// Upsert with empty tags // Upsert with empty tags
await upsertGlobalItem(db, { await upsertGlobalItem(db, {
brand: "Big Agnes", manufacturerSlug: "big-agnes",
model: "Copper Spur HV UL2", model: "Copper Spur HV UL2",
tags: [], tags: [],
}); });
@@ -390,10 +367,12 @@ describe("Global Item Service", () => {
}); });
it("bulkUpsertGlobalItems processes array and returns correct created/updated counts", async () => { 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, [ const result = await bulkUpsertGlobalItems(db, [
{ brand: "Petzl", model: "Actik Core", weightGrams: 87 }, { manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87 },
{ brand: "Black Diamond", model: "Spot 400", weightGrams: 95 }, { manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 },
{ brand: "Black Diamond", model: "Spot 350", weightGrams: 90 }, { manufacturerSlug: "black-diamond", model: "Spot 350", weightGrams: 90 },
]); ]);
expect(result.created).toBe(3); expect(result.created).toBe(3);
@@ -402,16 +381,18 @@ describe("Global Item Service", () => {
}); });
it("bulkUpsertGlobalItems handles mix of new and existing items", async () => { 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 // Pre-insert one item
await upsertGlobalItem(db, { await upsertGlobalItem(db, {
brand: "Petzl", manufacturerSlug: "petzl",
model: "Actik Core", model: "Actik Core",
weightGrams: 87, weightGrams: 87,
}); });
const result = await bulkUpsertGlobalItems(db, [ const result = await bulkUpsertGlobalItems(db, [
{ brand: "Petzl", model: "Actik Core", weightGrams: 90 }, // existing { manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 90 }, // existing
{ brand: "Black Diamond", model: "Spot 400", weightGrams: 95 }, // new { manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 }, // new
]); ]);
expect(result.created).toBe(1); expect(result.created).toBe(1);