mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
9dd58fe32f
Deleting a journey left its uploaded photos behind for good, and deleting a list queued neither its cover nor its place images. An object with a key the store refuses no longer fails every night's pass, and the admin view keeps the counts of a category skipped for a running backup.
346 lines
15 KiB
TypeScript
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 { 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),
|
|
{ releaseValues: async () => 0 } as never, // StorageReleaseService
|
|
),
|
|
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),
|
|
{ releaseValues: async () => [] } as never, // StorageReleaseService
|
|
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 });
|
|
});
|
|
});
|