Merge branch 'review/pr-542' into feat/search-autocomplete

This commit is contained in:
jubnl
2026-04-15 04:02:08 +02:00
24 changed files with 765 additions and 41 deletions
+10
View File
@@ -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"
}
+35
View File
@@ -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;
+130 -3
View File
@@ -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
+106
View File
@@ -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)', () => {