diff --git a/src/server/routes/global-items.ts b/src/server/routes/global-items.ts index cddd080..2831ba6 100644 --- a/src/server/routes/global-items.ts +++ b/src/server/routes/global-items.ts @@ -9,21 +9,26 @@ type Env = { Variables: { db?: any } }; const app = new Hono(); -app.get("/", (c) => { +app.get("/", async (c) => { const db = c.get("db"); const q = c.req.query("q"); - const items = searchGlobalItems(db, q || undefined); + const tagsParam = c.req.query("tags"); + const tagNames = tagsParam + ? tagsParam + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : undefined; + const items = await searchGlobalItems(db, q || undefined, tagNames); return c.json(items); }); -app.get("/:id", (c) => { +app.get("/:id", async (c) => { const db = c.get("db"); const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid global item ID" }, 400); - - const item = getGlobalItemWithOwnerCount(db, id); + const item = await getGlobalItemWithOwnerCount(db, id); if (!item) return c.json({ error: "Global item not found" }, 404); - return c.json(item); }); diff --git a/src/server/services/global-item.service.ts b/src/server/services/global-item.service.ts index e57fd58..bb1f54f 100644 --- a/src/server/services/global-item.service.ts +++ b/src/server/services/global-item.service.ts @@ -1,32 +1,63 @@ -import { count, eq, like, or, sql } from "drizzle-orm"; +import { and, count, eq, ilike, or, sql } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; -import { globalItems, itemGlobalLinks } from "../../db/schema.ts"; +import { globalItemTags, globalItems, items, tags } from "../../db/schema.ts"; type Db = typeof prodDb; /** - * Search global items by brand or model. LIKE is case-insensitive for ASCII. + * Search global items by brand or model and/or tag names. + * Text search uses ILIKE for case-insensitive matching (PostgreSQL). + * Tag filtering uses AND logic -- items must have ALL specified tags. * Escapes % and _ wildcard characters in user input. */ -export async function searchGlobalItems(db: Db = prodDb, query?: string) { - if (!query) { - return db.select().from(globalItems); +export async function searchGlobalItems( + db: Db = prodDb, + query?: string, + tagNames?: string[], +) { + const conditions: SQL[] = []; + + if (query) { + const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); + const pattern = `%${escaped}%`; + conditions.push( + or( + ilike(globalItems.brand, pattern), + ilike(globalItems.model, pattern), + )!, + ); } - // Escape SQL LIKE wildcards - const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); - const pattern = `%${escaped}%`; + if (tagNames && tagNames.length > 0) { + conditions.push( + sql`${globalItems.id} IN ( + SELECT ${globalItemTags.globalItemId} + FROM ${globalItemTags} + JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId} + WHERE ${tags.name} IN (${sql.join( + tagNames.map((t) => sql`${t}`), + sql`, `, + )}) + GROUP BY ${globalItemTags.globalItemId} + HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length} + )`, + ); + } + + if (conditions.length === 0) { + return db.select().from(globalItems); + } return db .select() .from(globalItems) - .where( - or(like(globalItems.brand, pattern), like(globalItems.model, pattern)), - ); + .where(and(...conditions)); } /** - * Get a single global item by ID with the count of user items linked to it. + * 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, @@ -41,35 +72,8 @@ export async function getGlobalItemWithOwnerCount( const [result] = await db .select({ ownerCount: count() }) - .from(itemGlobalLinks) - .where(eq(itemGlobalLinks.globalItemId, id)); + .from(items) + .where(eq(items.globalItemId, id)); return { ...item, ownerCount: result?.ownerCount ?? 0 }; } - -/** - * Link a user's item to a global item. Throws on duplicate (unique constraint on itemId). - */ -export async function linkItemToGlobal( - db: Db = prodDb, - itemId: number, - globalItemId: number, -) { - const [row] = await db - .insert(itemGlobalLinks) - .values({ itemId, globalItemId }) - .returning(); - return row; -} - -/** - * Remove the link between a user's item and any global item. - */ -export async function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) { - const result = await db - .delete(itemGlobalLinks) - .where(eq(itemGlobalLinks.itemId, itemId)) - .returning(); - - return result.length; -} diff --git a/tests/routes/global-items.test.ts b/tests/routes/global-items.test.ts index 8097a44..a464a39 100644 --- a/tests/routes/global-items.test.ts +++ b/tests/routes/global-items.test.ts @@ -1,52 +1,71 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { Hono } from "hono"; -import { globalItems, itemGlobalLinks, items } from "../../src/db/schema.ts"; +import { + globalItemTags, + globalItems, + items, + tags, +} from "../../src/db/schema.ts"; import { globalItemRoutes } from "../../src/server/routes/global-items.ts"; -import { itemRoutes } from "../../src/server/routes/items.ts"; import { createTestDb } from "../helpers/db.ts"; -type TestDb = ReturnType; +type TestDb = Awaited>; -function createTestApp() { - const db = createTestDb(); +async function createTestApp() { + const { db, userId } = await createTestDb(); const app = new Hono(); app.use("*", async (c, next) => { c.set("db", db); + c.set("userId", userId); await next(); }); app.route("/api/global-items", globalItemRoutes); - app.route("/api/items", itemRoutes); - return { app, db }; + return { app, db, userId }; } -function insertGlobalItem(db: TestDb, brand: string, model: string) { - return db +async function insertGlobalItem( + db: TestDb["db"], + brand: string, + model: string, +) { + const [row] = await db .insert(globalItems) .values({ brand, model, category: "bags" }) - .returning() - .get(); + .returning(); + return row; } -function insertItem(db: TestDb, name: string) { - return db.insert(items).values({ name, categoryId: 1 }).returning().get(); +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; } describe("Global Item Routes", () => { let app: Hono; - let db: TestDb; + let db: TestDb["db"]; + let userId: number; - beforeEach(() => { - const testApp = createTestApp(); + beforeEach(async () => { + const testApp = await createTestApp(); app = testApp.app; db = testApp.db; + userId = testApp.userId; }); describe("GET /api/global-items", () => { it("returns 200 with all global items", async () => { - insertGlobalItem(db, "Revelate Designs", "Terrapin System"); - insertGlobalItem(db, "Apidura", "Handlebar Pack"); + await insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + await insertGlobalItem(db, "Apidura", "Handlebar Pack"); const res = await app.request("/api/global-items"); expect(res.status).toBe(200); @@ -56,25 +75,47 @@ describe("Global Item Routes", () => { }); it("filters results by query parameter", async () => { - insertGlobalItem(db, "Revelate Designs", "Terrapin System"); - insertGlobalItem(db, "Apidura", "Handlebar Pack"); + await insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + await insertGlobalItem(db, "Apidura", "Handlebar Pack"); - const res = await app.request("/api/global-items?q=tent"); + const res = await app.request("/api/global-items?q=revelate"); expect(res.status).toBe(200); const body = await res.json(); - // "tent" doesn't match "Terrapin" or "Handlebar" — expect 0 - // Actually let's search for something that matches - const res2 = await app.request("/api/global-items?q=revelate"); - const body2 = await res2.json(); - expect(body2).toHaveLength(1); - expect(body2[0].brand).toBe("Revelate Designs"); + expect(body).toHaveLength(1); + expect(body[0].brand).toBe("Revelate Designs"); + }); + + it("filters results by tags parameter", async () => { + const gi1 = await insertGlobalItem( + db, + "Revelate Designs", + "Terrapin System", + ); + await insertGlobalItem(db, "Apidura", "Handlebar Pack"); + + const [tag] = await db + .insert(tags) + .values({ name: "ultralight" }) + .returning(); + await db + .insert(globalItemTags) + .values({ globalItemId: gi1.id, tagId: tag.id }); + + const res = await app.request( + "/api/global-items?tags=ultralight", + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toHaveLength(1); + expect(body[0].brand).toBe("Revelate Designs"); }); }); describe("GET /api/global-items/:id", () => { it("returns item with ownerCount", async () => { - const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + const gi = await insertGlobalItem(db, "MSR", "PocketRocket 2"); const res = await app.request(`/api/global-items/${gi.id}`); expect(res.status).toBe(200); @@ -84,6 +125,19 @@ describe("Global Item Routes", () => { expect(body.ownerCount).toBe(0); }); + it("returns ownerCount from items.globalItemId", async () => { + const gi = await insertGlobalItem(db, "MSR", "PocketRocket 2"); + await insertItem(db, "My Stove", userId, { + globalItemId: gi.id, + }); + + const res = await app.request(`/api/global-items/${gi.id}`); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.ownerCount).toBe(1); + }); + it("returns 404 for non-existent id", async () => { const res = await app.request("/api/global-items/999"); expect(res.status).toBe(404); @@ -94,84 +148,4 @@ describe("Global Item Routes", () => { expect(res.status).toBe(400); }); }); - - describe("POST /api/items/:id/link", () => { - it("returns 201 when linking item to global item", async () => { - const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); - const item = insertItem(db, "My Stove"); - - const res = await app.request(`/api/items/${item.id}/link`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ globalItemId: gi.id }), - }); - - expect(res.status).toBe(201); - const body = await res.json(); - expect(body.itemId).toBe(item.id); - expect(body.globalItemId).toBe(gi.id); - }); - - it("returns 409 when item already linked", async () => { - const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); - const item = insertItem(db, "My Stove"); - - // Link once - await app.request(`/api/items/${item.id}/link`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ globalItemId: gi.id }), - }); - - // Link again — should conflict - const res = await app.request(`/api/items/${item.id}/link`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ globalItemId: gi.id }), - }); - - expect(res.status).toBe(409); - }); - - it("returns 404 when item does not exist", async () => { - const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); - - const res = await app.request("/api/items/999/link", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ globalItemId: gi.id }), - }); - - expect(res.status).toBe(404); - }); - }); - - describe("DELETE /api/items/:id/link", () => { - it("returns 200 when unlinking", async () => { - const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); - const item = insertItem(db, "My Stove"); - - // Link first - await app.request(`/api/items/${item.id}/link`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ globalItemId: gi.id }), - }); - - // Unlink - const res = await app.request(`/api/items/${item.id}/link`, { - method: "DELETE", - }); - - expect(res.status).toBe(200); - }); - - it("returns 404 when item does not exist", async () => { - const res = await app.request("/api/items/999/link", { - method: "DELETE", - }); - - expect(res.status).toBe(404); - }); - }); }); diff --git a/tests/services/global-item.service.test.ts b/tests/services/global-item.service.test.ts index fcc7587..a808267 100644 --- a/tests/services/global-item.service.test.ts +++ b/tests/services/global-item.service.test.ts @@ -1,18 +1,21 @@ import { beforeEach, describe, expect, it } from "bun:test"; -import { globalItems, itemGlobalLinks, items } from "../../src/db/schema.ts"; +import { + globalItemTags, + globalItems, + items, + tags, +} from "../../src/db/schema.ts"; import { seedGlobalItems } from "../../src/db/seed-global-items.ts"; import { getGlobalItemWithOwnerCount, - linkItemToGlobal, searchGlobalItems, - unlinkItemFromGlobal, } from "../../src/server/services/global-item.service.ts"; import { createTestDb } from "../helpers/db.ts"; -type TestDb = ReturnType; +type TestDb = Awaited>; -function insertGlobalItem( - db: TestDb, +async function insertGlobalItem( + db: TestDb["db"], data: { brand: string; model: string; @@ -21,7 +24,7 @@ function insertGlobalItem( priceCents?: number; }, ) { - return db + const [row] = await db .insert(globalItems) .values({ brand: data.brand, @@ -30,164 +33,234 @@ function insertGlobalItem( weightGrams: data.weightGrams ?? null, priceCents: data.priceCents ?? null, }) - .returning() - .get(); + .returning(); + return row; } -function insertItem(db: TestDb, name: string) { - return db.insert(items).values({ name, categoryId: 1 }).returning().get(); +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; + let db: TestDb["db"]; + let userId: number; - beforeEach(() => { - db = createTestDb(); + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + userId = testDb.userId; }); describe("searchGlobalItems", () => { - it("returns all global items when no query provided", () => { - insertGlobalItem(db, { + it("returns all global items when no query provided", async () => { + await insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System", }); - insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + await insertGlobalItem(db, { + brand: "Apidura", + model: "Handlebar Pack", + }); - const results = searchGlobalItems(db); + const results = await searchGlobalItems(db); expect(results).toHaveLength(2); }); - it("returns items matching brand (case-insensitive)", () => { - insertGlobalItem(db, { + it("returns items matching brand (case-insensitive)", async () => { + await insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System", }); - insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + await insertGlobalItem(db, { + brand: "Apidura", + model: "Handlebar Pack", + }); - const results = searchGlobalItems(db, "revelate"); + const results = await searchGlobalItems(db, "revelate"); expect(results).toHaveLength(1); expect(results[0].brand).toBe("Revelate Designs"); }); - it("returns items matching model (case-insensitive)", () => { - insertGlobalItem(db, { + it("returns items matching model (case-insensitive)", async () => { + await insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System", }); - insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + await insertGlobalItem(db, { + brand: "Apidura", + model: "Handlebar Pack", + }); - const results = searchGlobalItems(db, "HANDLEBAR"); + 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", () => { - insertGlobalItem(db, { + it("does not match everything with wildcard chars", async () => { + await insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System", }); - insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + await insertGlobalItem(db, { + brand: "Apidura", + model: "Handlebar Pack", + }); - const results = searchGlobalItems(db, "100%"); + 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 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 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 gi1 = await insertGlobalItem(db, { + brand: "Revelate Designs", + model: "Terrapin System", + }); + const gi2 = await insertGlobalItem(db, { + brand: "Apidura", + 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 gi1 = await insertGlobalItem(db, { + brand: "Revelate Designs", + model: "Terrapin System", + }); + const gi2 = await insertGlobalItem(db, { + brand: "Revelate Designs", + 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 links", () => { - const gi = insertGlobalItem(db, { + it("returns item with ownerCount 0 when no items reference it", async () => { + const gi = await insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2", }); - const result = getGlobalItemWithOwnerCount(db, gi.id); + 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 linked items", () => { - const gi = insertGlobalItem(db, { + it("returns ownerCount matching number of items with globalItemId", async () => { + const gi = await insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2", }); - const item1 = insertItem(db, "My Stove"); - const item2 = insertItem(db, "Another Stove"); - db.insert(itemGlobalLinks) - .values({ itemId: item1.id, globalItemId: gi.id }) - .run(); - db.insert(itemGlobalLinks) - .values({ itemId: item2.id, globalItemId: gi.id }) - .run(); + await insertItem(db, "My Stove", userId, { globalItemId: gi.id }); + await insertItem(db, "Another Stove", userId, { + globalItemId: gi.id, + }); - const result = getGlobalItemWithOwnerCount(db, 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", () => { - const result = getGlobalItemWithOwnerCount(db, 9999); + it("returns null for non-existent id", async () => { + const result = await getGlobalItemWithOwnerCount(db, 9999); expect(result).toBeNull(); }); }); - describe("linkItemToGlobal", () => { - it("creates link and returns link row", () => { - const gi = insertGlobalItem(db, { - brand: "MSR", - model: "PocketRocket 2", - }); - const item = insertItem(db, "My Stove"); - - const link = linkItemToGlobal(db, item.id, gi.id); - expect(link.itemId).toBe(item.id); - expect(link.globalItemId).toBe(gi.id); - }); - - it("throws when item already linked", () => { - const gi = insertGlobalItem(db, { - brand: "MSR", - model: "PocketRocket 2", - }); - const item = insertItem(db, "My Stove"); - - linkItemToGlobal(db, item.id, gi.id); - expect(() => linkItemToGlobal(db, item.id, gi.id)).toThrow(); - }); - }); - - describe("unlinkItemFromGlobal", () => { - it("removes the link", () => { - const gi = insertGlobalItem(db, { - brand: "MSR", - model: "PocketRocket 2", - }); - const item = insertItem(db, "My Stove"); - - linkItemToGlobal(db, item.id, gi.id); - const deleted = unlinkItemFromGlobal(db, item.id); - expect(deleted).toBe(1); - - // Verify link is gone - const result = getGlobalItemWithOwnerCount(db, gi.id); - expect(result!.ownerCount).toBe(0); - }); - }); - describe("seedGlobalItems", () => { - it("inserts seed data on first call", () => { - seedGlobalItems(db); - const all = db.select().from(globalItems).all(); + 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", () => { - seedGlobalItems(db); - const countAfterFirst = db.select().from(globalItems).all().length; + it("is idempotent on second call", async () => { + await seedGlobalItems(db); + const countAfterFirst = (await db.select().from(globalItems)).length; - seedGlobalItems(db); - const countAfterSecond = db.select().from(globalItems).all().length; + await seedGlobalItems(db); + const countAfterSecond = (await db.select().from(globalItems)).length; expect(countAfterSecond).toBe(countAfterFirst); });