Files
TREK/server/tests/unit/nest/maps-dir.helpers.test.ts
T
Maurice 6fca238440 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.
2026-09-07 18:36:54 +02:00

112 lines
5.8 KiB
TypeScript

/**
* 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);
});
});