mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge branch 'review/pr-542' into feat/search-autocomplete
This commit is contained in:
Generated
+10
@@ -1590,6 +1590,7 @@
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
@@ -2151,6 +2152,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -3162,6 +3164,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -3669,6 +3672,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -5726,6 +5730,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -5800,6 +5805,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -5955,6 +5961,7 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6071,6 +6078,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6084,6 +6092,7 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -6322,6 +6331,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
autocompletePlaces,
|
||||
} from '../services/mapsService';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -29,6 +30,40 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /autocomplete
|
||||
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { input, lang, locationBias } = req.body;
|
||||
|
||||
if (!input || typeof input !== 'string') {
|
||||
return res.status(400).json({ error: 'Input is required' });
|
||||
}
|
||||
|
||||
if (locationBias) {
|
||||
const { low, high } = locationBias;
|
||||
if (!low || !high
|
||||
|| !Number.isFinite(low.lat) || !Number.isFinite(low.lng)
|
||||
|| !Number.isFinite(high.lat) || !Number.isFinite(high.lng)) {
|
||||
return res.status(400).json({ error: 'Invalid locationBias: low and high must have finite lat and lng' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await autocompletePlaces(
|
||||
authReq.user.id,
|
||||
input,
|
||||
lang as string,
|
||||
locationBias as { low: { lat: number; lng: number }; high: { lat: number; lng: number } } | undefined,
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
const message = err instanceof Error ? err.message : 'Autocomplete error';
|
||||
console.error('Maps autocomplete error:', err);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /details/:placeId
|
||||
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
|
||||
@@ -32,6 +32,16 @@ interface GooglePlaceResult {
|
||||
types?: string[];
|
||||
}
|
||||
|
||||
interface GoogleAutocompleteSuggestion {
|
||||
placePrediction?: {
|
||||
placeId: string;
|
||||
structuredFormat?: {
|
||||
mainText?: { text: string };
|
||||
secondaryText?: { text: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
userRatingCount?: number;
|
||||
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
||||
@@ -108,6 +118,34 @@ export async function searchNominatim(query: string, lang?: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Nominatim lookup (by OSM ID) ────────────────────────────────────────────
|
||||
|
||||
export async function lookupNominatim(osmType: string, osmId: string, lang?: string): Promise<{
|
||||
name: string; address: string; lat: number | null; lng: number | null;
|
||||
} | null> {
|
||||
const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R
|
||||
const params = new URLSearchParams({
|
||||
osm_ids: `${typePrefix}${osmId}`,
|
||||
format: 'json',
|
||||
'accept-language': lang || 'en',
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as NominatimResult[];
|
||||
const item = data[0];
|
||||
if (!item) return null;
|
||||
return {
|
||||
name: item.name || item.display_name?.split(',')[0] || '',
|
||||
address: item.display_name || '',
|
||||
lat: parseFloat(item.lat) || null,
|
||||
lng: parseFloat(item.lon) || null,
|
||||
};
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Overpass API (OSM details) ───────────────────────────────────────────────
|
||||
|
||||
export async function fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
|
||||
@@ -306,15 +344,104 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
return { places, source: 'google' };
|
||||
}
|
||||
|
||||
// ── Autocomplete (Google or Nominatim fallback) ─────────────────────────────
|
||||
|
||||
export async function autocompletePlaces(
|
||||
userId: number,
|
||||
input: string,
|
||||
lang?: string,
|
||||
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
|
||||
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
|
||||
const apiKey = getMapsKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
return autocompleteNominatim(input, lang);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
input,
|
||||
languageCode: lang || 'en',
|
||||
};
|
||||
if (locationBias) {
|
||||
body.locationBias = {
|
||||
rectangle: {
|
||||
low: { latitude: locationBias.low.lat, longitude: locationBias.low.lng },
|
||||
high: { latitude: locationBias.high.lat, longitude: locationBias.high.lng },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json() as { suggestions?: GoogleAutocompleteSuggestion[]; error?: { message?: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const suggestions = (data.suggestions || [])
|
||||
.filter((s) => s.placePrediction)
|
||||
.slice(0, 5)
|
||||
.map((s) => ({
|
||||
placeId: s.placePrediction!.placeId,
|
||||
mainText: s.placePrediction!.structuredFormat?.mainText?.text || '',
|
||||
secondaryText: s.placePrediction!.structuredFormat?.secondaryText?.text || '',
|
||||
}));
|
||||
|
||||
return { suggestions, source: 'google' };
|
||||
}
|
||||
|
||||
async function autocompleteNominatim(
|
||||
input: string,
|
||||
lang?: string,
|
||||
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
|
||||
try {
|
||||
const places = await searchNominatim(input, lang);
|
||||
const suggestions = places.slice(0, 5).map((p) => {
|
||||
const parts = (p.address || '').split(',').map((s) => s.trim());
|
||||
return {
|
||||
placeId: p.osm_id || '',
|
||||
mainText: p.name || parts[0] || '',
|
||||
secondaryText: parts.slice(1).join(', '),
|
||||
};
|
||||
});
|
||||
return { suggestions, source: 'nominatim' };
|
||||
} catch (err) {
|
||||
console.error('Nominatim autocomplete failed:', err);
|
||||
return { suggestions: [], source: 'nominatim' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Place details (Google or OSM) ────────────────────────────────────────────
|
||||
|
||||
export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record<string, unknown> }> {
|
||||
// OSM details: placeId is "node:123456" or "way:123456" etc.
|
||||
if (placeId.includes(':')) {
|
||||
const [osmType, osmId] = placeId.split(':');
|
||||
const element = await fetchOverpassDetails(osmType, osmId);
|
||||
if (!element?.tags) return { place: buildOsmDetails({}, osmType, osmId) };
|
||||
return { place: buildOsmDetails(element.tags, osmType, osmId) };
|
||||
const [element, nominatim] = await Promise.all([
|
||||
fetchOverpassDetails(osmType, osmId),
|
||||
lookupNominatim(osmType, osmId, lang),
|
||||
]);
|
||||
const details = buildOsmDetails(element?.tags || {}, osmType, osmId);
|
||||
return {
|
||||
place: {
|
||||
...details,
|
||||
name: nominatim?.name || element?.tags?.name || '',
|
||||
address: nominatim?.address || '',
|
||||
lat: nominatim?.lat ?? null,
|
||||
lng: nominatim?.lng ?? null,
|
||||
osm_id: placeId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Google details
|
||||
|
||||
@@ -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: { low: { lat: NaN, lng: 2.3 }, high: { lat: 49, lng: 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: { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } } });
|
||||
|
||||
expect(mapsService.autocompletePlaces).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
'test',
|
||||
'fr',
|
||||
{ low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } },
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -714,6 +714,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', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.locationBias).toEqual({
|
||||
rectangle: {
|
||||
low: { latitude: 48.5, longitude: 2.0 },
|
||||
high: { latitude: 49.0, longitude: 2.8 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user