diff --git a/package.json b/package.json index 765909e..f776c70 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", + "test": "node --test test/*.test.mjs", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/src/tools.ts b/src/tools.ts index 23ae023..5897bb3 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -23,7 +23,11 @@ export const TOOLS: ToolDef[] = [ inputSchema: { type: "object", properties: { - limit: { type: "number", description: "max orders (1-50)", default: 10 }, + limit: { + type: "number", + description: "max orders (1-50; fractional values truncated, range clamped)", + default: 10, + }, }, }, run: (env, args) => shopifyOrders(env, clampInt(args.limit, 10, 1, 50)), @@ -45,7 +49,11 @@ export const TOOLS: ToolDef[] = [ type: "object", properties: { table: { type: "string", description: "table name (allowlisted)" }, - limit: { type: "number", description: "max rows (1-50)", default: 10 }, + limit: { + type: "number", + description: "max rows (1-50; fractional values truncated, range clamped)", + default: 10, + }, }, required: ["table"], }, @@ -76,6 +84,6 @@ export function findTool(name: string): ToolDef | undefined { function clampInt(v: unknown, def: number, min: number, max: number): number { const n = typeof v === "number" ? v : parseInt(String(v ?? ""), 10); - if (Number.isNaN(n)) return def; - return Math.max(min, Math.min(max, n)); + if (!Number.isFinite(n)) return def; + return Math.max(min, Math.min(max, Math.trunc(n))); } diff --git a/test/tools.test.mjs b/test/tools.test.mjs new file mode 100644 index 0000000..ddbb4cc --- /dev/null +++ b/test/tools.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test, { after } from "node:test"; +import ts from "typescript"; + +async function compile(relativePath) { + const source = await readFile(new URL(relativePath, import.meta.url), "utf8"); + const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }, + fileName: relativePath, + reportDiagnostics: true, + }); + assert.equal(compiled.diagnostics?.length ?? 0, 0); + return compiled.outputText; +} + +function dataUrl(source) { + return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; +} + +const connectorsUrl = dataUrl(await compile("../src/connectors.ts")); +const toolsSource = (await compile("../src/tools.ts")).replace( + 'from "./connectors"', + `from ${JSON.stringify(connectorsUrl)}`, +); +assert.doesNotMatch(toolsSource, /from "\.\/connectors"/); +const { findTool } = await import(dataUrl(toolsSource)); + +const originalFetch = globalThis.fetch; +after(() => { + globalThis.fetch = originalFetch; +}); + +const shopifyEnv = { + SHOPIFY_STORE: "store.example.com", + SHOPIFY_ADMIN_TOKEN: "test-token", +}; +const databaseEnv = { + SUPABASE_URL: "https://database.example.com", + SUPABASE_SERVICE_ROLE_KEY: "test-key", +}; + +async function capturedLimit(toolName, env, args) { + const requests = []; + globalThis.fetch = async (input) => { + requests.push(String(input)); + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + await findTool(toolName).run(env, args); + assert.equal(requests.length, 1); + return new URL(requests[0]).searchParams.get("limit"); +} + +test("both bounded read tools receive the same normalized integer limit", async (t) => { + const cases = [ + { name: "default", input: undefined, expected: "10" }, + { name: "maximum", input: 50, expected: "50" }, + { name: "above maximum", input: 1000, expected: "50" }, + { name: "below minimum", input: -5, expected: "1" }, + { name: "fractional number", input: 1.9, expected: "1" }, + { name: "fractional string", input: "12.8", expected: "12" }, + { name: "NaN", input: Number.NaN, expected: "10" }, + { name: "positive infinity", input: Number.POSITIVE_INFINITY, expected: "10" }, + { name: "negative infinity", input: Number.NEGATIVE_INFINITY, expected: "10" }, + ]; + + for (const scenario of cases) { + await t.test(scenario.name, async () => { + const shopifyLimit = await capturedLimit( + "shopify_orders", + shopifyEnv, + { limit: scenario.input }, + ); + const databaseLimit = await capturedLimit("db_query", databaseEnv, { + table: "posts", + limit: scenario.input, + }); + assert.equal(shopifyLimit, scenario.expected); + assert.equal(databaseLimit, scenario.expected); + }); + } +});