mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(places): import the stops of a shared Google Maps route
Pasting a /maps/dir/ link into the list-import box answered "could not extract list ID", which is the whole of the complaint: people share a drive far more often than they share a list, and it is the same gesture — somebody pressed Share in Google Maps. Which screen they were on is the URL's business, so the box takes both and the controller decides. No API key and no call to Google for the link itself: the stops are in the URL. Three shapes arrive — the path form with its data blob, the documented ?api=1&origin= form, and a short link that is one of those after a redirect — and a stop the link spells out in coordinates needs no geocoder at all. One that is only a name is geocoded, keeping the name from the link rather than the one the geocoder answers with: somebody who typed a nickname into Google should not find a street address on their trip. A name nobody can place is left out and counted rather than failing the import, because a route of six stops with five findable is five stops more than the traveller had. Coordinates are read as 1d/2d, longitude before latitude, which is the reverse of a place link and the one way to put Berlin in Somalia. A blob that does not line up with the stops is ignored entirely: half a link read confidently is worse than one read as names.
This commit is contained in:
@@ -896,6 +896,32 @@ describe('Tool: import_places_from_url', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('sends a directions link to the directions importer, the same way the REST route does', async () => {
|
||||
// The parity that matters here is not the wire shape but the dispatch: a pasted
|
||||
// route has to reach the same importer through the tool as through the box.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const dir = vi.spyOn(PlacesService.prototype, 'importGoogleDirections').mockResolvedValue({
|
||||
places: [{ id: 7, name: 'Berlin' }] as never,
|
||||
listName: 'Berlin \u2192 Prague',
|
||||
skipped: 1,
|
||||
});
|
||||
const list = vi.spyOn(PlacesService.prototype, 'importGoogleList');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const url = 'https://www.google.com/maps/dir/Berlin/Dresden/Prague';
|
||||
const result = await h.client.callTool({
|
||||
name: 'import_places_from_url',
|
||||
arguments: { tripId: trip.id, url, source: 'google-list' },
|
||||
});
|
||||
expect(parseToolResult(result) as any).toMatchObject({ count: 1, skipped: 1 });
|
||||
expect(dir).toHaveBeenCalledWith(String(trip.id), url, { enrich: false, userId: user.id });
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
dir.mockRestore();
|
||||
list.mockRestore();
|
||||
});
|
||||
|
||||
it('surfaces the naver importer\'s { error, status } as a tool error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Reading a shared Google Maps directions link (MAPS-DIR-*).
|
||||
*
|
||||
* The links here are the real shapes, shortened: what the Share button produces on the
|
||||
* web, what the URL builder in Google's own docs produces, and what somebody gets when
|
||||
* they drop two pins and copy the address bar. The data blobs are trimmed to the parts
|
||||
* that matter, because the full ones run to four hundred characters of protobuf.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from '../../../src/nest/places/maps-dir.helpers';
|
||||
|
||||
describe('isDirectionsUrl', () => {
|
||||
it('MAPS-DIR-001: tells a route apart from a list and from a single place', () => {
|
||||
expect(isDirectionsUrl('https://www.google.com/maps/dir/Berlin/Dresden')).toBe(true);
|
||||
expect(isDirectionsUrl('https://www.google.com/maps/dir/?api=1&origin=A&destination=B')).toBe(true);
|
||||
expect(isDirectionsUrl('https://www.google.com/maps/place/Brandenburger+Tor/@52.5,13.3,17z')).toBe(false);
|
||||
expect(isDirectionsUrl('https://www.google.com/maps/placelists/list/abc123')).toBe(false);
|
||||
expect(isDirectionsUrl('not a url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDirectionsUrl', () => {
|
||||
it('MAPS-DIR-010: reads the stops out of the path, in driving order', () => {
|
||||
const stops = parseDirectionsUrl('https://www.google.com/maps/dir/Berlin/Dresden/Prague/@50.9,13.5,8z');
|
||||
expect(stops.map((s) => s.name)).toEqual(['Berlin', 'Dresden', 'Prague']);
|
||||
// Nothing invented: without a data blob there are no coordinates to be had, and the
|
||||
// caller geocodes rather than guessing.
|
||||
expect(stops.every((s) => s.lat === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-011: takes the coordinates out of the data blob when there is one per stop', () => {
|
||||
const url = 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/'
|
||||
+ 'data=!4m14!4m13!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409';
|
||||
// 1d is longitude and 2d is latitude, the reverse of a place link. Getting the two
|
||||
// the wrong way round is the failure this pins.
|
||||
expect(parseDirectionsUrl(url)).toEqual([
|
||||
{ name: 'Berlin', lat: 52.520008, lng: 13.404954 },
|
||||
{ name: 'Dresden', lat: 51.050409, lng: 13.737262 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-012: a blob that does not line up leaves every stop to the geocoder', () => {
|
||||
// Google drops the pair for "your location" and adds pairs of its own on a route
|
||||
// through several countries. Half a link read confidently is worse than one read as
|
||||
// names, so a mismatched count means names.
|
||||
const url = 'https://www.google.com/maps/dir/Berlin/Dresden/Prague/@51,13,7z/'
|
||||
+ 'data=!4m8!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008';
|
||||
const stops = parseDirectionsUrl(url);
|
||||
expect(stops).toHaveLength(3);
|
||||
expect(stops.every((s) => s.lat === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-013: a stop written as coordinates needs no geocoder at all', () => {
|
||||
expect(parseDirectionsUrl('https://www.google.com/maps/dir/52.520008,13.404954/51.050409,13.737262')).toEqual([
|
||||
{ name: null, lat: 52.520008, lng: 13.404954 },
|
||||
{ name: null, lat: 51.050409, lng: 13.737262 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-014: escapes and plus signs come back as the name somebody typed', () => {
|
||||
const stops = parseDirectionsUrl('https://www.google.com/maps/dir/Frankfurt+am+Main/K%C3%B6ln+Hbf');
|
||||
expect(stops.map((s) => s.name)).toEqual(['Frankfurt am Main', 'Köln Hbf']);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-015: the documented api=1 form names its parts instead of ordering them', () => {
|
||||
const url = 'https://www.google.com/maps/dir/?api=1&origin=Berlin&destination=Prague'
|
||||
+ '&waypoints=Dresden%7CLeipzig&travelmode=driving';
|
||||
expect(parseDirectionsUrl(url).map((s) => s.name)).toEqual(['Berlin', 'Dresden', 'Leipzig', 'Prague']);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-016: api=1 coordinates are coordinates, however they were written', () => {
|
||||
const url = 'https://www.google.com/maps/dir/?api=1&origin=52.52,13.405&destination=Prague';
|
||||
expect(parseDirectionsUrl(url)).toEqual([
|
||||
{ name: null, lat: 52.52, lng: 13.405 },
|
||||
{ name: 'Prague', lat: null, lng: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-017: a country domain is still Google Maps', () => {
|
||||
expect(parseDirectionsUrl('https://www.google.de/maps/dir/Berlin/Dresden').map((s) => s.name))
|
||||
.toEqual(['Berlin', 'Dresden']);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-018: one stop is a place, not a route', () => {
|
||||
// The place search box already takes those, and answering with a one-place import
|
||||
// would be a worse version of what it does.
|
||||
expect(parseDirectionsUrl('https://www.google.com/maps/dir/Berlin')).toEqual([]);
|
||||
expect(parseDirectionsUrl('https://www.google.com/maps/dir/')).toEqual([]);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-019: what is not a route reads as nothing', () => {
|
||||
expect(parseDirectionsUrl('https://www.google.com/maps/place/Berlin/@52.5,13.4,12z')).toEqual([]);
|
||||
expect(parseDirectionsUrl('gibberish')).toEqual([]);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-020: a link with more stops than anybody drives is cut to the cap', () => {
|
||||
// The URL is user input, and every name past the cap costs a geocoding request.
|
||||
const many = Array.from({ length: 50 }, (_, i) => `Stop${i}`).join('/');
|
||||
expect(parseDirectionsUrl(`https://www.google.com/maps/dir/${many}`)).toHaveLength(MAX_DIR_WAYPOINTS);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-021: numbers that are not on Earth are read as a name, not a position', () => {
|
||||
const stops = parseDirectionsUrl('https://www.google.com/maps/dir/999.5,13.4/Dresden');
|
||||
expect(stops[0]).toEqual({ name: '999.5,13.4', lat: null, lng: null });
|
||||
});
|
||||
|
||||
it('MAPS-DIR-022: a malformed escape is somebody else\'s bad link, not a throw', () => {
|
||||
expect(() => parseDirectionsUrl('https://www.google.com/maps/dir/%E0%A4%A/Dresden')).not.toThrow();
|
||||
expect(parseDirectionsUrl('https://www.google.com/maps/dir/%E0%A4%A/Dresden')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -190,6 +190,29 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
expect(importGoogleList).toHaveBeenCalledWith('5', 'http://x', { enrich: true, userId: 1 });
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('sends a directions link to the directions importer, not to the list one', async () => {
|
||||
// Same box, same gesture: somebody pressed Share in Google Maps, and which screen
|
||||
// they were on is the URL's business rather than the traveller's.
|
||||
const importGoogleDirections = vi.fn().mockResolvedValue({ places: [{ id: 9 }], listName: 'Berlin → Prague', skipped: 1 });
|
||||
const importGoogleList = vi.fn();
|
||||
const s = svc({ importGoogleDirections, importGoogleList, broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
const url = 'https://www.google.com/maps/dir/Berlin/Dresden/Prague';
|
||||
|
||||
expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).importGoogle(user, '5', { url })).toEqual({
|
||||
places: [{ id: 9 }], count: 1, listName: 'Berlin → Prague', skipped: 1,
|
||||
});
|
||||
expect(importGoogleDirections).toHaveBeenCalledWith('5', url, { enrich: false, userId: 1 });
|
||||
expect(importGoogleList).not.toHaveBeenCalled();
|
||||
});
|
||||
it('leaves a list link with the list importer', async () => {
|
||||
const importGoogleDirections = vi.fn();
|
||||
const importGoogleList = vi.fn().mockResolvedValue({ places: [], listName: 'L', skipped: 0 });
|
||||
const s = svc({ importGoogleDirections, importGoogleList, broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
|
||||
await new PlacesController(s, new RuntimeEnvService(), storageStub).importGoogle(user, '5', { url: 'https://maps.app.goo.gl/abc' });
|
||||
expect(importGoogleList).toHaveBeenCalled();
|
||||
expect(importGoogleDirections).not.toHaveBeenCalled();
|
||||
});
|
||||
it('wraps a thrown Error in the provider-specific 400 (Google)', async () => {
|
||||
const s = svc({ importGoogleList: vi.fn().mockRejectedValue(new Error('network down')) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importGoogle(user, '5', { url: 'http://x' }))).toEqual({
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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.
|
||||
{ searchNominatim, 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-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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user