diff --git a/src/server/index.ts b/src/server/index.ts index c601229..6609b59 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,7 @@ import { requireAuth } from "./middleware/auth.ts"; import { authRoutes } from "./routes/auth.ts"; import { categoryRoutes } from "./routes/categories.ts"; import { imageRoutes } from "./routes/images.ts"; +import { globalItemRoutes } from "./routes/global-items.ts"; import { itemRoutes } from "./routes/items.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; import { settingsRoutes } from "./routes/settings.ts"; @@ -73,6 +74,7 @@ app.route("/api/images", imageRoutes); app.route("/api/settings", settingsRoutes); app.route("/api/threads", threadRoutes); app.route("/api/setups", setupRoutes); +app.route("/api/global-items", globalItemRoutes); // MCP server (conditionally mounted) if (process.env.GEARBOX_MCP !== "false") { diff --git a/src/server/routes/global-items.ts b/src/server/routes/global-items.ts new file mode 100644 index 0000000..cddd080 --- /dev/null +++ b/src/server/routes/global-items.ts @@ -0,0 +1,30 @@ +import { Hono } from "hono"; +import { parseId } from "../lib/params.ts"; +import { + getGlobalItemWithOwnerCount, + searchGlobalItems, +} from "../services/global-item.service.ts"; + +type Env = { Variables: { db?: any } }; + +const app = new Hono(); + +app.get("/", (c) => { + const db = c.get("db"); + const q = c.req.query("q"); + const items = searchGlobalItems(db, q || undefined); + return c.json(items); +}); + +app.get("/:id", (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); + if (!item) return c.json({ error: "Global item not found" }, 404); + + return c.json(item); +}); + +export { app as globalItemRoutes }; diff --git a/src/server/routes/items.ts b/src/server/routes/items.ts index 2ba4325..81f6913 100644 --- a/src/server/routes/items.ts +++ b/src/server/routes/items.ts @@ -2,9 +2,17 @@ import { unlink } from "node:fs/promises"; import { join } from "node:path"; import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; -import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts"; +import { + createItemSchema, + linkItemSchema, + updateItemSchema, +} from "../../shared/schemas.ts"; import { parseId } from "../lib/params.ts"; import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts"; +import { + linkItemToGlobal, + unlinkItemFromGlobal, +} from "../services/global-item.service.ts"; import { createItem, deleteItem, @@ -103,4 +111,32 @@ app.delete("/:id", async (c) => { return c.json({ success: true }); }); +app.post("/:id/link", zValidator("json", linkItemSchema), (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid item ID" }, 400); + + const item = getItemById(db, id); + if (!item) return c.json({ error: "Item not found" }, 404); + + try { + const link = linkItemToGlobal(db, id, c.req.valid("json").globalItemId); + return c.json(link, 201); + } catch { + return c.json({ error: "Item already linked to a global item" }, 409); + } +}); + +app.delete("/:id/link", (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid item ID" }, 400); + + const item = getItemById(db, id); + if (!item) return c.json({ error: "Item not found" }, 404); + + unlinkItemFromGlobal(db, id); + return c.json({ success: true }); +}); + export { app as itemRoutes }; diff --git a/tests/routes/global-items.test.ts b/tests/routes/global-items.test.ts new file mode 100644 index 0000000..714c1b4 --- /dev/null +++ b/tests/routes/global-items.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { globalItems, itemGlobalLinks, items } 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; + +function createTestApp() { + const db = createTestDb(); + const app = new Hono(); + + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + + app.route("/api/global-items", globalItemRoutes); + app.route("/api/items", itemRoutes); + return { app, db }; +} + +function insertGlobalItem(db: TestDb, brand: string, model: string) { + return db + .insert(globalItems) + .values({ brand, model, category: "bags" }) + .returning() + .get(); +} + +function insertItem(db: TestDb, name: string) { + return db + .insert(items) + .values({ name, categoryId: 1 }) + .returning() + .get(); +} + +describe("Global Item Routes", () => { + let app: Hono; + let db: TestDb; + + beforeEach(() => { + const testApp = createTestApp(); + app = testApp.app; + db = testApp.db; + }); + + describe("GET /api/global-items", () => { + it("returns 200 with all global items", async () => { + insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + insertGlobalItem(db, "Apidura", "Handlebar Pack"); + + const res = await app.request("/api/global-items"); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toHaveLength(2); + }); + + it("filters results by query parameter", async () => { + insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + insertGlobalItem(db, "Apidura", "Handlebar Pack"); + + const res = await app.request("/api/global-items?q=tent"); + 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"); + }); + }); + + describe("GET /api/global-items/:id", () => { + it("returns item with ownerCount", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + + const res = await app.request(`/api/global-items/${gi.id}`); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.brand).toBe("MSR"); + expect(body.ownerCount).toBe(0); + }); + + it("returns 404 for non-existent id", async () => { + const res = await app.request("/api/global-items/999"); + expect(res.status).toBe(404); + }); + + it("returns 400 for invalid id", async () => { + const res = await app.request("/api/global-items/abc"); + 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); + }); + }); +});