Files
TREK/client/src/components/Tours/planner/TourAddMenu.test.tsx
T
Maurice 4ca5728072 feat(tours): a city walk from a Google Maps walking route, its short link followed only to Google Maps (CW-06, CW-07)
"Add tour" in the planner now offers "Import walk from Google Maps". The
dialog takes the link of a route shared in Google Maps, either a
maps.app.goo.gl short link or the long google.com/maps/dir link, or the
share text of the Google Maps app with the one link in it. It shows the
stops in their order with their names, marks a stop the link gives no
position as such, says that the route comes from Google Maps with a link to
open it there, and that TREK plans the way between the stops itself and
does not take the line drawn in Google Maps over. Nothing is written until
"Load as walk": that opens a new, unsaved city walk in the planner with the
positioned stops and their names, routed on foot by TREK, and warns first
when it replaces unsaved work. A stop without a position is left out and
never looked up or guessed; with fewer than two positions nothing can be
loaded. While the walk is not saved, a calm notice in the planner says
where it came from, also after a reload of the draft. Cancelling or a
failure leaves every tour as it was.

Only a route the link marks unambiguously as on foot is read. A car, bike
or transit route, a link that does not say, or one that says two things is
refused with a reason the dialog gives in the reader's language. Each stop
takes its position from its own block of the link's data, so a line
dragged through extra points or a stop given as coordinates no longer
leaves the stops without positions or shifts them. The road trip and
places imports read links as before.

A short link is followed by a resolver of its own: every hop is checked
against the Google Maps short link shapes and the strict SSRF guard before
it is looked up or asked, so ALLOW_INTERNAL_NETWORK and
ALLOW_LINK_LOCAL_IPS do not open it. Each request goes to the vetted
addresses only, follows nothing by itself, reads no body and carries no
credentials; the chain has one deadline including name lookups, at most
three hops and a cap on the Location header, and the long link it ends on
must be Google Maps and is never fetched. The preview is POST
/api/trips/:tripId/tours/import/google-maps/preview for members who may
edit places, with a 502 when Google could not be reached, and the MCP tool
preview_google_maps_walk reads the same.
2026-10-20 07:09:57 +00:00

