|
| 1 | +/** |
| 2 | + * Hermetic regression tests for the vendor MCP tools (query_vendors, |
| 3 | + * get_vendor_risk). getDb is mocked (same approach as tests/mcp.test.ts) so |
| 4 | + * these run without a database and pin the exact bug Codex flagged on #119: |
| 5 | + * at_risk filtering + risk recompute must happen BEFORE limiting, and ordering |
| 6 | + * must use live risk — not the stored (possibly stale) risk_score column. |
| 7 | + */ |
| 8 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 9 | +import { Hono } from 'hono'; |
| 10 | +import type { Env } from '../src/index'; |
| 11 | +import { mcpAuthMiddleware } from '../src/middleware/auth'; |
| 12 | +import type { AuthVariables } from '../src/middleware/auth'; |
| 13 | + |
| 14 | +// Mutable row set the mocked sql tagged-template resolves to. Hoisted so the |
| 15 | +// vi.mock factory can close over it. |
| 16 | +const dbState = vi.hoisted(() => ({ rows: [] as Array<Record<string, unknown>> })); |
| 17 | + |
| 18 | +vi.mock('../src/lib/db', () => ({ |
| 19 | + // getDb returns an sql() tagged-template that ignores the query and resolves |
| 20 | + // the current dbState.rows — each vendor tool issues a single SELECT. |
| 21 | + getDb: () => async () => dbState.rows, |
| 22 | + typedRows: <T>(rows: readonly Record<string, unknown>[]): T[] => rows as unknown as T[], |
| 23 | +})); |
| 24 | + |
| 25 | +import { mcpRoutes } from '../src/routes/mcp'; |
| 26 | + |
| 27 | +function makeEnv(): Pick<Env, 'ENVIRONMENT' | 'COMMAND_KV'> & Partial<Env> { |
| 28 | + return { |
| 29 | + ENVIRONMENT: 'test', |
| 30 | + COMMAND_KV: { |
| 31 | + get: vi.fn().mockResolvedValue(null), |
| 32 | + put: vi.fn().mockResolvedValue(undefined), |
| 33 | + } as unknown as KVNamespace, |
| 34 | + }; |
| 35 | +} |
| 36 | + |
| 37 | +function buildApp() { |
| 38 | + const app = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); |
| 39 | + app.use('/mcp/*', mcpAuthMiddleware); |
| 40 | + app.route('/mcp', mcpRoutes); |
| 41 | + const env = makeEnv(); |
| 42 | + return async function callTool(name: string, args: Record<string, unknown> = {}) { |
| 43 | + const req = new Request('http://localhost/mcp', { |
| 44 | + method: 'POST', |
| 45 | + headers: { 'Content-Type': 'application/json' }, |
| 46 | + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }), |
| 47 | + }); |
| 48 | + const res = await app.fetch(req, env as unknown as Env); |
| 49 | + const json = (await res.json()) as Record<string, unknown>; |
| 50 | + const result = json.result as { content: Array<{ text: string }>; isError?: boolean }; |
| 51 | + return { isError: result.isError === true, data: JSON.parse(result.content[0].text) }; |
| 52 | + }; |
| 53 | +} |
| 54 | + |
| 55 | +// A vendor row as Neon returns it (snake_case, NUMERIC as strings). |
| 56 | +function row(over: Partial<Record<string, unknown>>): Record<string, unknown> { |
| 57 | + return { |
| 58 | + id: over.id ?? `id-${over.vendor_name}`, |
| 59 | + vendor_name: over.vendor_name, |
| 60 | + category: over.category ?? 'other', |
| 61 | + billing_cycle: over.billing_cycle ?? 'monthly', |
| 62 | + expected_amount: over.expected_amount ?? '10.00', |
| 63 | + currency: 'USD', |
| 64 | + next_bill_date: over.next_bill_date ?? null, |
| 65 | + auto_pay: over.auto_pay ?? false, |
| 66 | + payment_status: over.payment_status ?? 'active', |
| 67 | + payment_method: null, |
| 68 | + spending_limit: over.spending_limit ?? null, |
| 69 | + mtd_spend: over.mtd_spend ?? null, |
| 70 | + budget_limit: over.budget_limit ?? null, |
| 71 | + status: over.status ?? 'active', |
| 72 | + risk_score: over.risk_score ?? null, // deliberately stale/null to prove live recompute |
| 73 | + }; |
| 74 | +} |
| 75 | + |
| 76 | +// Fixture: 5 active vendors. Healthy/low first, high-risk LAST in array order, |
| 77 | +// every stored risk_score null — so any reliance on stored order/score breaks. |
| 78 | +const FIXTURE = [ |
| 79 | + row({ vendor_name: 'healthy', payment_status: 'active', auto_pay: true, mtd_spend: '10', spending_limit: '100' }), // 0 (low) |
| 80 | + row({ vendor_name: 'unknown-low', payment_status: 'unknown' }), // 5 (low) |
| 81 | + row({ vendor_name: 'limited', payment_status: 'limited' }), // 35 (medium) |
| 82 | + row({ vendor_name: 'failed-hi', payment_status: 'failed', mtd_spend: '200', spending_limit: '100' }), // 75 (critical) |
| 83 | + row({ vendor_name: 'failed-50', payment_status: 'failed', mtd_spend: '50', spending_limit: '100' }), // 50 (high) |
| 84 | +]; |
| 85 | + |
| 86 | +beforeEach(() => { |
| 87 | + dbState.rows = FIXTURE.map((r) => ({ ...r })); |
| 88 | +}); |
| 89 | + |
| 90 | +describe('query_vendors (MCP)', () => { |
| 91 | + it('at_risk=true keeps high-risk vendors even when a small limit would page them out', async () => { |
| 92 | + const callTool = buildApp(); |
| 93 | + // limit=1: the buggy version applied LIMIT in SQL before filtering, which |
| 94 | + // could drop the at-risk vendor entirely. Now: filter → sort → slice. |
| 95 | + const { data } = await callTool('query_vendors', { at_risk: true, limit: 1 }); |
| 96 | + expect(data.count).toBe(1); |
| 97 | + // Highest live risk among the two at-risk vendors wins the single slot. |
| 98 | + expect(data.vendors[0].vendor_name).toBe('failed-hi'); |
| 99 | + expect(data.vendors[0].risk_score).toBe(75); |
| 100 | + }); |
| 101 | + |
| 102 | + it('at_risk=true returns ALL vendors at/over threshold (not just the first page)', async () => { |
| 103 | + const callTool = buildApp(); |
| 104 | + const { data } = await callTool('query_vendors', { at_risk: true }); |
| 105 | + expect(data.count).toBe(2); |
| 106 | + expect(data.vendors.map((v: { vendor_name: string }) => v.vendor_name).sort()).toEqual(['failed-50', 'failed-hi']); |
| 107 | + }); |
| 108 | + |
| 109 | + it('orders by LIVE risk, not the stale stored risk_score column', async () => { |
| 110 | + const callTool = buildApp(); |
| 111 | + const { data } = await callTool('query_vendors', { limit: 2 }); |
| 112 | + expect(data.vendors.map((v: { vendor_name: string }) => v.vendor_name)).toEqual(['failed-hi', 'failed-50']); |
| 113 | + }); |
| 114 | + |
| 115 | + it('recomputes risk live when stored risk_score is null', async () => { |
| 116 | + const callTool = buildApp(); |
| 117 | + const { data } = await callTool('query_vendors', {}); |
| 118 | + const healthy = data.vendors.find((v: { vendor_name: string }) => v.vendor_name === 'healthy'); |
| 119 | + expect(healthy.risk_score).toBe(0); |
| 120 | + expect(healthy.risk_level).toBe('low'); |
| 121 | + }); |
| 122 | +}); |
| 123 | + |
| 124 | +describe('get_vendor_risk (MCP)', () => { |
| 125 | + it('aggregates by risk level and lists the at-risk vendors', async () => { |
| 126 | + const callTool = buildApp(); |
| 127 | + const { data } = await callTool('get_vendor_risk', {}); |
| 128 | + expect(data.vendor_count).toBe(5); |
| 129 | + expect(data.by_level).toEqual({ critical: 1, high: 1, medium: 1, low: 2 }); |
| 130 | + expect(data.at_risk).toHaveLength(2); |
| 131 | + expect(data.at_risk[0].vendor_name).toBe('failed-hi'); // sorted desc by score |
| 132 | + // total MTD spend: 10 + 200 + 50 = 260 (others null → 0) |
| 133 | + expect(data.total_mtd_spend).toBe(260); |
| 134 | + }); |
| 135 | +}); |
0 commit comments