Files
TREK/server/tests/unit/nest/maps.gers-details.test.ts
Maurice accad52ed5 fix(maps): place suggestions name their town and country in the language of the search
The TREK Places service takes an optional lang now and puts localities into
it, and names its OpenStreetMap layer rows in it with the settlement they are
in. TREK never sent one, so a Japan trip in an English UI kept reading
"福岡市博多区" and the layer rows had no place at all, only the local name.

Search, autocomplete, nearby and the details of an index place now ask in the
reader's language (TREK codes through toApiLang, anything that is no language
tag left off). A layer row with a locality reads "Sumida, Japan" like an index
row, the country named from its coordinate; without one it keeps the local
name as before. The offline area download stays without a language. REST and
MCP share the service methods, so both get it.

Refs #2592
2026-10-12 20:53:08 +02:00

192 lines
8.7 KiB
TypeScript

/**
* MAPS-GERS-001..007 — the details of a place that came from the index.
*
* The case that started this: L'Osteria Steinstrasse showed opening hours while
* it was being added and none on its card afterwards. The index is not the only
* source for a place it holds, and this is where the rest gets filled in.
*/
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { MapsService } from '../../../src/nest/maps/maps.service';
import type { OsmClient } from '../../../src/nest/maps/providers/osm.client';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { noGoogleQuota } from '../../helpers/google-quota';
import { buildMapsParts } from '../../helpers/maps-service';
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockById } = vi.hoisted(() => ({
mockById: vi.fn(async (_gers: string): Promise<unknown> => null),
}));
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
trekPlacesById: mockById,
}));
// keyedProvider/resolveMapsKey (maps.service.ts) go through instance-api-keys.ts
// on every call now — none of these cases configure a key, so the stubs just
// answer "unset" the way the fake database.get(() => undefined) already did.
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
const PLACE = {
gers: 'abc-123',
name: "L'Osteria",
lat: 54.0879,
lng: 12.1408,
category: 'restaurant',
categoryPath: 'eat_and_drink>restaurant',
confidence: 1,
address: { freeform: 'Steinstrasse 9', locality: 'Rostock', postcode: '18055', region: null, country: 'DE' },
contact: { website: 'https://losteria.net/', phone: null, email: null, socials: null },
brand: null,
source: 'overture',
hours: null as { osm: string } | null,
};
/** The OSM client each service was built with, so a case can assert on what it was asked. */
const osmOf = new WeakMap<MapsService, OsmClient>();
function make(osmTags: Record<string, string> | null) {
const { svc, osm } = buildMapsParts(
{} as PlacePhotoCacheService,
noAppSettings,
noUsers,
{} as never,
{} as never,
noGoogleQuota,
);
osmOf.set(svc, osm);
vi.spyOn(osm, 'resolveOsmIdentity').mockResolvedValue(
osmTags ? { tags: osmTags, osmUrl: 'https://www.openstreetmap.org/node/1', matchedName: "L'Osteria" } : null,
);
return svc;
}
beforeEach(() => {
mockById.mockReset();
mockById.mockResolvedValue({ ...PLACE });
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
describe('MapsService.getPlaceDetails for a gers: id', () => {
it('MAPS-GERS-001: prefers the hours OpenStreetMap has for the same building', async () => {
// OSM describes this exact object and gets corrected by people who walked
// past. A chain's website often carries one set of hours for every branch.
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
const out = await make({ opening_hours: 'Mo-Su 11:30-23:00' }).getPlaceDetails(1, 'gers:abc-123');
// Expanded to weekday lines, which is the shape the card reads.
expect(out.place?.opening_hours).toEqual(expect.arrayContaining(['Monday: 11:30-23:00']));
});
it('MAPS-GERS-002: falls back to the hours the operator publishes', async () => {
// OpenStreetMap has hours for 27.5 percent of gastronomy, measured across
// seven countries. This is what covers part of the rest.
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
const out = await make({ cuisine: 'italian' }).getPlaceDetails(1, 'gers:abc-123');
expect(out.place?.opening_hours).toEqual(expect.arrayContaining(['Monday: 12:00-22:00']));
});
it('MAPS-GERS-003: still answers when OpenStreetMap knows nothing about it', async () => {
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
const out = await make(null).getPlaceDetails(1, 'gers:abc-123');
expect(out.place?.name).toBe("L'Osteria");
// Same shape whether the hours came from OSM or from the operator's site.
expect(out.place?.opening_hours).toEqual(expect.arrayContaining(['Monday: 12:00-22:00']));
});
it('MAPS-GERS-004: a place nobody has hours for shows none, not an empty string', async () => {
const out = await make(null).getPlaceDetails(1, 'gers:abc-123');
expect(out.place?.opening_hours ?? null).toBeNull();
});
it('MAPS-GERS-005: the index wins on the fields the user actually picked', async () => {
const out = await make({ name: 'Etwas anderes', opening_hours: 'Mo-Fr 09:00-17:00' }).getPlaceDetails(
1,
'gers:abc-123',
);
expect(out.place).toMatchObject({ name: "L'Osteria", osm_id: 'gers:abc-123', source: 'trek-places' });
});
it('MAPS-GERS-011: the place is read in the language of the request, so its locality is too (#2592)', async () => {
await make(null).getPlaceDetails(1, 'gers:abc-123', 'ja');
expect(mockById).toHaveBeenCalledWith('abc-123', { lang: 'ja' });
});
it('MAPS-GERS-006: an unknown id is null rather than a thrown request', async () => {
mockById.mockResolvedValue(null);
expect(await make(null).getPlaceDetails(1, 'gers:nope')).toEqual({ place: null });
});
it('MAPS-GERS-007: a failing lookup is a null, not a 500', async () => {
mockById.mockRejectedValue(new Error('places api down'));
expect(await make(null).getPlaceDetails(1, 'gers:abc-123')).toEqual({ place: null });
});
it('MAPS-GERS-008: Overpass being down costs the OSM half, not the whole place', async () => {
// The index has already answered by the time OSM is asked; the second lookup
// only adds what OSM knows about the same building. Letting its failure
// through would turn a working answer into an error for the one user whose
// details request happened to land while Overpass was unreachable.
const { svc, osm } = buildMapsParts(
{} as PlacePhotoCacheService,
noAppSettings,
noUsers,
{} as never,
{} as never,
noGoogleQuota,
);
vi.spyOn(osm, 'resolveOsmIdentity').mockRejectedValue(new Error('overpass down'));
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
const out = await svc.getPlaceDetails(1, 'gers:abc-123');
expect(out.place).toMatchObject({
name: "L'Osteria",
osm_id: 'gers:abc-123',
source: 'trek-places',
website: 'https://losteria.net/',
});
// The index's own hours survive: they came with the record, not from OSM.
expect(out.place?.opening_hours).toBeTruthy();
});
// #2483: the place from the issue, whose index record carries its website
// without a scheme. Both halves of the merge hand it over completed.
it('MAPS-GERS-010: the merged website has its scheme, from the index or else from OSM', async () => {
const site = 'fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët';
mockById.mockResolvedValue({
...PLACE,
gers: 'ceba0e62-172b-4343-bb3b-78b915a18383',
contact: { ...PLACE.contact, website: site },
});
expect((await make(null).getPlaceDetails(1, 'gers:ceba0e62-172b-4343-bb3b-78b915a18383')).place?.website).toBe(
`https://${site}`,
);
expect(
(await make({ website: 'www.example.fr' }).getPlaceDetails(1, 'gers:ceba0e62-172b-4343-bb3b-78b915a18383')).place
?.website,
).toBe(`https://${site}`);
mockById.mockResolvedValue({ ...PLACE, contact: { ...PLACE.contact, website: 'javascript:alert(1)' } });
expect((await make({ website: 'www.example.fr' }).getPlaceDetails(1, 'gers:abc-123')).place?.website).toBe(
'https://www.example.fr',
);
expect((await make(null).getPlaceDetails(1, 'gers:abc-123')).place?.website).toBeNull();
});
it('MAPS-GERS-009: with the index switched off, opening a saved place asks nobody', async () => {
// TREK_PLACES_ENABLED=false is the operator's word that nothing leaves for
// the index. Search, autocomplete and the area download honoured it; the
// lookup by id did not, so every saved place that came from the index was
// still sent out, with the instance token, on every open.
const svc = make({ opening_hours: 'Mo-Su 11:30-23:00' });
vi.spyOn(svc, 'trekPlacesEnabled').mockReturnValue(false);
await expect(svc.getPlaceDetails(1, 'gers:abc-123')).resolves.toEqual({ place: null });
expect(mockById).not.toHaveBeenCalled();
expect(osmOf.get(svc)?.resolveOsmIdentity).not.toHaveBeenCalled();
});
});