Files
TREK/server/tests/unit/nest/places.directions.test.ts
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

346 lines
15 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 { db as testDb } from '../../../src/db/database';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import type { MapsService } from '../../../src/nest/maps/maps.service';
import type { OsmClient } from '../../../src/nest/maps/providers/osm.client';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { PlacesService } from '../../../src/nest/places/places.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { accommodationsOver } from '../../helpers/accommodations-service';
import { asLegacyResult } from '../../helpers/domain-error';
import { createUser, createTrip, createPlace } from '../../helpers/factories';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import {
createTestJourneysRepo,
createTestJourneyContributorsRepo,
createTestJourneyTripsRepo,
createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo,
createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import { buildPlaceImportService } from '../../helpers/place-import';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
createTestTagsRepo,
createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo,
createTestPlacesRepo,
createTestTripMembersRepo,
createTestDayAssignmentsRepo,
createTestCategoriesRepo,
createTestTripsRepo,
sharedTestOrm,
} from '../../helpers/test-uow';
import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
// The joined place read goes through PlacesRepository now; the module only hands out the handle.
return { db, closeDb: () => {}, reinitialize: () => {} };
});
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 {},
}));
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,
});
async function svc(searchNominatim: OsmClient['searchNominatim']): Promise<PlacesService> {
// 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.
const maps = {
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;
return new PlacesService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new RealtimeService(),
maps,
new QueryHelpersService(
await createTestTagsRepo(testDb),
await createTestPlaceRatingsRepo(testDb),
await createTestAssignmentParticipantsRepo(testDb),
),
new UnsplashService(
await createTestAppSettingsRepo(testDb),
await createTestUsersRepo(testDb),
new RuntimeEnvService(),
storageFx.storage,
),
photoCacheStub,
new JourneyDomainService(
new RealtimeService(),
new TrekPhotoRegistrationService(
(await sharedTestOrm(testDb)).repo(TrekPhotos),
(await sharedTestOrm(testDb)).repo(TripPhotos),
await createTestJourneyPhotosRepo(testDb),
),
await createTestUnitOfWork(testDb),
await createTestJourneysRepo(testDb),
await createTestJourneyContributorsRepo(testDb),
await createTestJourneyTripsRepo(testDb),
await createTestJourneyEntriesRepo(testDb),
await createTestTripsRepo(testDb),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(testDb),
await createTestJourneyEntryPhotosRepo(testDb),
await createTestPlacesRepo(testDb),
),
storageFx.storage,
await accommodationsOver(testDb),
await createTestUnitOfWork(testDb),
await createTestPlacesRepo(testDb),
await createTestTagsRepo(testDb),
await createTestPlaceRatingsRepo(testDb),
await createTestTripMembersRepo(testDb),
await createTestDayAssignmentsRepo(testDb),
await createTestCategoriesRepo(testDb),
await createTestTripsRepo(testDb),
await createTestBudgetItemsRepo(testDb),
await createTestCollectionPlacesRepo(testDb),
buildPlaceImportService(),
);
}
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 OsmClient['searchNominatim'];
let tripId: string;
// Plan 4 Task 4: `DatabaseService` is gone — `PlacesService` is fully
// repository-backed now, so the `dbs.canAccessTrip`/`isOwner`/
// `rosterUserIds`/`getPlaceWithTags` spies this block used to route to a
// real `DatabaseService` are dead; removed with it.
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 asLegacyResult((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 asLegacyResult(
(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 asLegacyResult(
(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 OsmClient['searchNominatim'];
const result = await asLegacyResult(
(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 asLegacyResult((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 asLegacyResult(
(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 asLegacyResult(
(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 asLegacyResult(
(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 asLegacyResult(
(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 OsmClient['searchNominatim'];
const geocode = vi.fn(async () => ({ lat: 52.52, lng: 13.405 }));
const service = await svc(nominatim);
(service as unknown as { maps: Partial<MapsService> }).maps.geocodeQuery =
geocode as unknown as MapsService['geocodeQuery'];
await asLegacyResult(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 asLegacyResult(
(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 OsmClient['searchNominatim'];
const url = 'https://www.google.com/maps/dir/52.52,13.405/Nowhere/Nowhere+Else';
const result = await asLegacyResult((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 });
});
});