Files
TREK/server/tests/unit/nest/places.directions.test.ts
T
Maurice 41e256bf0f feat(roadtrip): answer the corridor search from the place index
Search along the route went to Overpass and only to Overpass, on the one path
where that hurts most: one search fans out over sixteen boxes, and each of those
races four public mirrors. The index answers the map's explore pills in
milliseconds where those mirrors take seconds, and the corridor was the reason
the index exists.

Two independent things kept it out. The corridor sends its categories as a
comma-separated list, and the index branch looked the whole string up as one key,
so it missed every time. And the four kinds a drive needs, fuel, charging, rest
areas and campsites, had no Overture terms mapped at all. They do now:
gas_station and fueling_station, ev_charging_station, rest_stop, and campground
with rv_park.

The branch is list-aware the way searchOverpassPois already was, and it labels
each hit with the category that produced it rather than with the list that was
asked for, because the client colours and groups its markers by that field and
"fuel,charging" is not a category. It answers all-or-nothing: a category the
index has no terms for sends the whole query to Overpass, since answering the
half it knows would silently drop the rest while the panel still reported a
finished search. The caller's limit is passed through instead of a hardcoded 50,
so a mixed search gets the same per-category budget the Overpass path spends.

The other outbound path of this addon changes with it. Importing a shared Google
route geocoded its waypoints by calling searchNominatim directly, which walked
past geocodeQuery and its index lookup. Through geocodeQuery an index hit costs
no throttle slot at all, and what the index does not know still falls through on
the background lane.

A place answered from the index also carries its chain and that chain's Wikidata
item, so a branch gets its logo on the pin here exactly as it does on the
Overpass path. Sockets stay an OSM field: a charging station answered from the
index reports "not stated" rather than claiming it offers nothing.
2026-09-07 18:36:54 +02:00

267 lines
13 KiB
TypeScript

