/** * Unit tests for @sh-mcp/google-maps * * All tests use a mock GoogleMapsClient — no real network calls are made. * The mock AuthContext satisfies the @sh-mcp/shared contract. */ import { describe, it, expect, vi } from 'vitest'; import type { AuthContext } from '@sh-mcp/shared'; import type { GoogleMapsClient, PlaceResult } from '../src/client.js'; import { makeSearchNearbyVendors } from '../src/tools.js'; import type { SearchNearbyVendorsInput } from '../src/tools.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function mockAuthCtx(overrides?: Partial): AuthContext { return { sub: 'test-user@seahavenind.com', scopes: ['ops:read'], aud: 'sh-mcp-ops', ...overrides, }; } const SAMPLE_PLACE: PlaceResult = { displayName: 'Acme Electric LLC', formattedAddress: '123 Main St, Ronkonkoma, NY 11779', nationalPhoneNumber: '(631) 555-0100', types: ['electrician', 'home_goods_store'], rating: 4.7, googleMapsUri: 'https://maps.google.com/?cid=12345', }; // --------------------------------------------------------------------------- // Mock client factory // --------------------------------------------------------------------------- function makeMockClient(impl: () => Promise): GoogleMapsClient { return { searchText: vi.fn().mockImplementation(impl) }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('search_nearby_vendors', () => { describe('happy path — results returned', () => { it('returns shaped VendorListing objects with an unvetted disclaimer', async () => { const client = makeMockClient(async () => [SAMPLE_PLACE]); const tool = makeSearchNearbyVendors(client); const input: SearchNearbyVendorsInput = { query: 'electrician near Ronkonkoma NY' }; const result = await tool.handler(input, mockAuthCtx()); expect(result.total).toBe(1); const vendor = result.results[0]; expect(vendor.name).toBe('Acme Electric LLC'); expect(vendor.address).toBe('123 Main St, Ronkonkoma, NY 11779'); expect(vendor.phone).toBe('(631) 555-0100'); expect(vendor.categories).toContain('electrician'); expect(vendor.rating).toBe(4.7); expect(vendor.maps_url).toBe('https://maps.google.com/?cid=12345'); expect(vendor.disclaimer).toMatch(/UNVETTED/); }); it('passes through location bias params to the client', async () => { const client = makeMockClient(async () => [SAMPLE_PLACE]); const tool = makeSearchNearbyVendors(client); const input: SearchNearbyVendorsInput = { query: 'plumber', lat: 40.8296, lng: -73.104, radius_meters: 8000, max_results: 5, }; await tool.handler(input, mockAuthCtx()); expect(client.searchText).toHaveBeenCalledWith( expect.objectContaining({ textQuery: 'plumber', locationBiasLat: 40.8296, locationBiasLng: -73.104, locationBiasRadiusMeters: 8000, maxResultCount: 5, }), ); }); it('defaults max_results to 10 when not supplied', async () => { const client = makeMockClient(async () => []); const tool = makeSearchNearbyVendors(client); await tool.handler({ query: 'locksmith' }, mockAuthCtx()); expect(client.searchText).toHaveBeenCalledWith( expect.objectContaining({ maxResultCount: 10 }), ); }); it('handles optional fields absent (no phone, rating, maps_url)', async () => { const sparse: PlaceResult = { displayName: 'Sparse Vendor', formattedAddress: '456 Oak Ave', types: ['general_contractor'], }; const client = makeMockClient(async () => [sparse]); const tool = makeSearchNearbyVendors(client); const result = await tool.handler({ query: 'general contractor' }, mockAuthCtx()); const vendor = result.results[0]; expect(vendor.phone).toBeUndefined(); expect(vendor.rating).toBeUndefined(); expect(vendor.maps_url).toBeUndefined(); }); }); describe('empty results', () => { it('returns an empty array and total 0 when the API finds nothing', async () => { const client = makeMockClient(async () => []); const tool = makeSearchNearbyVendors(client); const result = await tool.handler({ query: 'zxzxzx nonsense query' }, mockAuthCtx()); expect(result.total).toBe(0); expect(result.results).toHaveLength(0); }); }); describe('error handling', () => { it('propagates a generic API error to the caller', async () => { const client = makeMockClient(async () => { throw new Error('Google Places API error 500: Internal Server Error'); }); const tool = makeSearchNearbyVendors(client); await expect(tool.handler({ query: 'electrician' }, mockAuthCtx())).rejects.toThrow( 'Google Places API error 500', ); }); it('propagates a 400 Bad Request error to the caller', async () => { const client = makeMockClient(async () => { throw new Error('Google Places API error 400: INVALID_ARGUMENT'); }); const tool = makeSearchNearbyVendors(client); await expect(tool.handler({ query: 'bad request' }, mockAuthCtx())).rejects.toThrow('400'); }); }); describe('throttle / retry', () => { it('surfaces a RATE_LIMITED error when the client throws one', async () => { const rateLimitErr = Object.assign(new Error('Google Places API rate limit exceeded'), { code: 'RATE_LIMITED', }); const client = makeMockClient(async () => { throw rateLimitErr; }); const tool = makeSearchNearbyVendors(client); await expect(tool.handler({ query: 'electrician' }, mockAuthCtx())).rejects.toMatchObject({ code: 'RATE_LIMITED', }); }); it('succeeds on a second attempt when the first throws RATE_LIMITED', async () => { let calls = 0; const client: GoogleMapsClient = { searchText: vi.fn().mockImplementation(async () => { calls += 1; if (calls === 1) { const err = Object.assign(new Error('Google Places API rate limit exceeded'), { code: 'RATE_LIMITED', }); throw err; } return [SAMPLE_PLACE]; }), }; // The tool itself does not retry — retry belongs in the server/transport // layer. This test documents that the error is throw-able and re-tryable // by calling the handler twice directly. const tool = makeSearchNearbyVendors(client); const ctx = mockAuthCtx(); // First call fails await expect(tool.handler({ query: 'electrician' }, ctx)).rejects.toThrow(); // Second call succeeds const result = await tool.handler({ query: 'electrician' }, ctx); expect(result.total).toBe(1); expect(result.results[0].name).toBe('Acme Electric LLC'); }); }); describe('scope enforcement', () => { it('throws ScopeError when the context lacks ops:read', async () => { const client = makeMockClient(async () => [SAMPLE_PLACE]); const tool = makeSearchNearbyVendors(client); // Context with an empty scope list — no ops:read const ctx = mockAuthCtx({ scopes: [] }); await expect(tool.handler({ query: 'electrician' }, ctx)).rejects.toThrow(); // The client must never have been called if scope fails expect(client.searchText).not.toHaveBeenCalled(); }); it('throws ScopeError when only a finance scope is present', async () => { const client = makeMockClient(async () => [SAMPLE_PLACE]); const tool = makeSearchNearbyVendors(client); const ctx = mockAuthCtx({ scopes: ['finance:read'] }); await expect(tool.handler({ query: 'electrician' }, ctx)).rejects.toThrow(); expect(client.searchText).not.toHaveBeenCalled(); }); }); describe('tool metadata', () => { it('has the correct name, tier, and requiredScope', () => { const client = makeMockClient(async () => []); const tool = makeSearchNearbyVendors(client); expect(tool.name).toBe('search_nearby_vendors'); expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('ops:read'); }); it('has a valid JSON Schema with query as a required property', () => { const client = makeMockClient(async () => []); const tool = makeSearchNearbyVendors(client); const schema = tool.inputSchema as { required: string[]; properties: Record; }; expect(schema.required).toContain('query'); expect(schema.properties).toHaveProperty('query'); expect(schema.properties).toHaveProperty('lat'); expect(schema.properties).toHaveProperty('lng'); expect(schema.properties).toHaveProperty('radius_meters'); expect(schema.properties).toHaveProperty('max_results'); }); }); });