Files
TREK/server/tests/unit/nest/places.directions.test.ts
T
Maurice b2f2aef016 test(server): seed the small specs through the repositories
The suites with one to three raw statements left now seed and read their
rows through the ORM factories instead of hand-written SQLite on the
better-sqlite3 handle. A legacy statement that serves as a parity oracle,
the schema introspection and the bare backup file keep their raw SQL and
say why with a test-sql-allow marker. Two database mocks still answered a
getPlaceWithTags the module no longer exports; that dead branch goes too,
and the budget seam suite stubs the reservations repository directly
instead of a fake prepare().
2026-10-08 18:46:13 +02:00

279 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 { 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 {},
}));
import { db as testDb } from '../../../src/db/database';
import { createUser, createTrip, createPlace } from '../../helpers/factories';
import { accommodationsOver } from '../../helpers/accommodations-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 { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, createTestCategoriesRepo, createTestTripsRepo, sharedTestOrm } from '../../helpers/test-uow';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
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: MapsService['searchNominatim']): Promise<PlacesService> {
return new PlacesService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
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(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),
);
}
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;
// 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 (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 (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 (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 (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 (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 (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 (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 (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 (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 = await 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 (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 (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 });
});
});