/**
* Importing a shared Google Maps directions link (PLACES-DIR-*).
*
* A real in-memory DB, so the dedupe and the insert are exercised as they run; the SSRF
* guard and the geocoder are stubbed, because neither a DNS lookup nor Nominatim belongs
* in a unit test. What is under test is the step between the two: which stops the link
* yields, which of them get geocoded, and what a partly readable link leaves behind.
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA foreign_keys = ON');
// DatabaseService delegates the joined read straight through to the module, so the
// mock has to answer it: without this the insert half of every import throws.
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: any) => {
const place: any = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId);
return place ? { ...place, category: null, tags: [] } : null;
},
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
const { checkSsrf, safeFetchFollow } = vi.hoisted(() => ({
checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })),
safeFetchFollow: vi.fn(),
}));
vi.mock('../../../src/utils/ssrfGuard', () => ({
checkSsrf,
safeFetchFollow,
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError: class SsrfBlockedError extends Error {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { createUser, createTrip, createPlace } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { PlacesService } from '../../../src/nest/places/places.service';
import type { MapsService } from '../../../src/nest/maps/maps.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { makeStorageFixture } from '../../helpers/storage-fixture';
const dbs = new DatabaseService(testDb);
const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService;
const storageFx = makeStorageFixture('');
/** Nominatim's answer shape, cut to the fields this path reads. */
const hit = (name: string, lat: number, lng: number) => ({
google_place_id: null, google_ftid: null, osm_id: `node:${name}`, name, address: name,
lat, lng, rating: null, website: null, phone: null, source: 'openstreetmap' as const,
});
function svc(searchNominatim: MapsService['searchNominatim']): PlacesService {
return new PlacesService(
dbs,
new PermissionsService(dbs),
new RealtimeService(),
// The address backfill runs fire-and-forget after every import, so the stub answers
// it too — otherwise every passing test prints a rejected promise.
// geocodeQuery is what the importer calls now: it asks the TREK index first
// and falls through to Nominatim on the background lane. Stubbed in terms of
// the searchNominatim each case already provides, so the cases keep driving
// one seam while the code under test uses the real one.
{
searchNominatim,
geocodeQuery: async (query: string) => {
const hit = (await searchNominatim(query, undefined, 'background'))
.find((h: { lat: number | null; lng: number | null }) => h.lat !== null && h.lng !== null);
return hit ? { lat: hit.lat as number, lng: hit.lng as number } : null;
},
reverseGeocode: vi.fn(async () => null),
} as unknown as MapsService,
new QueryHelpersService(dbs),
new UnsplashService(dbs, new RuntimeEnvService(), storageFx.storage),
photoCacheStub,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)),
storageFx.storage,
);
}
const geocoder = () => vi.fn(async (query: string) => {
const known: Record<string, [number, number]> = {
Berlin: [52.52, 13.405],
Dresden: [51.05, 13.737],
Prague: [50.075, 14.437],
};
const found = known[query];
return found ? [hit(query, found[0], found[1])] : [];
}) as unknown as MapsService['searchNominatim'];
let tripId: string;
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
testDb.exec('DELETE FROM places; DELETE FROM trips; DELETE FROM users');
const { user } = createUser(testDb, { email: 'dir@example.com' });
tripId = String(createTrip(testDb, user.id, { title: 'Road trip' }).id);
checkSsrf.mockResolvedValue({ allowed: true, resolvedIp: '1.2.3.4' });
safeFetchFollow.mockReset();
});
describe('PlacesService.importGoogleDirections', () => {
it('PLACES-DIR-001: a link whose blob carries every stop needs no geocoder at all', async () => {
const search = geocoder();
const url = 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/'
+ 'data=!4m14!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409';
const result = await svc(search).importGoogleDirections(tripId, url);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.places.map((p) => p.name)).toEqual(['Berlin', 'Dresden']);
expect(result.places[0].lat).toBeCloseTo(52.520008, 5);
expect(result.listName).toBe('Berlin → Dresden');
expect(search).not.toHaveBeenCalled();
});
it('PLACES-DIR-002: a stop that is only a name is geocoded, and keeps the name from the link', async () => {
const search = geocoder();
const result = await svc(search).importGoogleDirections(
tripId,
'https://www.google.com/maps/dir/Berlin/Dresden/Prague',
);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.places.map((p) => p.name)).toEqual(['Berlin', 'Dresden', 'Prague']);
expect(search).toHaveBeenCalledTimes(3);
});
it('PLACES-DIR-003: a stop nobody can place is left out, not made up, and is counted', async () => {
const search = geocoder();
const result = await svc(search).importGoogleDirections(
tripId,
'https://www.google.com/maps/dir/Berlin/Somewhere+Nobody+Knows/Prague',
);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.places.map((p) => p.name)).toEqual(['Berlin', 'Prague']);
// Skipped covers both "already on the trip" and "could not be placed": from the
// traveller's side they are one number, this many stops did not arrive.
expect(result.skipped).toBe(1);
});
it('PLACES-DIR-004: a geocoder that is down costs its stop, never the import', async () => {
const search = vi.fn(async (query: string) => {
if (query === 'Dresden') throw new Error('Nominatim 429');
return [hit(query, 52.52, 13.405)];
}) as unknown as MapsService['searchNominatim'];
const result = await svc(search).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden/Prague');
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.places).toHaveLength(2);
expect(result.skipped).toBe(1);
});
it('PLACES-DIR-005: a stop already on the trip is skipped rather than doubled', async () => {
createPlace(testDb, Number(tripId), { name: 'Berlin', lat: 52.520008, lng: 13.404954 });
const url = 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/'
+ 'data=!4m14!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409';
const result = await svc(geocoder()).importGoogleDirections(tripId, url);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.places.map((p) => p.name)).toEqual(['Dresden']);
expect(result.skipped).toBe(1);
});
it('PLACES-DIR-006: a link that is not Google\'s is refused after it is resolved', async () => {
const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://evil.example.com/maps/dir/Berlin/Dresden');
expect(result).toEqual({ error: 'That link is not a Google Maps link.', status: 400 });
});
it('PLACES-DIR-007: a short link is followed once, through the guard', async () => {
safeFetchFollow.mockResolvedValue({
url: 'https://www.google.com/maps/dir/Berlin/Dresden',
} as unknown as Response);
const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://maps.app.goo.gl/abc123');
expect(safeFetchFollow).toHaveBeenCalledTimes(1);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.places).toHaveLength(2);
});
it('PLACES-DIR-008: a blocked URL never reaches the parser', async () => {
checkSsrf.mockResolvedValue({ allowed: false } as never);
const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden');
expect(result).toEqual({ error: 'URL is not allowed', status: 400 });
});
it('PLACES-DIR-009: a link with nothing to read says what to do instead', async () => {
const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin');
expect(result).toMatchObject({ status: 400 });
expect((result as { error: string }).error).toMatch(/Share button/);
});
it('PLACES-DIR-012: the bulk geocoding goes through the index, not straight at Nominatim', async () => {
// geocodeQuery asks the TREK index first and only falls through to
// Nominatim for what it does not know, on the BACKGROUND lane. That matters
// more here than anywhere: this loop runs up to thirty times in one request,
// each Nominatim call taking the next slot on a 1.1 s process-wide throttle,
// so on the interactive lane one pasted link made everybody else's place
// search queue behind it for half a minute. An index hit costs no slot.
const nominatim = vi.fn(async () => [{ lat: 52.52, lng: 13.405 }]) as unknown as MapsService['searchNominatim'];
const geocode = vi.fn(async () => ({ lat: 52.52, lng: 13.405 }));
const service = svc(nominatim);
(service as unknown as { maps: Partial<MapsService> }).maps.geocodeQuery =
geocode as unknown as MapsService['geocodeQuery'];
await service.importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden');
expect(geocode).toHaveBeenCalledWith('Berlin');
expect(geocode).toHaveBeenCalledWith('Dresden');
// Not reached directly: whether Nominatim is asked at all, and on which
// lane, is geocodeQuery's business and is pinned in maps.geocode.test.ts.
expect(nominatim).not.toHaveBeenCalled();
});
it('PLACES-DIR-011: a route shared as a short link is imported as a route', async () => {
// The path of a short link is `/<code>`: nothing in it says route or list,
// so the dispatch upstream cannot tell and sent every one of them to the
// list importer, which answered "could not extract list ID" — the complaint
// this whole import was written to remove. The Share sheet in the Google
// Maps phone app produces exactly this shape, and the box beside it says a
// directions link works.
safeFetchFollow.mockResolvedValue({
url: 'https://www.google.com/maps/dir/52.52,13.405/51.05,13.74',
} as never);
const result = await svc(geocoder()).importGoogleList(tripId, 'https://maps.app.goo.gl/aBcDeF12345');
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.places).toHaveLength(2);
// Resolved once, not once per importer: the route is handed on with the URL
// the redirect already produced.
expect(safeFetchFollow).toHaveBeenCalledTimes(1);
});
it('PLACES-DIR-010: a route where only one stop can be placed is not half an import', async () => {
const search = vi.fn(async () => []) as unknown as MapsService['searchNominatim'];
const url = 'https://www.google.com/maps/dir/52.52,13.405/Nowhere/Nowhere+Else';
const result = await svc(search).importGoogleDirections(tripId, url);
expect(result).toEqual({ error: 'None of the stops in that link could be placed on the map.', status: 400 });
});
});