From 583ac6d4d9b823a70dfbe71c6cf18d6a4a9333e4 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Thu, 9 Apr 2026 16:02:10 -0700 Subject: [PATCH] Add tests for mapsApi.autocomplete and autocompletePlaces service interactions --- client/tests/integration/api/client.test.ts | 51 +++++ server/tests/integration/maps.test.ts | 106 ++++++++++ .../tests/unit/services/mapsService.test.ts | 196 ++++++++++++++++++ 3 files changed, 353 insertions(+) diff --git a/client/tests/integration/api/client.test.ts b/client/tests/integration/api/client.test.ts index 0b71af53..f3121c10 100644 --- a/client/tests/integration/api/client.test.ts +++ b/client/tests/integration/api/client.test.ts @@ -902,3 +902,54 @@ describe('API namespace smoke tests', () => { await expect(backupApi.create()).resolves.toMatchObject({ filename: 'backup.zip' }); }); }); + +describe('mapsApi', () => { + it('FE-MAPS-001: mapsApi.autocomplete sends input, lang, and locationBias', async () => { + let capturedBody: any = null; + + server.use( + http.post('/api/maps/autocomplete', async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json({ + suggestions: [{ placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' }], + source: 'google', + }); + }) + ); + + const result = await mapsApi.autocomplete('Par', 'fr', { lat: 48.8, lng: 2.3 }); + + expect(capturedBody).toEqual({ + input: 'Par', + lang: 'fr', + locationBias: { lat: 48.8, lng: 2.3 }, + }); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].mainText).toBe('Paris'); + expect(result.source).toBe('google'); + }); + + it('FE-MAPS-002: mapsApi.autocomplete works without optional params', async () => { + server.use( + http.post('/api/maps/autocomplete', async ({ request }) => { + const body: any = await request.json(); + expect(body.lang).toBeUndefined(); + expect(body.locationBias).toBeUndefined(); + return HttpResponse.json({ suggestions: [], source: 'nominatim' }); + }) + ); + + const result = await mapsApi.autocomplete('test'); + expect(result.suggestions).toEqual([]); + }); + + it('FE-MAPS-003: mapsApi.autocomplete rejects on server error', async () => { + server.use( + http.post('/api/maps/autocomplete', () => { + return HttpResponse.json({ error: 'Rate limited' }, { status: 429 }); + }) + ); + + await expect(mapsApi.autocomplete('test')).rejects.toThrow(); + }); +}); diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index 91559e20..d89893a1 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -44,6 +44,7 @@ vi.mock('../../src/config', () => ({ // URLs that look internal); individual tests override with mockResolvedValueOnce. vi.mock('../../src/services/mapsService', () => ({ searchPlaces: vi.fn(), + autocompletePlaces: vi.fn(), getPlaceDetails: vi.fn(), getPlacePhoto: vi.fn(), reverseGeocode: vi.fn(), @@ -278,3 +279,108 @@ describe('Maps happy paths (mocked service)', () => { expect(res.body.address).toBeNull(); }); }); + +describe('Maps autocomplete', () => { + it('MAPS-009 — POST /maps/autocomplete without auth returns 401', async () => { + const res = await request(app) + .post('/api/maps/autocomplete') + .send({ input: 'Paris' }); + expect(res.status).toBe(401); + }); + + it('MAPS-010 — POST /maps/autocomplete without input returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({}); + expect(res.status).toBe(400); + }); + + it('MAPS-011 — POST /maps/autocomplete with non-string input returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 123 }); + expect(res.status).toBe(400); + }); + + it('MAPS-012 — POST /maps/autocomplete with invalid locationBias returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'Paris', locationBias: { lat: NaN, lng: 2.3 } }); + expect(res.status).toBe(400); + }); + + it('MAPS-013 — POST /maps/autocomplete returns suggestions from service', async () => { + const { user } = createUser(testDb); + vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({ + suggestions: [ + { placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' }, + ], + source: 'google', + }); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'Paris' }); + + expect(res.status).toBe(200); + expect(res.body.suggestions).toHaveLength(1); + expect(res.body.suggestions[0].mainText).toBe('Paris'); + expect(res.body.source).toBe('google'); + }); + + it('MAPS-014 — POST /maps/autocomplete passes lang and locationBias to service', async () => { + const { user } = createUser(testDb); + vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({ + suggestions: [], + source: 'google', + }); + + await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'test', lang: 'fr', locationBias: { lat: 48.8, lng: 2.3 } }); + + expect(mapsService.autocompletePlaces).toHaveBeenCalledWith( + user.id, + 'test', + 'fr', + { lat: 48.8, lng: 2.3 }, + ); + }); + + it('MAPS-015 — autocomplete service error propagates correct status', async () => { + const { user } = createUser(testDb); + const err = Object.assign(new Error('Rate limited'), { status: 429 }); + vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(err); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'test' }); + + expect(res.status).toBe(429); + expect(res.body.error).toBe('Rate limited'); + }); + + it('MAPS-016 — autocomplete service error without status returns 500', async () => { + const { user } = createUser(testDb); + vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(new Error('Unknown')); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'test' }); + + expect(res.status).toBe(500); + }); +}); diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index 6dd98dd0..28118168 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -709,6 +709,202 @@ describe('searchPlaces (fetch stubbed)', () => { }); }); +// ── autocompletePlaces (fetch stubbed) ────────────────────────────────────── + +describe('autocompletePlaces (fetch stubbed)', () => { + it('MAPS-081: uses Nominatim when user has no API key', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, Île-de-France, France', name: 'Paris' }, + ], + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(999, 'Paris'); + expect(result.source).toBe('nominatim'); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].mainText).toBe('Paris'); + expect(result.suggestions[0].placeId).toBe('node:1'); + }); + + it('MAPS-082: uses Google when user has an API key', async () => { + mockDbGet + .mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }) + .mockReturnValueOnce(null); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + suggestions: [ + { + placePrediction: { + placeId: 'ChIJ1234', + structuredFormat: { + mainText: { text: 'Eiffel Tower' }, + secondaryText: { text: 'Paris, France' }, + }, + }, + }, + ], + }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'Eiffel'); + expect(result.source).toBe('google'); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].placeId).toBe('ChIJ1234'); + expect(result.suggestions[0].mainText).toBe('Eiffel Tower'); + expect(result.suggestions[0].secondaryText).toBe('Paris, France'); + }); + + it('MAPS-083: throws with Google error status when API returns non-ok', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: { message: 'API key invalid' } }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({ + message: 'API key invalid', + status: 403, + }); + }); + + it('MAPS-084: throws generic message when Google error has no message', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: {} }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({ + message: 'Google Places Autocomplete error', + status: 500, + }); + }); + + it('MAPS-085: returns empty suggestions when Google returns no results', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: [] }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'very obscure place'); + expect(result.source).toBe('google'); + expect(result.suggestions).toHaveLength(0); + }); + + it('MAPS-086: filters out suggestions without placePrediction', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + suggestions: [ + { placePrediction: { placeId: 'A', structuredFormat: { mainText: { text: 'Good' } } } }, + { queryPrediction: { text: 'some query' } }, + { placePrediction: { placeId: 'B', structuredFormat: { mainText: { text: 'Also Good' } } } }, + ], + }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'test'); + expect(result.suggestions).toHaveLength(2); + expect(result.suggestions[0].placeId).toBe('A'); + expect(result.suggestions[1].placeId).toBe('B'); + }); + + it('MAPS-087: limits results to 5 suggestions', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + const manySuggestions = Array.from({ length: 10 }, (_, i) => ({ + placePrediction: { + placeId: `id-${i}`, + structuredFormat: { mainText: { text: `Place ${i}` } }, + }, + })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: manySuggestions }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'test'); + expect(result.suggestions).toHaveLength(5); + }); + + it('MAPS-088: includes locationBias in Google request when provided', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + await autocompletePlaces(1, 'test', 'en', { lat: 48.8, lng: 2.3 }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.locationBias).toEqual({ + circle: { + center: { latitude: 48.8, longitude: 2.3 }, + radius: 50000.0, + }, + }); + }); + + it('MAPS-089: omits locationBias from Google request when not provided', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + await autocompletePlaces(1, 'test', 'en'); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.locationBias).toBeUndefined(); + }); + + it('MAPS-090: handles missing structuredFormat fields gracefully', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + suggestions: [ + { placePrediction: { placeId: 'sparse-id' } }, + ], + }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'sparse'); + expect(result.suggestions[0].placeId).toBe('sparse-id'); + expect(result.suggestions[0].mainText).toBe(''); + expect(result.suggestions[0].secondaryText).toBe(''); + }); + + it('MAPS-091: Nominatim fallback returns empty suggestions on searchNominatim error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(999, 'fail'); + expect(result.source).toBe('nominatim'); + expect(result.suggestions).toHaveLength(0); + }); + + it('MAPS-092: Nominatim fallback splits address into mainText and secondaryText', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { osm_type: 'way', osm_id: '42', lat: '51.5', lon: '-0.1', display_name: 'Big Ben, Westminster, London, UK', name: 'Big Ben' }, + ], + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(999, 'Big Ben'); + expect(result.suggestions[0].mainText).toBe('Big Ben'); + expect(result.suggestions[0].secondaryText).toBe('Westminster, London, UK'); + }); +}); + // ── getPlaceDetails (fetch stubbed) ───────────────────────────────────────── describe('getPlaceDetails (fetch stubbed)', () => {