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