127 lines
5.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import type { TourGpxImport } from '../useTourGpxImport';
import { TourAddMenu } from './TourAddMenu';
const library = vi.hoisted(() => ({ show: vi.fn(), options: null as unknown }));
vi.mock('../library/useTourLibrary', () => ({
useTourLibrary: (options: unknown) => {
library.options = options;
return { show: library.show };
},
}));
vi.mock('../library/TourLibraryDialog', () => ({ TourLibraryDialog: () => <div data-testid="library-dialog" /> }));
const walk = vi.hoisted(() => ({ show: vi.fn(), options: null as unknown }));
vi.mock('../walk/useWalkImport', () => ({
useWalkImport: (options: unknown) => {
walk.options = options;
return { show: walk.show };
},
}));
vi.mock('../walk/WalkImportDialog', () => ({
WalkImportDialog: ({ hasUnsavedChanges }: { hasUnsavedChanges: boolean }) => (
<div data-testid="walk-dialog" data-unsaved={String(hasUnsavedChanges)} />
),
}));
// FE-TOUR-ADD-001 to FE-TOUR-ADD-006
function planner(mode: 'neutral' | 'new-draft' = 'new-draft', overrides: Record<string, unknown> = {}) {
return {
tripId: 5,
mode: { type: mode },
hasUnsavedChanges: false,
openTour: vi.fn(),
startNewTour: vi.fn(),
startDerivedTour: vi.fn(),
...overrides,
} as unknown as Parameters<typeof TourAddMenu>[0]['planner'];
}
const gpxImport = (importing = false) => ({ pick: vi.fn(), importing }) as unknown as TourGpxImport;
const entries = () => screen.getAllByRole('button').filter((button) => button.closest('[role="group"]'));
const open = () => fireEvent.click(screen.getByTestId('tour-rail-add'));
afterEach(() => vi.clearAllMocks());
describe('TourAddMenu (LIB-02)', () => {
it('FE-TOUR-ADD-001: one button opens the four ways to add a tour, in order', () => {
render(<TourAddMenu planner={planner()} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
const button = screen.getByTestId('tour-rail-add');
expect(button).toHaveAccessibleName('Add tour');
expect(button).toHaveAttribute('aria-expanded', 'false');
open();
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(entries().map((entry) => entry.textContent)).toEqual([
'Plan new tour',
'Import GPX',
'Import walk from Google Maps',
'Take over a tour from another trip',
]);
});
it('FE-TOUR-ADD-002: each entry does its own thing and closes the menu', () => {
const p = planner();
const gpx = gpxImport();
render(<TourAddMenu planner={p} gpxImport={gpx} onTakenOver={vi.fn()} disabled={false} />);
open();
fireEvent.click(screen.getByText('Plan new tour'));
expect(p.startNewTour).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('group')).toBeNull();
open();
fireEvent.click(screen.getByText('Import GPX'));
expect(gpx.pick).toHaveBeenCalledTimes(1);
open();
fireEvent.click(screen.getByText('Take over a tour from another trip'));
expect(library.show).toHaveBeenCalledTimes(1);
});
it('FE-TOUR-ADD-003: with no tour open the map is already planning one, so that entry is left out', () => {
render(<TourAddMenu planner={planner('neutral')} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
open();
expect(entries().map((entry) => entry.textContent)).toEqual([
'Import GPX',
'Import walk from Google Maps',
'Take over a tour from another trip',
]);
});
it('FE-TOUR-ADD-004: a tour taken over comes in, and opens unless the editor holds unsaved work', async () => {
const onTakenOver = vi.fn().mockResolvedValue(undefined);
const p = planner();
const { rerender } = render(<TourAddMenu planner={p} onTakenOver={onTakenOver} disabled={false} />);
const take = (library.options as { onTakenOver: (tour: unknown) => Promise<void> }).onTakenOver;
await take({ place_id: 9 });
expect(onTakenOver).toHaveBeenCalledWith({ place_id: 9 });
expect(p.openTour).toHaveBeenCalledWith({ place_id: 9 });
const busy = planner('new-draft', { hasUnsavedChanges: true });
rerender(<TourAddMenu planner={busy} onTakenOver={onTakenOver} disabled={false} />);
await (library.options as { onTakenOver: (tour: unknown) => Promise<void> }).onTakenOver({ place_id: 10 });
expect(busy.openTour).not.toHaveBeenCalled();
expect(screen.getByTestId('library-dialog')).toBeInTheDocument();
});
it('FE-TOUR-ADD-005: waits while saving or importing, and offers a walk from Google Maps with nothing else to offer', () => {
const { rerender } = render(<TourAddMenu planner={planner()} gpxImport={gpxImport(true)} disabled={false} />);
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
rerender(<TourAddMenu planner={planner()} gpxImport={gpxImport()} disabled />);
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
rerender(<TourAddMenu planner={planner('neutral')} disabled={false} />);
open();
expect(entries().map((entry) => entry.textContent)).toEqual(['Import walk from Google Maps']);
expect(screen.queryByTestId('library-dialog')).toBeNull();
});
it('FE-TOUR-ADD-006: the walk entry opens its dialog, which knows about unsaved work in the planner (CW-07)', () => {
const p = planner('new-draft', { hasUnsavedChanges: true });
render(<TourAddMenu planner={p} gpxImport={gpxImport()} disabled={false} />);
expect((walk.options as { planner: unknown }).planner).toBe(p);
expect(screen.getByTestId('walk-dialog')).toHaveAttribute('data-unsaved', 'true');
open();
fireEvent.click(screen.getByText('Import walk from Google Maps'));
expect(walk.show).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('group')).toBeNull();
});
});