/** * Unit tests for @sh-mcp/qbo. * * All tests inject a mock QboClientInterface — no real network calls, no AWS. * A mock AuthContext is passed directly; real JWT validation is the DEFERRED * auth layer and is not exercised here. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { AuthContext } from '@sh-mcp/shared'; import { makeSearchVendorsTool } from '../src/tools.js'; import type { QboClientInterface, SearchVendorsParams, SearchVendorsResult } from '../src/client.js'; import { QboApiError, QboThrottleError } from '../src/client.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** A mock AuthContext that carries finance:read scope. */ function makeFinanceCtx(overrides?: Partial): AuthContext { return { sub: 'adam@seahavenind.com', scopes: ['ops:read', 'finance:read'], aud: 'sh-mcp-finance', ...overrides, }; } /** A mock QboClientInterface backed by a vitest spy. */ function makeMockClient( impl: (params: SearchVendorsParams) => Promise, ): QboClientInterface { return { searchVendors: vi.fn(impl), }; } /** Minimal vendor fixture. */ const VENDOR_ACME = { id: 'qbo-vendor-001', displayName: 'Acme Supplies', email: 'billing@acme.example', phone: '555-0100', active: true, balance: 1250.0, taxId: '12-3456789', }; /** Vendor fixture with no optional fields. */ const VENDOR_MINIMAL = { id: 'qbo-vendor-002', displayName: 'Beta Services', active: false, }; // --------------------------------------------------------------------------- // Happy-path tests // --------------------------------------------------------------------------- describe('search_vendors — happy path', () => { let client: QboClientInterface; beforeEach(() => { client = makeMockClient(async () => ({ vendors: [VENDOR_ACME, VENDOR_MINIMAL], totalCount: 2, })); }); it('returns vendor records with the correct shape', async () => { const tool = makeSearchVendorsTool(client); const result = await tool.handler({ query: 'acme' }, makeFinanceCtx()); expect(result.totalCount).toBe(2); expect(result.vendors).toHaveLength(2); const acme = result.vendors[0]; expect(acme.id).toBe('qbo-vendor-001'); expect(acme.displayName).toBe('Acme Supplies'); expect(acme.email).toBe('billing@acme.example'); expect(acme.phone).toBe('555-0100'); expect(acme.active).toBe(true); expect(acme.balance).toBe(1250.0); }); it('redacts taxId in the response', async () => { const tool = makeSearchVendorsTool(client); const result = await tool.handler({ query: 'acme' }, makeFinanceCtx()); const acme = result.vendors[0]; // taxId must be present but must NOT equal the raw value expect(acme.taxId).toBeDefined(); expect(acme.taxId).not.toBe('12-3456789'); // redact() replaces sensitive data with masked characters expect(acme.taxId).toMatch(/[*x●]/i); }); it('preserves vendor displayName and contact fields intact (not redacted)', async () => { const tool = makeSearchVendorsTool(client); const result = await tool.handler({ query: 'acme' }, makeFinanceCtx()); const acme = result.vendors[0]; // redact() contract: leaves vendor names and contact info intact expect(acme.displayName).toBe('Acme Supplies'); expect(acme.email).toBe('billing@acme.example'); expect(acme.phone).toBe('555-0100'); }); it('omits optional fields when the vendor has none', async () => { const tool = makeSearchVendorsTool(client); const result = await tool.handler({ query: 'beta' }, makeFinanceCtx()); const beta = result.vendors[1]; expect(beta.id).toBe('qbo-vendor-002'); expect(beta.displayName).toBe('Beta Services'); expect(beta.active).toBe(false); expect(beta.email).toBeUndefined(); expect(beta.phone).toBeUndefined(); expect(beta.balance).toBeUndefined(); expect(beta.taxId).toBeUndefined(); }); it('forwards maxResults to the client', async () => { const tool = makeSearchVendorsTool(client); await tool.handler({ query: 'acme', maxResults: 5 }, makeFinanceCtx()); expect(client.searchVendors).toHaveBeenCalledWith( expect.objectContaining({ maxResults: 5 }), ); }); it('defaults maxResults to 20 when not specified', async () => { const tool = makeSearchVendorsTool(client); await tool.handler({ query: 'acme' }, makeFinanceCtx()); expect(client.searchVendors).toHaveBeenCalledWith( expect.objectContaining({ maxResults: 20 }), ); }); }); // --------------------------------------------------------------------------- // Empty result // --------------------------------------------------------------------------- describe('search_vendors — empty result', () => { it('returns an empty vendors array and totalCount 0 when QBO finds nothing', async () => { const client = makeMockClient(async () => ({ vendors: [], totalCount: 0 })); const tool = makeSearchVendorsTool(client); const result = await tool.handler({ query: 'nonexistent vendor xyz' }, makeFinanceCtx()); expect(result.vendors).toHaveLength(0); expect(result.totalCount).toBe(0); }); it('returns totalCount accurately when results are paginated (totalCount > vendors.length)', async () => { const client = makeMockClient(async () => ({ vendors: [VENDOR_MINIMAL], totalCount: 47, })); const tool = makeSearchVendorsTool(client); const result = await tool.handler({ query: 'services', maxResults: 1 }, makeFinanceCtx()); expect(result.vendors).toHaveLength(1); expect(result.totalCount).toBe(47); }); }); // --------------------------------------------------------------------------- // Error paths // --------------------------------------------------------------------------- describe('search_vendors — error handling', () => { it('rethrows unexpected errors from the client unchanged', async () => { const client = makeMockClient(async () => { throw new Error('Unexpected internal failure'); }); const tool = makeSearchVendorsTool(client); await expect(tool.handler({ query: 'acme' }, makeFinanceCtx())).rejects.toThrow( 'Unexpected internal failure', ); }); it('wraps QboApiError with status code in the thrown message', async () => { const client = makeMockClient(async () => { throw new QboApiError('Forbidden', 403); }); const tool = makeSearchVendorsTool(client); await expect(tool.handler({ query: 'acme' }, makeFinanceCtx())).rejects.toThrow( 'QBO API error (HTTP 403): Forbidden', ); }); it('throws a scope error when the context lacks finance:read', async () => { const client = makeMockClient(async () => ({ vendors: [], totalCount: 0 })); const tool = makeSearchVendorsTool(client); // ops-only context — missing finance:read const opsCtx = makeFinanceCtx({ scopes: ['ops:read'] }); await expect(tool.handler({ query: 'acme' }, opsCtx)).rejects.toThrow(); // client should never be called when scope check fails expect(client.searchVendors).not.toHaveBeenCalled(); }); it('throws a scope error when the context has no scopes at all', async () => { const client = makeMockClient(async () => ({ vendors: [], totalCount: 0 })); const tool = makeSearchVendorsTool(client); const emptyCtx = makeFinanceCtx({ scopes: [] }); await expect(tool.handler({ query: 'acme' }, emptyCtx)).rejects.toThrow(); expect(client.searchVendors).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // Throttle / retry // --------------------------------------------------------------------------- describe('search_vendors — throttle / retry', () => { it('surfaces a rate-limit error with retry hint when QBO returns 429 with retryAfter', async () => { const client = makeMockClient(async () => { throw new QboThrottleError(30); }); const tool = makeSearchVendorsTool(client); await expect(tool.handler({ query: 'acme' }, makeFinanceCtx())).rejects.toThrow( /rate limit.*retry after 30s/i, ); }); it('surfaces a rate-limit error without retry hint when QBO omits retryAfter', async () => { const client = makeMockClient(async () => { throw new QboThrottleError(); }); const tool = makeSearchVendorsTool(client); const err = await tool .handler({ query: 'acme' }, makeFinanceCtx()) .catch((e: unknown) => e as Error); expect(err.message).toMatch(/rate limit/i); // No "Retry after Xs" appended expect(err.message).not.toMatch(/retry after/i); }); it('does not swallow the error — the caller is responsible for retry logic', async () => { // The tool itself does not retry; it propagates so the server layer // (or the MCP client) can back off and retry. const client = makeMockClient(async () => { throw new QboThrottleError(60); }); const tool = makeSearchVendorsTool(client); await expect(tool.handler({ query: 'acme' }, makeFinanceCtx())).rejects.toThrow(); // Called exactly once — no internal retry loop. expect(client.searchVendors).toHaveBeenCalledTimes(1); }); }); // --------------------------------------------------------------------------- // Tool metadata // --------------------------------------------------------------------------- describe('search_vendors — tool definition', () => { it('has the correct name', () => { const tool = makeSearchVendorsTool(makeMockClient(async () => ({ vendors: [], totalCount: 0 }))); expect(tool.name).toBe('search_vendors'); }); it('declares finance tier', () => { const tool = makeSearchVendorsTool(makeMockClient(async () => ({ vendors: [], totalCount: 0 }))); expect(tool.tier).toBe('finance'); }); it('requires finance:read scope', () => { const tool = makeSearchVendorsTool(makeMockClient(async () => ({ vendors: [], totalCount: 0 }))); expect(tool.requiredScope).toBe('finance:read'); }); it('has an inputSchema that marks query as required', () => { const tool = makeSearchVendorsTool(makeMockClient(async () => ({ vendors: [], totalCount: 0 }))); const schema = tool.inputSchema as { required: string[]; properties: Record; }; expect(schema.required).toContain('query'); expect(schema.properties).toHaveProperty('query'); expect(schema.properties).toHaveProperty('maxResults'); }); });