fix: update all tests and MCP catalog tool for manufacturerId schema migration

This commit is contained in:
2026-04-18 16:30:11 +02:00
parent a508773809
commit 0b4715b80c
7 changed files with 135 additions and 41 deletions

View File

@@ -22,10 +22,10 @@ function errorResult(message: string): ToolResult {
} }
const catalogItemInputSchema = { const catalogItemInputSchema = {
brand: z.string().describe("Brand or manufacturer name"), manufacturerSlug: z.string().describe("Manufacturer slug (e.g. 'revelate-designs', 'apidura')"),
model: z model: z
.string() .string()
.describe("Model name — combined with brand forms the unique identifier"), .describe("Model name — combined with manufacturerSlug forms the unique identifier"),
category: z category: z
.string() .string()
.optional() .optional()
@@ -80,7 +80,7 @@ export const catalogToolDefinitions = [
export function registerCatalogTools(db: Db) { export function registerCatalogTools(db: Db) {
return { return {
upsert_catalog_item: async (args: { upsert_catalog_item: async (args: {
brand: string; manufacturerSlug: string;
model: string; model: string;
category?: string; category?: string;
weightGrams?: number; weightGrams?: number;
@@ -105,7 +105,7 @@ export function registerCatalogTools(db: Db) {
bulk_upsert_catalog: async (args: { bulk_upsert_catalog: async (args: {
items: Array<{ items: Array<{
brand: string; manufacturerSlug: string;
model: string; model: string;
category?: string; category?: string;
weightGrams?: number; weightGrams?: number;

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { manufacturers } from "../../src/db/schema.ts";
import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts"; import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts";
import { registerCatalogTools } from "../../src/server/mcp/tools/catalog.ts"; import { registerCatalogTools } from "../../src/server/mcp/tools/catalog.ts";
import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts"; import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts";
@@ -7,6 +8,16 @@ import { registerSetupTools } from "../../src/server/mcp/tools/setups.ts";
import { registerThreadTools } from "../../src/server/mcp/tools/threads.ts"; import { registerThreadTools } from "../../src/server/mcp/tools/threads.ts";
import { createSecondTestUser, createTestDb } from "../helpers/db.ts"; import { createSecondTestUser, createTestDb } from "../helpers/db.ts";
async function insertManufacturer(db: any, name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.onConflictDoUpdate({ target: manufacturers.slug, set: { name } })
.returning();
return row!;
}
function parseResult(result: { function parseResult(result: {
content: Array<{ type: string; text: string }>; content: Array<{ type: string; text: string }>;
}) { }) {
@@ -256,15 +267,15 @@ describe("MCP Collection Summary Resource", () => {
describe("MCP Catalog Tools", () => { describe("MCP Catalog Tools", () => {
test("upsert_catalog_item creates a new global item with created=true", async () => { test("upsert_catalog_item creates a new global item with created=true", async () => {
const { db } = await createTestDb(); const { db } = await createTestDb();
await insertManufacturer(db, "Revelate Designs");
const tools = registerCatalogTools(db); const tools = registerCatalogTools(db);
const result = await tools.upsert_catalog_item({ const result = await tools.upsert_catalog_item({
brand: "Revelate Designs", manufacturerSlug: "revelate-designs",
model: "Terrapin System", model: "Terrapin System",
weightGrams: 235, weightGrams: 235,
priceCents: 16500, priceCents: 16500,
}); });
const data = parseResult(result); const data = parseResult(result);
expect(data.brand).toBe("Revelate Designs");
expect(data.model).toBe("Terrapin System"); expect(data.model).toBe("Terrapin System");
expect(data.created).toBe(true); expect(data.created).toBe(true);
expect(data.id).toBeDefined(); expect(data.id).toBeDefined();
@@ -272,17 +283,18 @@ describe("MCP Catalog Tools", () => {
test("upsert_catalog_item updates existing item on brand+model match", async () => { test("upsert_catalog_item updates existing item on brand+model match", async () => {
const { db } = await createTestDb(); const { db } = await createTestDb();
await insertManufacturer(db, "Apidura");
const tools = registerCatalogTools(db); const tools = registerCatalogTools(db);
// Create initial item // Create initial item
await tools.upsert_catalog_item({ await tools.upsert_catalog_item({
brand: "Apidura", manufacturerSlug: "apidura",
model: "Handlebar Pack", model: "Handlebar Pack",
}); });
// Update it // Update it
const result = await tools.upsert_catalog_item({ const result = await tools.upsert_catalog_item({
brand: "Apidura", manufacturerSlug: "apidura",
model: "Handlebar Pack", model: "Handlebar Pack",
description: "Updated description", description: "Updated description",
weightGrams: 120, weightGrams: 120,
@@ -295,10 +307,11 @@ describe("MCP Catalog Tools", () => {
test("upsert_catalog_item includes attribution fields in result (SEED-03)", async () => { test("upsert_catalog_item includes attribution fields in result (SEED-03)", async () => {
const { db } = await createTestDb(); const { db } = await createTestDb();
await insertManufacturer(db, "MSR");
const tools = registerCatalogTools(db); const tools = registerCatalogTools(db);
const result = await tools.upsert_catalog_item({ const result = await tools.upsert_catalog_item({
brand: "MSR", manufacturerSlug: "msr",
model: "PocketRocket 2", model: "PocketRocket 2",
sourceUrl: "https://www.cascadedesigns.com/msr/pocket-rocket-2", sourceUrl: "https://www.cascadedesigns.com/msr/pocket-rocket-2",
imageCredit: "MSR Photography", imageCredit: "MSR Photography",
@@ -317,13 +330,16 @@ describe("MCP Catalog Tools", () => {
test("bulk_upsert_catalog processes array and returns created/updated counts", async () => { test("bulk_upsert_catalog processes array and returns created/updated counts", async () => {
const { db } = await createTestDb(); const { db } = await createTestDb();
await insertManufacturer(db, "Revelate Designs");
await insertManufacturer(db, "Apidura");
await insertManufacturer(db, "MSR");
const tools = registerCatalogTools(db); const tools = registerCatalogTools(db);
const result = await tools.bulk_upsert_catalog({ const result = await tools.bulk_upsert_catalog({
items: [ items: [
{ brand: "Revelate Designs", model: "Terrapin System" }, { manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" }, { manufacturerSlug: "apidura", model: "Handlebar Pack" },
{ brand: "MSR", model: "PocketRocket 2" }, { manufacturerSlug: "msr", model: "PocketRocket 2" },
], ],
}); });
const data = parseResult(result); const data = parseResult(result);
@@ -335,18 +351,20 @@ describe("MCP Catalog Tools", () => {
test("bulk_upsert_catalog returns totalProcessed matching input length", async () => { test("bulk_upsert_catalog returns totalProcessed matching input length", async () => {
const { db } = await createTestDb(); const { db } = await createTestDb();
await insertManufacturer(db, "Revelate Designs");
await insertManufacturer(db, "Apidura");
const tools = registerCatalogTools(db); const tools = registerCatalogTools(db);
// Pre-create one item // Pre-create one item
await tools.upsert_catalog_item({ await tools.upsert_catalog_item({
brand: "Revelate Designs", manufacturerSlug: "revelate-designs",
model: "Terrapin System", model: "Terrapin System",
}); });
const result = await tools.bulk_upsert_catalog({ const result = await tools.bulk_upsert_catalog({
items: [ items: [
{ brand: "Revelate Designs", model: "Terrapin System" }, { manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" }, { manufacturerSlug: "apidura", model: "Handlebar Pack" },
], ],
}); });
const data = parseResult(result); const data = parseResult(result);

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { globalItems, setups } from "../../src/db/schema.ts"; import { globalItems, manufacturers, setups } from "../../src/db/schema.ts";
import { discoveryRoutes } from "../../src/server/routes/discovery.ts"; import { discoveryRoutes } from "../../src/server/routes/discovery.ts";
import { createTestDb } from "../helpers/db.ts"; import { createTestDb } from "../helpers/db.ts";
@@ -20,17 +20,28 @@ async function createTestApp() {
return { app, db, userId }; return { app, db, userId };
} }
async function insertManufacturer(db: TestDb["db"], name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.onConflictDoUpdate({ target: manufacturers.slug, set: { name } })
.returning();
return row!;
}
async function insertGlobalItem( async function insertGlobalItem(
db: TestDb["db"], db: TestDb["db"],
brand: string, brand: string,
model: string, model: string,
category?: string, category?: string,
) { ) {
const m = await insertManufacturer(db, brand);
const [row] = await db const [row] = await db
.insert(globalItems) .insert(globalItems)
.values({ brand, model, category: category ?? "bags" }) .values({ manufacturerId: m.id, model, category: category ?? "bags" })
.returning(); .returning();
return row; return row!;
} }
async function insertPublicSetup( async function insertPublicSetup(
@@ -142,14 +153,16 @@ describe("Discovery Routes", () => {
const olderTime = new Date("2024-01-01T00:00:00Z"); const olderTime = new Date("2024-01-01T00:00:00Z");
const newerTime = new Date("2024-06-01T00:00:00Z"); const newerTime = new Date("2024-06-01T00:00:00Z");
const mA = await insertManufacturer(db, "Brand A");
const mB = await insertManufacturer(db, "Brand B");
await db.insert(globalItems).values({ await db.insert(globalItems).values({
brand: "Brand A", manufacturerId: mA.id,
model: "Model A", model: "Model A",
category: "bags", category: "bags",
createdAt: olderTime, createdAt: olderTime,
}); });
await db.insert(globalItems).values({ await db.insert(globalItems).values({
brand: "Brand B", manufacturerId: mB.id,
model: "Model B", model: "Model B",
category: "bags", category: "bags",
createdAt: newerTime, createdAt: newerTime,

View File

@@ -4,6 +4,7 @@ import {
globalItems, globalItems,
globalItemTags, globalItemTags,
items, items,
manufacturers,
tags, tags,
} from "../../src/db/schema.ts"; } from "../../src/db/schema.ts";
import { globalItemRoutes } from "../../src/server/routes/global-items.ts"; import { globalItemRoutes } from "../../src/server/routes/global-items.ts";
@@ -25,16 +26,27 @@ async function createTestApp() {
return { app, db, userId }; return { app, db, userId };
} }
async function insertManufacturer(db: TestDb["db"], name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.onConflictDoUpdate({ target: manufacturers.slug, set: { name } })
.returning();
return row!;
}
async function insertGlobalItem( async function insertGlobalItem(
db: TestDb["db"], db: TestDb["db"],
brand: string, brand: string,
model: string, model: string,
) { ) {
const m = await insertManufacturer(db, brand);
const [row] = await db const [row] = await db
.insert(globalItems) .insert(globalItems)
.values({ brand, model, category: "bags" }) .values({ manufacturerId: m.id, model, category: "bags" })
.returning(); .returning();
return row; return row!;
} }
async function insertItem( async function insertItem(
@@ -113,18 +125,18 @@ describe("Global Item Routes", () => {
describe("POST /api/global-items", () => { describe("POST /api/global-items", () => {
it("returns 200 with item and created=true on new item", async () => { it("returns 200 with item and created=true on new item", async () => {
await insertManufacturer(db, "Revelate Designs");
const res = await app.request("/api/global-items", { const res = await app.request("/api/global-items", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
brand: "Revelate Designs", manufacturerSlug: "revelate-designs",
model: "Terrapin System", model: "Terrapin System",
}), }),
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.item.brand).toBe("Revelate Designs");
expect(body.item.model).toBe("Terrapin System"); expect(body.item.model).toBe("Terrapin System");
expect(body.created).toBe(true); expect(body.created).toBe(true);
}); });
@@ -136,7 +148,7 @@ describe("Global Item Routes", () => {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
brand: "Revelate Designs", manufacturerSlug: "revelate-designs",
model: "Terrapin System", model: "Terrapin System",
description: "Updated description", description: "Updated description",
}), }),
@@ -148,7 +160,7 @@ describe("Global Item Routes", () => {
expect(body.item.description).toBe("Updated description"); expect(body.item.description).toBe("Updated description");
}); });
it("returns 400 when brand is missing", async () => { it("returns 400 when manufacturerSlug is missing", async () => {
const res = await app.request("/api/global-items", { const res = await app.request("/api/global-items", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -161,7 +173,7 @@ describe("Global Item Routes", () => {
const res = await app.request("/api/global-items", { const res = await app.request("/api/global-items", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand: "Revelate Designs" }), body: JSON.stringify({ manufacturerSlug: "revelate-designs" }),
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
@@ -169,13 +181,15 @@ describe("Global Item Routes", () => {
describe("POST /api/global-items/bulk", () => { describe("POST /api/global-items/bulk", () => {
it("returns 200 with created/updated counts", async () => { it("returns 200 with created/updated counts", async () => {
await insertManufacturer(db, "Revelate Designs");
await insertManufacturer(db, "Apidura");
const res = await app.request("/api/global-items/bulk", { const res = await app.request("/api/global-items/bulk", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
items: [ items: [
{ brand: "Revelate Designs", model: "Terrapin System" }, { manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" }, { manufacturerSlug: "apidura", model: "Handlebar Pack" },
], ],
}), }),
}); });
@@ -189,14 +203,15 @@ describe("Global Item Routes", () => {
it("returns correct counts for mix of new and existing items", async () => { it("returns correct counts for mix of new and existing items", async () => {
await insertGlobalItem(db, "Revelate Designs", "Terrapin System"); await insertGlobalItem(db, "Revelate Designs", "Terrapin System");
await insertManufacturer(db, "Apidura");
const res = await app.request("/api/global-items/bulk", { const res = await app.request("/api/global-items/bulk", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
items: [ items: [
{ brand: "Revelate Designs", model: "Terrapin System" }, { manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" }, { manufacturerSlug: "apidura", model: "Handlebar Pack" },
], ],
}), }),
}); });
@@ -218,7 +233,7 @@ describe("Global Item Routes", () => {
it("returns 400 when items array exceeds 100", async () => { it("returns 400 when items array exceeds 100", async () => {
const items = Array.from({ length: 101 }, (_, i) => ({ const items = Array.from({ length: 101 }, (_, i) => ({
brand: `Brand${i}`, manufacturerSlug: `brand${i}`,
model: `Model${i}`, model: `Model${i}`,
})); }));
const res = await app.request("/api/global-items/bulk", { const res = await app.request("/api/global-items/bulk", {
@@ -229,14 +244,14 @@ describe("Global Item Routes", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it("returns 400 for invalid item in array (missing brand)", async () => { it("returns 400 for invalid item in array (missing manufacturerSlug)", async () => {
const res = await app.request("/api/global-items/bulk", { const res = await app.request("/api/global-items/bulk", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
items: [ items: [
{ brand: "Revelate Designs", model: "Terrapin System" }, { manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ model: "Invalid Item without brand" }, { model: "Invalid Item without manufacturerSlug" },
], ],
}), }),
}); });

View File

@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { import {
globalItems, globalItems,
items, items,
manufacturers,
setupItems, setupItems,
setups, setups,
users, users,
@@ -16,19 +17,34 @@ 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: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [existing] = await db
.select()
.from(manufacturers)
.where(eq(manufacturers.slug, slug));
if (existing) return existing;
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.returning();
return row!;
}
async function insertGlobalItem( async function insertGlobalItem(
db: TestDb["db"], db: TestDb["db"],
data: { brand: string; model: string; category?: string }, data: { brand: string; model: string; category?: string },
) { ) {
const m = await insertManufacturer(db, data.brand);
const [row] = await db const [row] = await db
.insert(globalItems) .insert(globalItems)
.values({ .values({
brand: data.brand, manufacturerId: m.id,
model: data.model, model: data.model,
category: data.category ?? null, category: data.category ?? null,
}) })
.returning(); .returning();
return row; return row!;
} }
async function insertItem(db: TestDb["db"], userId: number, categoryId = 1) { async function insertItem(db: TestDb["db"], userId: number, categoryId = 1) {

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { globalItems } from "../../src/db/schema.ts"; import { globalItems, manufacturers } from "../../src/db/schema.ts";
import { import {
createItem, createItem,
deleteItem, deleteItem,
@@ -170,6 +170,15 @@ describe("Item Service", () => {
}); });
describe("reference items (globalItemId)", () => { describe("reference items (globalItemId)", () => {
async function insertManufacturer(testDb: any, name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await testDb
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.returning();
return row;
}
async function insertGlobalItem( async function insertGlobalItem(
testDb: any, testDb: any,
data: { data: {
@@ -180,7 +189,14 @@ describe("Item Service", () => {
imageUrl?: string; imageUrl?: string;
}, },
) { ) {
const [row] = await testDb.insert(globalItems).values(data).returning(); const m = await insertManufacturer(testDb, data.brand);
const [row] = await testDb.insert(globalItems).values({
manufacturerId: m.id,
model: data.model,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
imageUrl: data.imageUrl ?? null,
}).returning();
return row; return row;
} }

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { globalItems } from "../../src/db/schema.ts"; import { globalItems, manufacturers } from "../../src/db/schema.ts";
import { import {
createCandidate, createCandidate,
createThread, createThread,
@@ -618,6 +618,15 @@ describe("Thread Service", () => {
}); });
describe("catalog-linked candidates (globalItemId)", () => { describe("catalog-linked candidates (globalItemId)", () => {
async function insertManufacturer(testDb: any, name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await testDb
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.returning();
return row;
}
async function insertGlobalItem( async function insertGlobalItem(
testDb: any, testDb: any,
data: { data: {
@@ -628,7 +637,14 @@ describe("Thread Service", () => {
imageUrl?: string; imageUrl?: string;
}, },
) { ) {
const [row] = await testDb.insert(globalItems).values(data).returning(); const m = await insertManufacturer(testDb, data.brand);
const [row] = await testDb.insert(globalItems).values({
manufacturerId: m.id,
model: data.model,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
imageUrl: data.imageUrl ?? null,
}).returning();
return row; return row;
} }