diff --git a/apps/cli/package.json b/apps/cli/package.json index 9e7571e..6390b6b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@c0upons/cli", - "version": "1.9.2", + "version": "1.9.3", "description": "c0upons CLI — search, submit, and manage coupons from the terminal", "type": "module", "bin": { diff --git a/apps/web/lib/mcp-tools.ts b/apps/web/lib/mcp-tools.ts index 040513c..4b4c281 100644 --- a/apps/web/lib/mcp-tools.ts +++ b/apps/web/lib/mcp-tools.ts @@ -26,8 +26,6 @@ export const SERVER: McpServerInfo = { 'reveal_pending reads a few code-less pages.', }; -const couponFields = 'c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at'; - export const TOOLS: McpTool[] = [ { name: 'search_coupons', @@ -37,7 +35,7 @@ export const TOOLS: McpTool[] = [ const pattern = `%${String(q).trim()}%`; const db = getDb(); const rows = await db.sql` - SELECT ${couponFields}, s.name AS store_name, s.slug AS store_slug + SELECT c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at, s.name AS store_name, s.slug AS store_slug FROM coupons c JOIN stores s ON s.id = c.store_id WHERE c.title LIKE ${pattern} OR c.description LIKE ${pattern} OR s.name LIKE ${pattern} OR c.code LIKE ${pattern} ORDER BY c.votes DESC LIMIT 50 @@ -52,7 +50,7 @@ export const TOOLS: McpTool[] = [ async run({ limit }) { const n = Math.min(100, Math.max(1, Number(limit) || 20)); const rows = await getDb().sql` - SELECT ${couponFields}, s.name AS store_name, s.slug AS store_slug + SELECT c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at, s.name AS store_name, s.slug AS store_slug FROM coupons c JOIN stores s ON s.id = c.store_id ORDER BY c.votes DESC, c.created_at DESC LIMIT ${n} `; @@ -81,7 +79,7 @@ export const TOOLS: McpTool[] = [ const stores = await db.sql`SELECT id, name, slug, website FROM stores WHERE slug = ${String(slug)}`; if (!stores.length) throw new Error(`No store with slug "${String(slug)}"`); const coupons = await db.sql` - SELECT ${couponFields} FROM coupons c WHERE c.store_id = ${stores[0].id} + SELECT c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at FROM coupons c WHERE c.store_id = ${stores[0].id} ORDER BY c.votes DESC, c.created_at DESC `; return { store: stores[0], count: coupons.length, coupons }; @@ -93,7 +91,7 @@ export const TOOLS: McpTool[] = [ inputSchema: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'], additionalProperties: false }, async run({ id }) { const rows = await getDb().sql` - SELECT ${couponFields}, c.code_source, s.name AS store_name, s.slug AS store_slug, s.website AS store_website + SELECT c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at, c.code_source, s.name AS store_name, s.slug AS store_slug, s.website AS store_website FROM coupons c JOIN stores s ON s.id = c.store_id WHERE c.id = ${Number(id)} `; if (!rows.length) throw new Error(`No coupon ${String(id)}`); diff --git a/apps/web/package.json b/apps/web/package.json index 33dbfb1..0386954 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@c0upons/web", - "version": "1.9.2", + "version": "1.9.3", "private": true, "type": "module", "scripts": { diff --git a/apps/web/public/cli/c0upons b/apps/web/public/cli/c0upons index f8e0871..f925844 100644 --- a/apps/web/public/cli/c0upons +++ b/apps/web/public/cli/c0upons @@ -6,7 +6,7 @@ set -euo pipefail -VERSION="1.9.2" +VERSION="1.9.3" BASE_URL="${C0UPONS_API:-https://c0upons.com/api}" # Website root (for login + share links). Defaults to BASE_URL without /api. WEB_URL="${C0UPONS_WEB:-${BASE_URL%/api}}" diff --git a/package.json b/package.json index 9f808e6..5644cef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "c0upons", - "version": "1.9.2", + "version": "1.9.3", "private": true, "type": "module", "packageManager": "bun@1.4.0", diff --git a/test/mcp-tools.test.mjs b/test/mcp-tools.test.mjs new file mode 100644 index 0000000..ee9e3ab --- /dev/null +++ b/test/mcp-tools.test.mjs @@ -0,0 +1,73 @@ +// The MCP coupon tools against the real migration in a local libSQL file. +// db.sql binds every ${} as a parameter, so a column list interpolated into +// a SELECT comes back as one string column instead of the coupon; this +// proves each tool returns the coupon's own fields. +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mock } from 'bun:test'; + +// db.ts and mcp-tools.ts open with `import 'server-only'`, which only Next resolves. +mock.module('server-only', () => ({})); + +const dir = mkdtempSync(join(tmpdir(), 'c0upons-mcp-tools-')); +const url = `file:${join(dir, 'local.db')}`; +execFileSync('node', ['apps/web/scripts/migrate.mjs'], { + cwd: new URL('..', import.meta.url), + env: { ...process.env, TURSO_DATABASE_URL: url, TURSO_AUTH_TOKEN: '' }, + stdio: 'pipe', +}); +const { getDb } = await import('../apps/web/lib/db.ts'); +const { TOOLS } = await import('../apps/web/lib/mcp-tools.ts'); +const db = getDb(); +const tool = (name) => TOOLS.find((t) => t.name === name); +let couponId; + +// db.ts opens its client from DATABASE_URL on the first query. Unset it right +// after: bun runs every test file in one process, and the other files' +// migrate.mjs children would inherit it and prefer it over their own database. +before(async () => { + const previousUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = url; + await db.sql`INSERT INTO stores (name, slug) VALUES ('Nike', 'nike')`; + const [store] = await db.sql`SELECT id FROM stores WHERE slug = 'nike'`; + await db.sql`INSERT INTO coupons (store_id, code, title, votes) VALUES (${store.id}, 'SAVE20', '20% off shoes', 5)`; + [{ id: couponId }] = await db.sql`SELECT id FROM coupons`; + if (previousUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = previousUrl; +}); + +after(() => rmSync(dir, { recursive: true, force: true })); + +function assertCoupon(c) { + assert.equal(c['?column?'], undefined); + assert.equal(c.code, 'SAVE20'); + assert.equal(c.title, '20% off shoes'); + assert.equal(Number(c.votes), 5); +} + +test('search_coupons returns the coupon fields', async () => { + const r = await tool('search_coupons').run({ q: 'shoes' }); + assert.equal(r.count, 1); + assertCoupon(r.coupons[0]); + assert.equal(r.coupons[0].store_slug, 'nike'); +}); + +test('top_coupons returns the coupon fields', async () => { + const r = await tool('top_coupons').run({ limit: 5 }); + assertCoupon(r.coupons[0]); +}); + +test('store_coupons returns the coupon fields', async () => { + const r = await tool('store_coupons').run({ slug: 'nike' }); + assertCoupon(r.coupons[0]); +}); + +test('get_coupon returns the coupon fields', async () => { + const r = await tool('get_coupon').run({ id: couponId }); + assertCoupon(r); + assert.equal(r.store_name, 'Nike'); +});