From e2be3ec1911dd3be17fbce0cd61bb4397bcbd6f1 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 5 Apr 2026 23:37:53 +0200 Subject: [PATCH] fix(atlas): replace fuzzy region matching with exact name_en check Bidirectional substring matching in isVisitedFeature caused unrelated regions to be highlighted as visited (e.g. selecting Nordrhein-Westfalen also marked Nord France due to "nord" being a substring match). Replace the fuzzy loop with an additional exact check against the Natural Earth name_en property to cover English-vs-native name mismatches. Also fix Nominatim field priority to prefer state over county so reverse-geocoded places resolve to the correct admin-1 level. Adds integration tests ATLAS-009 through ATLAS-011 covering mark/unmark region endpoints and user isolation. Fixes #446 --- client/src/pages/AtlasPage.tsx | 13 +- server/src/services/atlasService.ts | 2 +- server/tests/integration/atlas.test.ts | 181 +++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 8 deletions(-) diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index ddff4ce..2365c8c 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -480,15 +480,13 @@ export default function AtlasPage(): React.ReactElement { } } - // Match feature by ISO code OR region name + // Match feature by ISO code OR region name (native or English) const isVisitedFeature = (f: any) => { if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true const name = (f.properties?.name || '').toLowerCase() if (visitedRegionNames.has(name)) return true - // Fuzzy: check if any visited name is contained in feature name or vice versa - for (const vn of visitedRegionNames) { - if (name.includes(vn) || vn.includes(name)) return true - } + const nameEn = (f.properties?.name_en || '').toLowerCase() + if (nameEn && visitedRegionNames.has(nameEn)) return true return false } @@ -535,15 +533,16 @@ export default function AtlasPage(): React.ReactElement { }, onEachFeature: (feature, layer) => { const regionName = feature?.properties?.name || '' + const regionNameEn = feature?.properties?.name_en || '' const countryName = feature?.properties?.admin || '' const regionCode = feature?.properties?.iso_3166_2 || '' const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() const visited = isVisitedFeature(feature) - const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0 + const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || regionPlaceCounts[regionNameEn.toLowerCase()] || 0 layer.on('click', () => { if (!countryA2) return if (visited) { - const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode) + const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase()) if (regionEntry?.manuallyMarked) { setConfirmActionRef.current({ type: 'unmark-region', diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index f79d082..bb48347 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -421,7 +421,7 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise { expect(res.status).toBe(404); }); }); + +describe('Mark/unmark region', () => { + it('ATLAS-009 — POST /region/:code/mark marks a region as visited', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Nordrhein-Westfalen', country_code: 'DE' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('ATLAS-009 — POST /region/:code/mark without name returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ country_code: 'DE' }); + + expect(res.status).toBe(400); + }); + + it('ATLAS-009 — POST /region/:code/mark without country_code returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Nordrhein-Westfalen' }); + + expect(res.status).toBe(400); + }); + + it('ATLAS-009 — marking a region also auto-marks the parent country', async () => { + const { user } = createUser(testDb); + + await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Nordrhein-Westfalen', country_code: 'DE' }); + + const stats = await request(app) + .get('/api/addons/atlas/stats') + .set('Cookie', authCookie(user.id)); + + const codes = (stats.body.countries as any[]).map((c: any) => c.code); + expect(codes).toContain('DE'); + }); + + it('ATLAS-009 — marking the same region twice is idempotent', async () => { + const { user } = createUser(testDb); + + await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Nordrhein-Westfalen', country_code: 'DE' }); + + const res = await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Nordrhein-Westfalen', country_code: 'DE' }); + + expect(res.status).toBe(200); + }); + + it('ATLAS-010 — GET /regions returns marked regions grouped by country', async () => { + const { user } = createUser(testDb); + + await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Nordrhein-Westfalen', country_code: 'DE' }); + + await request(app) + .post('/api/addons/atlas/region/DE-BY/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Bayern', country_code: 'DE' }); + + const res = await request(app) + .get('/api/addons/atlas/regions') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('regions'); + const deRegions = res.body.regions['DE'] as any[]; + expect(deRegions).toBeDefined(); + const codes = deRegions.map((r: any) => r.code); + expect(codes).toContain('DE-NW'); + expect(codes).toContain('DE-BY'); + }); + + it('ATLAS-011 — DELETE /region/:code/mark unmarks a region', async () => { + const { user } = createUser(testDb); + + await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Nordrhein-Westfalen', country_code: 'DE' }); + + const del = await request(app) + .delete('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)); + + expect(del.status).toBe(200); + expect(del.body.success).toBe(true); + + const res = await request(app) + .get('/api/addons/atlas/regions') + .set('Cookie', authCookie(user.id)); + + const deRegions = res.body.regions['DE'] as any[] | undefined; + const codes = (deRegions || []).map((r: any) => r.code); + expect(codes).not.toContain('DE-NW'); + }); + + it('ATLAS-011 — unmark last region in country also unmarks the parent country', async () => { + const { user } = createUser(testDb); + + await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Nordrhein-Westfalen', country_code: 'DE' }); + + await request(app) + .delete('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)); + + const stats = await request(app) + .get('/api/addons/atlas/stats') + .set('Cookie', authCookie(user.id)); + + const codes = (stats.body.countries as any[]).map((c: any) => c.code); + expect(codes).not.toContain('DE'); + }); + + it('ATLAS-011 — unmark one region keeps country when another region remains', async () => { + const { user } = createUser(testDb); + + await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Nordrhein-Westfalen', country_code: 'DE' }); + + await request(app) + .post('/api/addons/atlas/region/DE-BY/mark') + .set('Cookie', authCookie(user.id)) + .send({ name: 'Bayern', country_code: 'DE' }); + + await request(app) + .delete('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user.id)); + + const stats = await request(app) + .get('/api/addons/atlas/stats') + .set('Cookie', authCookie(user.id)); + + const codes = (stats.body.countries as any[]).map((c: any) => c.code); + expect(codes).toContain('DE'); + }); + + it('ATLAS-011 — regions are isolated between users', async () => { + const { user: user1 } = createUser(testDb); + const { user: user2 } = createUser(testDb); + + await request(app) + .post('/api/addons/atlas/region/DE-NW/mark') + .set('Cookie', authCookie(user1.id)) + .send({ name: 'Nordrhein-Westfalen', country_code: 'DE' }); + + const res = await request(app) + .get('/api/addons/atlas/regions') + .set('Cookie', authCookie(user2.id)); + + expect(res.status).toBe(200); + const deRegions = res.body.regions['DE'] as any[] | undefined; + expect(deRegions).toBeUndefined(); + }); +});