Files
TREK/server/tests/unit/nest/maps.gers-details.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

187 lines
8.4 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-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();
});
});