import { describe, expect, test } from "bun:test"; import { manufacturers } from "../../src/db/schema.ts"; import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts"; import { registerCatalogTools } from "../../src/server/mcp/tools/catalog.ts"; import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts"; import { registerItemTools } from "../../src/server/mcp/tools/items.ts"; import { registerSetupTools } from "../../src/server/mcp/tools/setups.ts"; import { registerThreadTools } from "../../src/server/mcp/tools/threads.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: { content: Array<{ type: string; text: string }>; }) { return JSON.parse(result.content[0].text); } describe("MCP Item Tools", () => { test("list_items returns array", async () => { const { db, userId } = await createTestDb(); const tools = registerItemTools(db, userId); const result = await tools.list_items({}); const data = parseResult(result); expect(Array.isArray(data)).toBe(true); }); test("create_item creates and returns item", async () => { const { db, userId } = await createTestDb(); const tools = registerItemTools(db, userId); const result = await tools.create_item({ name: "Test Tent", categoryId: 1, weightGrams: 1200, priceCents: 35000, }); const data = parseResult(result); expect(data.name).toBe("Test Tent"); expect(data.weightGrams).toBe(1200); expect(data.priceCents).toBe(35000); expect(data.id).toBeDefined(); }); test("get_item retrieves by ID", async () => { const { db, userId } = await createTestDb(); const tools = registerItemTools(db, userId); const created = parseResult( await tools.create_item({ name: "Sleeping Bag", categoryId: 1 }), ); const result = await tools.get_item({ id: created.id }); const data = parseResult(result); expect(data.name).toBe("Sleeping Bag"); expect(data.id).toBe(created.id); }); test("get_item returns error for missing item", async () => { const { db, userId } = await createTestDb(); const tools = registerItemTools(db, userId); const result = await tools.get_item({ id: 999 }); const data = parseResult(result); expect(data.error).toContain("not found"); }); test("delete_item removes item", async () => { const { db, userId } = await createTestDb(); const tools = registerItemTools(db, userId); const created = parseResult( await tools.create_item({ name: "To Delete", categoryId: 1 }), ); const deleteResult = await tools.delete_item({ id: created.id }); const data = parseResult(deleteResult); expect(data.deleted).toBe(true); // Verify it's gone const getResult = await tools.get_item({ id: created.id }); const getData = parseResult(getResult); expect(getData.error).toContain("not found"); }); }); describe("MCP Category Tools", () => { test("list_categories returns array with Uncategorized", async () => { const { db, userId } = await createTestDb(); const tools = registerCategoryTools(db, userId); const result = await tools.list_categories(); const data = parseResult(result); expect(Array.isArray(data)).toBe(true); expect(data.length).toBeGreaterThanOrEqual(1); expect(data.some((c: any) => c.name === "Uncategorized")).toBe(true); }); test("create_category creates a new category", async () => { const { db, userId } = await createTestDb(); const tools = registerCategoryTools(db, userId); const result = await tools.create_category({ name: "Shelter", icon: "tent", }); const data = parseResult(result); expect(data.name).toBe("Shelter"); expect(data.icon).toBe("tent"); }); }); describe("MCP Thread Tools", () => { test("create_thread starts a thread with status active", async () => { const { db, userId } = await createTestDb(); const tools = registerThreadTools(db, userId); const result = await tools.create_thread({ name: "Handlebar Bag", categoryId: 1, }); const data = parseResult(result); expect(data.name).toBe("Handlebar Bag"); expect(data.status).toBe("active"); }); test("add_candidate adds to thread", async () => { const { db, userId } = await createTestDb(); const tools = registerThreadTools(db, userId); const thread = parseResult( await tools.create_thread({ name: "Saddle Bag", categoryId: 1 }), ); const result = await tools.add_candidate({ threadId: thread.id, name: "Apidura Racing", categoryId: 1, priceCents: 8500, pros: "Lightweight", cons: "Expensive", }); const data = parseResult(result); expect(data.name).toBe("Apidura Racing"); expect(data.threadId).toBe(thread.id); expect(data.pros).toBe("Lightweight"); }); test("resolve_thread picks winner and creates item", async () => { const { db, userId } = await createTestDb(); const threadTools = registerThreadTools(db, userId); const itemTools = registerItemTools(db, userId); // Create thread with two candidates const thread = parseResult( await threadTools.create_thread({ name: "Frame Bag", categoryId: 1 }), ); const candidate1 = parseResult( await threadTools.add_candidate({ threadId: thread.id, name: "Revelate Tangle", categoryId: 1, priceCents: 12000, }), ); await threadTools.add_candidate({ threadId: thread.id, name: "Ortlieb Frame Pack", categoryId: 1, priceCents: 9000, }); // Resolve with first candidate const resolveResult = await threadTools.resolve_thread({ threadId: thread.id, candidateId: candidate1.id, }); const resolveData = parseResult(resolveResult); expect(resolveData.success).toBe(true); expect(resolveData.item.name).toBe("Revelate Tangle"); // Check item was added to collection const items = parseResult(await itemTools.list_items({})); expect(items.some((i: any) => i.name === "Revelate Tangle")).toBe(true); // Check thread is now resolved const threadList = parseResult( await threadTools.list_threads({ includeResolved: true }), ); const resolved = threadList.find((t: any) => t.id === thread.id); expect(resolved.status).toBe("resolved"); }); }); describe("MCP Setup Tools", () => { test("create_setup and list_setups", async () => { const { db, userId } = await createTestDb(); const tools = registerSetupTools(db, userId); await tools.create_setup({ name: "Weekend Trip" }); const result = await tools.list_setups(); const data = parseResult(result); expect(data.length).toBe(1); expect(data[0].name).toBe("Weekend Trip"); }); test("get_setup returns setup with items", async () => { const { db, userId } = await createTestDb(); const setupTools = registerSetupTools(db, userId); const itemTools = registerItemTools(db, userId); const setup = parseResult( await setupTools.create_setup({ name: "Overnighter" }), ); const item = parseResult( await itemTools.create_item({ name: "Bivvy", categoryId: 1 }), ); await setupTools.update_setup({ id: setup.id, itemIds: [item.id] }); const result = await setupTools.get_setup({ id: setup.id }); const data = parseResult(result); expect(data.name).toBe("Overnighter"); expect(data.items.length).toBe(1); expect(data.items[0].name).toBe("Bivvy"); }); }); describe("MCP Collection Summary Resource", () => { test("returns overview with correct counts", async () => { const { db, userId } = await createTestDb(); const summary = await getCollectionSummary(db, userId); expect(summary.overview).toBeDefined(); expect(summary.overview.totalItems).toBe(0); expect(summary.overview.categoryCount).toBe(1); // Uncategorized expect(summary.itemsByCategory).toBeDefined(); expect(summary.activeThreads).toBeDefined(); expect(Array.isArray(summary.activeThreads)).toBe(true); }); test("reflects items and threads after creation", async () => { const { db, userId } = await createTestDb(); const itemTools = registerItemTools(db, userId); const threadTools = registerThreadTools(db, userId); await itemTools.create_item({ name: "Tent", categoryId: 1, weightGrams: 1500, }); await itemTools.create_item({ name: "Sleeping Pad", categoryId: 1, weightGrams: 500, }); await threadTools.create_thread({ name: "Cook System", categoryId: 1, }); const summary = await getCollectionSummary(db, userId); expect(summary.overview.totalItems).toBe(2); expect(summary.overview.totalWeightGrams).toBe(2000); expect(summary.overview.activeThreadCount).toBe(1); expect(summary.itemsByCategory.Uncategorized).toBe(2); expect(summary.activeThreads.length).toBe(1); expect(summary.activeThreads[0].name).toBe("Cook System"); }); }); describe("MCP Catalog Tools", () => { test("upsert_catalog_item creates a new global item with created=true", async () => { const { db } = await createTestDb(); await insertManufacturer(db, "Revelate Designs"); const tools = registerCatalogTools(db); const result = await tools.upsert_catalog_item({ manufacturerSlug: "revelate-designs", model: "Terrapin System", weightGrams: 235, priceCents: 16500, }); const data = parseResult(result); expect(data.model).toBe("Terrapin System"); expect(data.created).toBe(true); expect(data.id).toBeDefined(); }); test("upsert_catalog_item updates existing item on brand+model match", async () => { const { db } = await createTestDb(); await insertManufacturer(db, "Apidura"); const tools = registerCatalogTools(db); // Create initial item await tools.upsert_catalog_item({ manufacturerSlug: "apidura", model: "Handlebar Pack", }); // Update it const result = await tools.upsert_catalog_item({ manufacturerSlug: "apidura", model: "Handlebar Pack", description: "Updated description", weightGrams: 120, }); const data = parseResult(result); expect(data.created).toBe(false); expect(data.description).toBe("Updated description"); expect(data.weightGrams).toBe(120); }); test("upsert_catalog_item includes attribution fields in result (SEED-03)", async () => { const { db } = await createTestDb(); await insertManufacturer(db, "MSR"); const tools = registerCatalogTools(db); const result = await tools.upsert_catalog_item({ manufacturerSlug: "msr", 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", }); const data = parseResult(result); 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", ); }); test("bulk_upsert_catalog processes array and returns created/updated counts", async () => { const { db } = await createTestDb(); await insertManufacturer(db, "Revelate Designs"); await insertManufacturer(db, "Apidura"); await insertManufacturer(db, "MSR"); const tools = registerCatalogTools(db); const result = await tools.bulk_upsert_catalog({ items: [ { manufacturerSlug: "revelate-designs", model: "Terrapin System" }, { manufacturerSlug: "apidura", model: "Handlebar Pack" }, { manufacturerSlug: "msr", model: "PocketRocket 2" }, ], }); const data = parseResult(result); expect(data.created).toBe(3); expect(data.updated).toBe(0); expect(data.totalProcessed).toBe(3); expect(data.items).toHaveLength(3); }); test("bulk_upsert_catalog returns totalProcessed matching input length", async () => { const { db } = await createTestDb(); await insertManufacturer(db, "Revelate Designs"); await insertManufacturer(db, "Apidura"); const tools = registerCatalogTools(db); // Pre-create one item await tools.upsert_catalog_item({ manufacturerSlug: "revelate-designs", model: "Terrapin System", }); const result = await tools.bulk_upsert_catalog({ items: [ { manufacturerSlug: "revelate-designs", model: "Terrapin System" }, { manufacturerSlug: "apidura", model: "Handlebar Pack" }, ], }); const data = parseResult(result); expect(data.totalProcessed).toBe(2); expect(data.created).toBe(1); expect(data.updated).toBe(1); }); test("catalog tool definitions include attribution fields in inputSchema", () => { const { catalogToolDefinitions, } = require("../../src/server/mcp/tools/catalog.ts"); const upsertDef = catalogToolDefinitions.find( (d: { name: string }) => d.name === "upsert_catalog_item", ); expect(upsertDef).toBeDefined(); expect(upsertDef.inputSchema.sourceUrl).toBeDefined(); expect(upsertDef.inputSchema.imageCredit).toBeDefined(); expect(upsertDef.inputSchema.imageSourceUrl).toBeDefined(); }); }); describe("MCP Cross-User Isolation", () => { test("user 2 cannot see user 1's items via MCP tools", async () => { const { db, userId } = await createTestDb(); const userId2 = await createSecondTestUser(db); const user1Tools = registerItemTools(db, userId); const user2Tools = registerItemTools(db, userId2); // User 1 creates an item await user1Tools.create_item({ name: "User 1 Tent", categoryId: 1, weightGrams: 1200, }); // User 2 creates an item await user2Tools.create_item({ name: "User 2 Sleeping Bag", categoryId: 1, weightGrams: 800, }); // Each user only sees their own items const user1Items = parseResult(await user1Tools.list_items({})); const user2Items = parseResult(await user2Tools.list_items({})); expect(user1Items).toHaveLength(1); expect(user1Items[0].name).toBe("User 1 Tent"); expect(user2Items).toHaveLength(1); expect(user2Items[0].name).toBe("User 2 Sleeping Bag"); }); test("user 2 cannot access user 1's item by ID", async () => { const { db, userId } = await createTestDb(); const userId2 = await createSecondTestUser(db); const user1Tools = registerItemTools(db, userId); const user2Tools = registerItemTools(db, userId2); const created = parseResult( await user1Tools.create_item({ name: "Private Item", categoryId: 1, }), ); // User 2 tries to get user 1's item const result = await user2Tools.get_item({ id: created.id }); const data = parseResult(result); expect(data.error).toContain("not found"); }); test("user 2 cannot see user 1's threads via MCP tools", async () => { const { db, userId } = await createTestDb(); const userId2 = await createSecondTestUser(db); const user1Tools = registerThreadTools(db, userId); const user2Tools = registerThreadTools(db, userId2); await user1Tools.create_thread({ name: "User 1 Thread", categoryId: 1, }); const user1Threads = parseResult( await user1Tools.list_threads({ includeResolved: false }), ); const user2Threads = parseResult( await user2Tools.list_threads({ includeResolved: false }), ); expect(user1Threads).toHaveLength(1); expect(user1Threads[0].name).toBe("User 1 Thread"); expect(user2Threads).toHaveLength(0); }); test("collection summary is scoped to user", async () => { const { db, userId } = await createTestDb(); const userId2 = await createSecondTestUser(db); const user1Tools = registerItemTools(db, userId); await user1Tools.create_item({ name: "User 1 Item", categoryId: 1, weightGrams: 500, }); const user1Summary = await getCollectionSummary(db, userId); const user2Summary = await getCollectionSummary(db, userId2); expect(user1Summary.overview.totalItems).toBe(1); expect(user2Summary.overview.totalItems).toBe(0); }); });