mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
b2f2aef016
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().
279 lines
15 KiB
TypeScript
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 });
|
|
});
|
|
});
|