fix(search-autocomplete): address PR #542 review issues

- Fix race condition: AbortController cancels in-flight autocomplete
  requests on each keystroke; stale responses no longer overwrite fresh ones
- Remove acTrigger state hack; onFocus calls fetchSuggestions directly
- Cap autocomplete input at 200 chars server-side (400 on violation)
- Filter Nominatim suggestions with empty osm_id segments
- Revert getPlaceDetails OSM branch from unconditional parallel fetch to
  conditional serial: Nominatim called only when Overpass lacks coords/address
- Wire places.loadingDetails i18n key to Loader2 spinner via aria-label/role
- Add tests: MAPS-017, MAPS-040c, MAPS-093, FE-MAPS-004
This commit is contained in:
jubnl
2026-04-15 04:16:56 +02:00
parent 35321076cf
commit 607498cabe
7 changed files with 133 additions and 35 deletions
+4
View File
@@ -39,6 +39,10 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
return res.status(400).json({ error: 'Input is required' });
}
if (input.length > 200) {
return res.status(400).json({ error: 'Input too long (max 200 chars)' });
}
if (locationBias) {
const { low, high } = locationBias;
if (!low || !high
+22 -16
View File
@@ -406,14 +406,17 @@ async function autocompleteNominatim(
): 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(', '),
};
});
const suggestions = places
.filter((p) => p.osm_id && p.osm_id.includes(':') && p.osm_id.split(':')[1] !== '')
.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);
@@ -427,18 +430,21 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
// OSM details: placeId is "node:123456" or "way:123456" etc.
if (placeId.includes(':')) {
const [osmType, osmId] = placeId.split(':');
const [element, nominatim] = await Promise.all([
fetchOverpassDetails(osmType, osmId),
lookupNominatim(osmType, osmId, lang),
]);
const element = await fetchOverpassDetails(osmType, osmId);
const details = buildOsmDetails(element?.tags || {}, osmType, osmId);
// Fetch Nominatim only when Overpass lacks coordinates or address
const d = details as Record<string, unknown>;
const needsNominatim = !d.lat || !d.lng || !d.address;
const nominatim = needsNominatim ? await lookupNominatim(osmType, osmId, lang) : null;
return {
place: {
...details,
name: nominatim?.name || element?.tags?.name || '',
address: nominatim?.address || '',
lat: nominatim?.lat ?? null,
lng: nominatim?.lng ?? null,
name: (d.name as string) || nominatim?.name || element?.tags?.name || '',
address: (d.address as string) || nominatim?.address || '',
lat: d.lat ?? nominatim?.lat ?? null,
lng: d.lng ?? nominatim?.lng ?? null,
osm_id: placeId,
},
};
+12
View File
@@ -383,4 +383,16 @@ describe('Maps autocomplete', () => {
expect(res.status).toBe(500);
});
it('MAPS-017 — POST /maps/autocomplete with input > 200 chars returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/maps/autocomplete')
.set('Cookie', authCookie(user.id))
.send({ input: 'a'.repeat(201) });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/too long/i);
});
});
@@ -908,6 +908,21 @@ describe('autocompletePlaces (fetch stubbed)', () => {
expect(result.suggestions[0].mainText).toBe('Big Ben');
expect(result.suggestions[0].secondaryText).toBe('Westminster, London, UK');
});
it('MAPS-093: Nominatim fallback filters out results with empty osm_id', 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, France', name: 'Paris' },
{ osm_type: 'node', osm_id: '', lat: '51.5', lon: '-0.1', display_name: 'London, UK', name: 'London' },
{ osm_type: 'way', osm_id: '3', lat: '52.5', lon: '13.4', display_name: 'Berlin, Germany', name: 'Berlin' },
],
}));
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(999, 'test');
expect(result.suggestions).toHaveLength(2);
expect(result.suggestions.map((s) => s.placeId)).toEqual(['node:1', 'way:3']);
});
});
// ── getPlaceDetails (fetch stubbed) ─────────────────────────────────────────
@@ -1023,6 +1038,37 @@ describe('getPlaceDetails (fetch stubbed)', () => {
expect(review.photo).toBeNull();
});
it('MAPS-040c: OSM path enriches name/address/coords from Nominatim (serial fetch)', async () => {
const fetchMock = vi.fn()
// First call: Overpass (returns element with tags but no coords)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ elements: [{ tags: { website: 'https://example.com' } }] }),
})
// Second call: Nominatim /lookup
.mockResolvedValueOnce({
ok: true,
json: async () => [
{ osm_type: 'way', osm_id: '5', lat: '48.85', lon: '2.29', display_name: 'Eiffel Tower, Paris, France', name: 'Eiffel Tower' },
],
});
vi.stubGlobal('fetch', fetchMock);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
const result = await getPlaceDetails(1, 'way:5');
const place = result.place as any;
expect(place.name).toBe('Eiffel Tower');
expect(place.address).toBe('Eiffel Tower, Paris, France');
expect(place.lat).toBeCloseTo(48.85);
expect(place.lng).toBeCloseTo(2.29);
expect(place.source).toBe('openstreetmap');
// Overpass first, then Nominatim — two total fetch calls
expect(fetchMock).toHaveBeenCalledTimes(2);
const overpassUrl = fetchMock.mock.calls[0][0] as string;
const nominatimUrl = fetchMock.mock.calls[1][0] as string;
expect(overpassUrl).toContain('overpass');
expect(nominatimUrl).toContain('nominatim');
});
it('MAPS-041e: open_now is null when regularOpeningHours.openNow is undefined', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({