Files
TREK/server/tests/unit/nest/maps.gers-details.test.ts
T
Maurice 273bac8917 fix(maps): keep index ids away from Google, and let the area download be big
Four things the index changed and nothing followed:

`gers:` was missing from NON_GOOGLE_PLACE_ID, so isGooglePlaceId said true
for every id the index hands out. Five call sites gate on that function to
keep unresolvable ids away from Google, which answers them with a billable
400 INVALID_ARGUMENT: photo refs, the editorial summary, the place-photo
route, the expanded details lookup and the enrichment photo pass. Since the
index answers first for search and autocomplete, a GERS id is what an
ordinary new place now carries, so this was the normal case rather than an
edge one — three billed lookups per added place, none of which could ever
return anything.

The area download shared the search path's megabyte cap and its 3.5 s
timeout. Both are right for a keystroke and wrong for the offline prefetch,
which asks for 3000 rows at once: a city-sized answer sits on that megabyte,
readCapped threw, and the caller could only report "index unavailable" — the
trip then went offline with an empty place cache and nothing said so. The cap
now follows the row count the caller asked for, with a floor, and the bulk
call gets a budget that fits a bulk download. It is still capped, so a broken
upstream cannot hand us an endless body.

searchPlaces returned before the Google branch as soon as the index or
OpenStreetMap had anything, and both set google_place_id to null. That is
right for a search and wrong for the one caller that wants an identity rather
than an answer: list-import enrichment (#886) exists to attach a
google_place_id to a bare imported pin, and pickEnrichmentMatch discards every
candidate without one. So the import silently stayed unenriched on an instance
that pays for a key. That caller now asks for the Google identity explicitly.
Nothing changes for an instance without a key — enrichment could never resolve
anything there, before or after.

Tests cover the three functions this branch had left untested, which is what
put the maps ratchet under its functions threshold: the autocomplete index
path (a new file, MAPS-AUTO-001..007), the gers details branch when Overpass
is down (MAPS-GERS-008), and the client's abort timeout — including that the
signal exists at all, which nothing asserted before.
2026-09-07 14:05:33 +02:00

123 lines
5.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 { 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,
}));
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
import { MapsService } from '../../../src/nest/maps/maps.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
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,
};
function make(osmTags: Record<string, string> | null) {
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
const svc = new MapsService(database, {} as PlacePhotoCacheService);
vi.spyOn(svc, '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 database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
const svc = new MapsService(database, {} as PlacePhotoCacheService);
vi.spyOn(svc, '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();
});
});