diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index f477ad6..797dced 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -67,15 +67,15 @@ const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({ getParentRoute: () => rootRouteImport, } as any) const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => ThreadsThreadIdRoute, + id: '/threads/$threadId/', + path: '/threads/$threadId/', + getParentRoute: () => rootRouteImport, } as any) const ThreadsThreadIdCandidatesCandidateIdRoute = ThreadsThreadIdCandidatesCandidateIdRouteImport.update({ - id: '/candidates/$candidateId', - path: '/candidates/$candidateId', - getParentRoute: () => ThreadsThreadIdRoute, + id: '/threads/$threadId/candidates/$candidateId', + path: '/threads/$threadId/candidates/$candidateId', + getParentRoute: () => rootRouteImport, } as any) export interface FileRoutesByFullPath { @@ -170,6 +170,8 @@ export interface RootRouteChildren { UsersUserIdRoute: typeof UsersUserIdRoute CollectionIndexRoute: typeof CollectionIndexRoute GlobalItemsIndexRoute: typeof GlobalItemsIndexRoute + ThreadsThreadIdIndexRoute: typeof ThreadsThreadIdIndexRoute + ThreadsThreadIdCandidatesCandidateIdRoute: typeof ThreadsThreadIdCandidatesCandidateIdRoute } declare module '@tanstack/react-router' { @@ -239,17 +241,17 @@ declare module '@tanstack/react-router' { } '/threads/$threadId/': { id: '/threads/$threadId/' - path: '/' + path: '/threads/$threadId' fullPath: '/threads/$threadId/' preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport - parentRoute: typeof ThreadsThreadIdRoute + parentRoute: typeof rootRouteImport } '/threads/$threadId/candidates/$candidateId': { id: '/threads/$threadId/candidates/$candidateId' - path: '/candidates/$candidateId' + path: '/threads/$threadId/candidates/$candidateId' fullPath: '/threads/$threadId/candidates/$candidateId' preLoaderRoute: typeof ThreadsThreadIdCandidatesCandidateIdRouteImport - parentRoute: typeof ThreadsThreadIdRoute + parentRoute: typeof rootRouteImport } } } @@ -264,6 +266,9 @@ const rootRouteChildren: RootRouteChildren = { UsersUserIdRoute: UsersUserIdRoute, CollectionIndexRoute: CollectionIndexRoute, GlobalItemsIndexRoute: GlobalItemsIndexRoute, + ThreadsThreadIdIndexRoute: ThreadsThreadIdIndexRoute, + ThreadsThreadIdCandidatesCandidateIdRoute: + ThreadsThreadIdCandidatesCandidateIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/server/mcp/tools/catalog.ts b/src/server/mcp/tools/catalog.ts index 55f420d..4ff2647 100644 --- a/src/server/mcp/tools/catalog.ts +++ b/src/server/mcp/tools/catalog.ts @@ -16,21 +16,43 @@ function textResult(data: unknown): ToolResult { } function errorResult(message: string): ToolResult { - return { content: [{ type: "text", text: JSON.stringify({ error: message }) }] }; + return { + content: [{ type: "text", text: JSON.stringify({ error: message }) }], + }; } const catalogItemInputSchema = { brand: z.string().describe("Brand or manufacturer name"), - model: z.string().describe("Model name — combined with brand forms the unique identifier"), - category: z.string().optional().describe("Category name (e.g., 'Bags', 'Lights')"), + model: z + .string() + .describe("Model name — combined with brand forms the unique identifier"), + category: z + .string() + .optional() + .describe("Category name (e.g., 'Bags', 'Lights')"), weightGrams: z.number().optional().describe("Weight in grams"), - priceCents: z.number().optional().describe("MSRP price in cents (e.g., 9999 = $99.99)"), + priceCents: z + .number() + .optional() + .describe("MSRP price in cents (e.g., 9999 = $99.99)"), imageUrl: z.string().optional().describe("URL to the product image"), description: z.string().optional().describe("Product description"), - sourceUrl: z.string().optional().describe("URL to the product page on manufacturer/retailer site"), - imageCredit: z.string().optional().describe("Image credit — photographer or source name"), - imageSourceUrl: z.string().optional().describe("Original URL where the image was sourced from"), - tags: z.array(z.string()).optional().describe("Tags for categorization (created automatically if new)"), + sourceUrl: z + .string() + .optional() + .describe("URL to the product page on manufacturer/retailer site"), + imageCredit: z + .string() + .optional() + .describe("Image credit — photographer or source name"), + imageSourceUrl: z + .string() + .optional() + .describe("Original URL where the image was sourced from"), + tags: z + .array(z.string()) + .optional() + .describe("Tags for categorization (created automatically if new)"), }; export const catalogToolDefinitions = [ diff --git a/src/server/routes/global-items.ts b/src/server/routes/global-items.ts index 529969a..96733ac 100644 --- a/src/server/routes/global-items.ts +++ b/src/server/routes/global-items.ts @@ -48,11 +48,15 @@ app.post("/", zValidator("json", upsertGlobalItemSchema), async (c) => { }); // Bulk upsert — per D-06, D-07, D-08 -app.post("/bulk", zValidator("json", bulkUpsertGlobalItemsSchema), async (c) => { - const db = c.get("db"); - const { items } = c.req.valid("json"); - const result = await bulkUpsertGlobalItems(db, items); - return c.json(result); -}); +app.post( + "/bulk", + zValidator("json", bulkUpsertGlobalItemsSchema), + async (c) => { + const db = c.get("db"); + const { items } = c.req.valid("json"); + const result = await bulkUpsertGlobalItems(db, items); + return c.json(result); + }, +); export { app as globalItemRoutes }; diff --git a/tests/mcp/tools.test.ts b/tests/mcp/tools.test.ts index f724b2f..8152e45 100644 --- a/tests/mcp/tools.test.ts +++ b/tests/mcp/tools.test.ts @@ -302,12 +302,17 @@ describe("MCP Catalog Tools", () => { model: "PocketRocket 2", sourceUrl: "https://www.cascadedesigns.com/msr/pocket-rocket-2", imageCredit: "MSR Photography", - imageSourceUrl: "https://cdn.cascadedesigns.com/images/pocket-rocket-2.jpg", + imageSourceUrl: + "https://cdn.cascadedesigns.com/images/pocket-rocket-2.jpg", }); const data = parseResult(result); - expect(data.sourceUrl).toBe("https://www.cascadedesigns.com/msr/pocket-rocket-2"); + expect(data.sourceUrl).toBe( + "https://www.cascadedesigns.com/msr/pocket-rocket-2", + ); expect(data.imageCredit).toBe("MSR Photography"); - expect(data.imageSourceUrl).toBe("https://cdn.cascadedesigns.com/images/pocket-rocket-2.jpg"); + expect(data.imageSourceUrl).toBe( + "https://cdn.cascadedesigns.com/images/pocket-rocket-2.jpg", + ); }); test("bulk_upsert_catalog processes array and returns created/updated counts", async () => { @@ -333,7 +338,10 @@ describe("MCP Catalog Tools", () => { const tools = registerCatalogTools(db); // Pre-create one item - await tools.upsert_catalog_item({ brand: "Revelate Designs", model: "Terrapin System" }); + await tools.upsert_catalog_item({ + brand: "Revelate Designs", + model: "Terrapin System", + }); const result = await tools.bulk_upsert_catalog({ items: [ @@ -348,7 +356,9 @@ describe("MCP Catalog Tools", () => { }); test("catalog tool definitions include attribution fields in inputSchema", () => { - const { catalogToolDefinitions } = require("../../src/server/mcp/tools/catalog.ts"); + const { + catalogToolDefinitions, + } = require("../../src/server/mcp/tools/catalog.ts"); const upsertDef = catalogToolDefinitions.find( (d: { name: string }) => d.name === "upsert_catalog_item", ); diff --git a/tests/routes/global-items.test.ts b/tests/routes/global-items.test.ts index cb8940f..67f7bd9 100644 --- a/tests/routes/global-items.test.ts +++ b/tests/routes/global-items.test.ts @@ -112,136 +112,139 @@ describe("Global Item Routes", () => { }); describe("POST /api/global-items", () => { - it("returns 200 with item and created=true on new item", async () => { - const res = await app.request("/api/global-items", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ brand: "Revelate Designs", model: "Terrapin System" }), - }); - expect(res.status).toBe(200); + it("returns 200 with item and created=true on new item", async () => { + const res = await app.request("/api/global-items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + brand: "Revelate Designs", + model: "Terrapin System", + }), + }); + expect(res.status).toBe(200); - const body = await res.json(); - expect(body.item.brand).toBe("Revelate Designs"); - expect(body.item.model).toBe("Terrapin System"); - expect(body.created).toBe(true); + const body = await res.json(); + expect(body.item.brand).toBe("Revelate Designs"); + expect(body.item.model).toBe("Terrapin System"); + expect(body.created).toBe(true); + }); + + it("returns 200 with created=false when upserting existing item", async () => { + await insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + + const res = await app.request("/api/global-items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + brand: "Revelate Designs", + model: "Terrapin System", + description: "Updated description", + }), + }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.created).toBe(false); + expect(body.item.description).toBe("Updated description"); + }); + + it("returns 400 when brand is missing", async () => { + const res = await app.request("/api/global-items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "Terrapin System" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when model is missing", async () => { + const res = await app.request("/api/global-items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ brand: "Revelate Designs" }), + }); + expect(res.status).toBe(400); + }); }); - it("returns 200 with created=false when upserting existing item", async () => { - await insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + describe("POST /api/global-items/bulk", () => { + it("returns 200 with created/updated counts", async () => { + const res = await app.request("/api/global-items/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: [ + { brand: "Revelate Designs", model: "Terrapin System" }, + { brand: "Apidura", model: "Handlebar Pack" }, + ], + }), + }); + expect(res.status).toBe(200); - const res = await app.request("/api/global-items", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - brand: "Revelate Designs", - model: "Terrapin System", - description: "Updated description", - }), + const body = await res.json(); + expect(body.created).toBe(2); + expect(body.updated).toBe(0); + expect(body.items).toHaveLength(2); }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.created).toBe(false); - expect(body.item.description).toBe("Updated description"); + it("returns correct counts for mix of new and existing items", async () => { + await insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + + const res = await app.request("/api/global-items/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: [ + { brand: "Revelate Designs", model: "Terrapin System" }, + { brand: "Apidura", model: "Handlebar Pack" }, + ], + }), + }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.created).toBe(1); + expect(body.updated).toBe(1); + }); + + it("returns 400 when items array is empty", async () => { + const res = await app.request("/api/global-items/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: [] }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when items array exceeds 100", async () => { + const items = Array.from({ length: 101 }, (_, i) => ({ + brand: `Brand${i}`, + model: `Model${i}`, + })); + const res = await app.request("/api/global-items/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid item in array (missing brand)", async () => { + const res = await app.request("/api/global-items/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: [ + { brand: "Revelate Designs", model: "Terrapin System" }, + { model: "Invalid Item without brand" }, + ], + }), + }); + expect(res.status).toBe(400); + }); }); - it("returns 400 when brand is missing", async () => { - const res = await app.request("/api/global-items", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "Terrapin System" }), - }); - expect(res.status).toBe(400); - }); - - it("returns 400 when model is missing", async () => { - const res = await app.request("/api/global-items", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ brand: "Revelate Designs" }), - }); - expect(res.status).toBe(400); - }); -}); - -describe("POST /api/global-items/bulk", () => { - it("returns 200 with created/updated counts", async () => { - const res = await app.request("/api/global-items/bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - items: [ - { brand: "Revelate Designs", model: "Terrapin System" }, - { brand: "Apidura", model: "Handlebar Pack" }, - ], - }), - }); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body.created).toBe(2); - expect(body.updated).toBe(0); - expect(body.items).toHaveLength(2); - }); - - it("returns correct counts for mix of new and existing items", async () => { - await insertGlobalItem(db, "Revelate Designs", "Terrapin System"); - - const res = await app.request("/api/global-items/bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - items: [ - { brand: "Revelate Designs", model: "Terrapin System" }, - { brand: "Apidura", model: "Handlebar Pack" }, - ], - }), - }); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body.created).toBe(1); - expect(body.updated).toBe(1); - }); - - it("returns 400 when items array is empty", async () => { - const res = await app.request("/api/global-items/bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: [] }), - }); - expect(res.status).toBe(400); - }); - - it("returns 400 when items array exceeds 100", async () => { - const items = Array.from({ length: 101 }, (_, i) => ({ - brand: `Brand${i}`, - model: `Model${i}`, - })); - const res = await app.request("/api/global-items/bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items }), - }); - expect(res.status).toBe(400); - }); - - it("returns 400 for invalid item in array (missing brand)", async () => { - const res = await app.request("/api/global-items/bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - items: [ - { brand: "Revelate Designs", model: "Terrapin System" }, - { model: "Invalid Item without brand" }, - ], - }), - }); - expect(res.status).toBe(400); - }); -}); - -describe("GET /api/global-items/:id", () => { + describe("GET /api/global-items/:id", () => { it("returns item with ownerCount", async () => { const gi = await insertGlobalItem(db, "MSR", "PocketRocket 2");