diff --git a/client/src/components/Tours/planner/StationDialog.test.tsx b/client/src/components/Tours/planner/StationDialog.test.tsx
new file mode 100644
index 000000000..a51b617b1
--- /dev/null
+++ b/client/src/components/Tours/planner/StationDialog.test.tsx
@@ -0,0 +1,157 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '../../../../tests/helpers/render';
+import { StationDialog } from './StationDialog';
+import { StationPills } from './stationParts';
+import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
+
+const cathedral: TourPlannerWaypoint = {
+ id: 'p1',
+ lat: 48.1,
+ lng: 11.5,
+ role: 'via',
+ key: 'stop-cathedral',
+ name: 'Cathedral',
+ stayMinutes: 30,
+ importance: 'must',
+ protected: true,
+ openingHours: 'Mo-Su 09:00-18:00',
+};
+
+function open(point: TourPlannerWaypoint, fallbackName?: string) {
+ const onSave = vi.fn();
+ const onClose = vi.fn();
+ render();
+ return { onSave, onClose };
+}
+
+const stayField = () => screen.getByLabelText('Stay in minutes');
+const nameField = () => screen.getByLabelText('Label');
+
+describe('StationDialog (CW-02, CW-03, CW-10)', () => {
+ it('starts from the station as it is: its name, stay, importance and protection', () => {
+ open(cathedral);
+ expect(screen.getByTestId('station-dialog')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Cathedral' })).toBeInTheDocument();
+ expect(nameField()).toHaveValue('Cathedral');
+ expect(stayField()).toHaveValue('30');
+ expect(screen.getByRole('button', { name: 'Must see' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByRole('button', { name: 'Protected' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByText('A change proposed later keeps it in the walk.')).toBeInTheDocument();
+ });
+
+ it('names a station without a name by the row it stands in, or as unnamed', () => {
+ open({ id: 'p2', lat: 48.11, lng: 11.51, role: 'via' }, 'Stop 2');
+ expect(screen.getByRole('heading', { name: 'Stop 2' })).toBeInTheDocument();
+ expect(nameField()).toHaveAttribute('placeholder', 'Stop 2');
+ expect(stayField()).toHaveValue('');
+ expect(screen.getByRole('button', { name: 'Regular' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByRole('button', { name: 'Protected' })).toHaveAttribute('aria-pressed', 'false');
+ fireEvent.change(nameField(), { target: { value: 'Fountain' } });
+ expect(screen.getByRole('heading', { name: 'Fountain' })).toBeInTheDocument();
+ });
+
+ it('calls a station without a name and without a row name an unnamed stop', () => {
+ open({ id: 'p3', lat: 48.12, lng: 11.52, role: 'end' });
+ expect(screen.getByRole('heading', { name: 'Unnamed stop' })).toBeInTheDocument();
+ expect(nameField()).toHaveAttribute('placeholder', 'What is here');
+ });
+
+ it('takes whole minutes up to a day only, and says so', () => {
+ const { onSave } = open(cathedral);
+ for (const typed of ['abc', '2000', '12.5', '-3']) {
+ fireEvent.change(stayField(), { target: { value: typed } });
+ expect(screen.getByText('Enter whole minutes, up to a day.')).toBeInTheDocument();
+ expect(screen.getByTestId('station-save')).toBeDisabled();
+ }
+ fireEvent.submit(screen.getByTestId('station-dialog').closest('form') as HTMLFormElement);
+ expect(onSave).not.toHaveBeenCalled();
+ fireEvent.change(stayField(), { target: { value: '1440' } });
+ expect(screen.queryByText('Enter whole minutes, up to a day.')).not.toBeInTheDocument();
+ expect(screen.getByText('Empty means not known yet; 0 for a point you only pass.')).toBeInTheDocument();
+ expect(screen.getByTestId('station-save')).toBeEnabled();
+ // A submit of the form saves too, and keeps the page where it is.
+ const submitted = fireEvent.submit(screen.getByTestId('station-dialog').closest('form') as HTMLFormElement);
+ expect(submitted).toBe(false);
+ expect(onSave).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ key: 'stop-cathedral', stayMinutes: 1440 })
+ );
+ });
+
+ it('saves the edited station with its key and opening hours, a stay left empty as not known', () => {
+ const { onSave, onClose } = open(cathedral);
+ fireEvent.change(nameField(), { target: { value: ' Old cathedral ' } });
+ fireEvent.change(stayField(), { target: { value: '' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Optional' }));
+ expect(screen.getByText('Can be left out when time runs short.')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Protected' }));
+ fireEvent.click(screen.getByTestId('station-save'));
+ expect(onSave).toHaveBeenCalledExactlyOnceWith({
+ id: 'p1',
+ lat: 48.1,
+ lng: 11.5,
+ role: 'via',
+ key: 'stop-cathedral',
+ openingHours: 'Mo-Su 09:00-18:00',
+ name: 'Old cathedral',
+ importance: 'optional',
+ });
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('saves 0 as a point passed through, and normal importance and a blank name as none', () => {
+ const { onSave } = open(cathedral);
+ fireEvent.change(nameField(), { target: { value: ' ' } });
+ fireEvent.change(stayField(), { target: { value: ' 0 ' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Regular' }));
+ expect(screen.getByText('Visited as the walk goes.')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('station-save'));
+ expect(onSave).toHaveBeenCalledExactlyOnceWith({
+ id: 'p1',
+ lat: 48.1,
+ lng: 11.5,
+ role: 'via',
+ key: 'stop-cathedral',
+ openingHours: 'Mo-Su 09:00-18:00',
+ stayMinutes: 0,
+ protected: true,
+ });
+ });
+
+ it('cancelling closes the dialog and saves nothing', () => {
+ const { onSave, onClose } = open(cathedral);
+ fireEvent.change(nameField(), { target: { value: 'Changed' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(onClose).toHaveBeenCalledOnce();
+ expect(onSave).not.toHaveBeenCalled();
+ });
+});
+
+describe('StationPills (CW-02, CW-03, CW-10)', () => {
+ const pills = (point: Partial) => {
+ render();
+ return screen.getByTestId('station-pills');
+ };
+
+ it('says a stay nobody entered is unknown, and nothing of a normal, unprotected station', () => {
+ const row = pills({});
+ expect(row).toHaveTextContent('Stay unknown');
+ expect(row).not.toHaveTextContent('Must see');
+ expect(row).not.toHaveTextContent('Optional');
+ expect(screen.queryByLabelText('Protected')).not.toBeInTheDocument();
+ });
+
+ it('names a point passed through', () => {
+ expect(pills({ stayMinutes: 0 })).toHaveTextContent('Passing through');
+ });
+
+ it('shows the stay, a must-see and a protection', () => {
+ const row = pills({ stayMinutes: 45, importance: 'must', protected: true });
+ expect(row).toHaveTextContent('45');
+ expect(row).toHaveTextContent('Must see');
+ expect(screen.getByLabelText('Protected')).toBeInTheDocument();
+ });
+
+ it('shows an optional station as optional', () => {
+ expect(pills({ stayMinutes: 90, importance: 'optional' })).toHaveTextContent('Optional');
+ });
+});
diff --git a/client/src/components/Tours/planner/StationDialog.tsx b/client/src/components/Tours/planner/StationDialog.tsx
index aa5714224..4d59dfd66 100644
--- a/client/src/components/Tours/planner/StationDialog.tsx
+++ b/client/src/components/Tours/planner/StationDialog.tsx
@@ -49,7 +49,9 @@ export function StationDialog({
const stayMinutes = parseStay(stay);
const stayInvalid = stayMinutes === undefined;
- const save = () => {
+ // The footer sits outside the dialog's form: its button saves by its own click, and a submit of the form stays here.
+ const save = (event?: { preventDefault: () => void }) => {
+ event?.preventDefault();
if (stayInvalid) return;
onSave(
withStationFields(point, {
@@ -85,7 +87,7 @@ export function StationDialog({
{t('common.cancel')}
-
+ save()} disabled={stayInvalid} data-testid="station-save">
{t('common.save')}
diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx
index 3790eb439..5b4b63692 100644
--- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx
+++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx
@@ -749,7 +749,7 @@ describe('TourPlannerRail', () => {
expect(controller.cancelNewTour).toHaveBeenCalledOnce()
rerender()
- fireEvent.click(screen.getByRole('button', { name: 'Discard changes and start new tour' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Discard and start' }))
expect(controller.startNewTour).toHaveBeenCalledWith(true)
})
diff --git a/client/src/components/Tours/planner/TourTypeFields.test.tsx b/client/src/components/Tours/planner/TourTypeFields.test.tsx
index c4016d0ce..18d0fab71 100644
--- a/client/src/components/Tours/planner/TourTypeFields.test.tsx
+++ b/client/src/components/Tours/planner/TourTypeFields.test.tsx
@@ -44,6 +44,23 @@ describe('TourTypeFields (PAD-01)', () => {
expect(field).toHaveAttribute('aria-expanded', 'false');
});
+ it('CW-01: a city walk is a type of its own, on foot from stop to stop, with no fields of a bike or a boat', () => {
+ const hike = planner({ tourType: 'hike', isPaddle: false });
+ const { unmount } = render();
+ fireEvent.click(screen.getByTestId('tour-type-select'));
+ const option = screen.getByTestId('tour-type-option-walk');
+ expect(option).toHaveTextContent('City / sightseeing walk');
+ expect(option).toHaveTextContent('On foot from stop to stop, without a hiking limit');
+ fireEvent.click(option);
+ expect(hike.setTourType).toHaveBeenCalledWith('walk');
+ unmount();
+
+ render();
+ expect(screen.getByTestId('tour-type-select')).toHaveTextContent('City / sightseeing walk');
+ expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('tour-planner-bike-fields')).not.toBeInTheDocument();
+ });
+
it('BIK-01 to BIK-04: a bike tour names its profile like a boat, with what the profile asks for', () => {
const bike = planner({ tourType: 'bike', isPaddle: false, isBike: true, bikeProfile: 'gravel' });
render();
diff --git a/client/src/components/Tours/planner/TourWaypointList.test.tsx b/client/src/components/Tours/planner/TourWaypointList.test.tsx
index 0be1bc1e6..fb0e3c36c 100644
--- a/client/src/components/Tours/planner/TourWaypointList.test.tsx
+++ b/client/src/components/Tours/planner/TourWaypointList.test.tsx
@@ -1,4 +1,5 @@
-// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-006: shaping points and the water router's answers in the list (P2A-03, P2A-05, PAD-11).
+// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-007: shaping points, the water router's answers
+// and a city walk's stops in the list (P2A-03, P2A-05, PAD-11, CW-01).
import userEvent from '@testing-library/user-event';
import type { PaddleRouteLeg } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -171,4 +172,48 @@ describe('TourWaypointList', () => {
await userEvent.click(screen.getByTestId('tour-planner-route-on-water'));
expect(controller.routeDrawnLegsOnWater).toHaveBeenCalled();
});
+
+ it('FE-TOUR-WAYPOINT-LIST-007: a city walk names its stops, shows their stay and edits one stop from its menu', async () => {
+ const cathedral = {
+ ...STOP,
+ key: 'stop-cathedral',
+ name: 'Cathedral',
+ stayMinutes: 30,
+ importance: 'must' as const,
+ };
+ const controller = planner({
+ tourType: 'walk',
+ waypoints: [START, SHAPE, cathedral, END],
+ selectedWaypointId: 'b',
+ });
+ render();
+ const rows = screen.getAllByTestId('tour-planner-waypoint-row');
+ expect(within(at(rows, 2)).getByTestId('tour-planner-waypoint-name')).toHaveTextContent('Cathedral');
+ expect(within(at(rows, 2)).getByTestId('station-pills')).toHaveTextContent('Must see');
+ expect(within(at(rows, 0)).getByTestId('station-pills')).toHaveTextContent('Stay unknown');
+ expect(within(at(rows, 1)).queryByTestId('station-pills')).not.toBeInTheDocument();
+
+ await userEvent.click(within(at(rows, 2)).getByRole('button', { name: 'More actions' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Edit stop' }));
+ const stay = screen.getByLabelText('Stay in minutes');
+ await userEvent.clear(stay);
+ await userEvent.type(stay, '45');
+ await userEvent.click(screen.getByTestId('station-save'));
+ expect(screen.queryByTestId('station-dialog')).not.toBeInTheDocument();
+ expect(controller.editWaypoints).toHaveBeenCalledTimes(1);
+ const update = vi.mocked(controller.editWaypoints).mock.calls[0]?.[0] as (
+ points: TourPlannerController['waypoints']
+ ) => TourPlannerController['waypoints'];
+ expect(update([START, SHAPE, cathedral, END])).toEqual([START, SHAPE, { ...cathedral, stayMinutes: 45 }, END]);
+ });
+
+ it('FE-TOUR-WAYPOINT-LIST-007b: a hike has no stop pills and no stop to edit', async () => {
+ const controller = planner({ selectedWaypointId: 'b' });
+ render();
+ expect(screen.queryByTestId('station-pills')).not.toBeInTheDocument();
+ await userEvent.click(
+ within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 2)).getByRole('button', { name: 'More actions' })
+ );
+ expect(screen.queryByRole('button', { name: 'Edit stop' })).not.toBeInTheDocument();
+ });
});
diff --git a/client/src/components/Tours/planner/tourPlannerParts.test.ts b/client/src/components/Tours/planner/tourPlannerParts.test.ts
index 6b72976a7..2eb8c2c0d 100644
--- a/client/src/components/Tours/planner/tourPlannerParts.test.ts
+++ b/client/src/components/Tours/planner/tourPlannerParts.test.ts
@@ -88,6 +88,34 @@ describe('tourPlannerFacts', () => {
expect(unknown.walkingDurationMinutes).toBeNull();
expect(unknown.savedDurationSeconds).toBeNull();
});
+
+ it('CW-02: times a city walk by the router on foot, its stays on top in place of breaks, and saves no hiking limit', () => {
+ const analysis = { distanceKm: 4, gain: 300, loss: 300 } as TourPlannerFactsInput['routeAnalysis'];
+ const walk = hike({
+ tourType: 'walk',
+ routeAnalysis: analysis,
+ durationSeconds: 3_000,
+ breakAdditionalMinutes: 40,
+ stationStayMinutes: 75,
+ hikingPreferences: { avoid_steps: true },
+ });
+ const facts = tourPlannerFacts(walk);
+ expect(facts.walkingDurationMinutes).toBe(50);
+ expect(facts.automaticPlannedTotalMinutes).toBe(125);
+ expect(facts.plannedTotalMinutes).toBe(125);
+ expect(facts.savedDurationSeconds).toBe(3_000);
+ expect(facts.typeFields).toMatchObject({
+ tour_type: 'walk',
+ max_hiking_difficulty: null,
+ hiking_preferences: null,
+ bike_profile: null,
+ boat_type: null,
+ });
+ // Stays nobody entered count for nothing; a total typed by hand still wins.
+ expect(tourPlannerFacts({ ...walk, stationStayMinutes: null }).automaticPlannedTotalMinutes).toBe(50);
+ expect(tourPlannerFacts({ ...walk, plannedDurationMinutes: 180 }).plannedTotalMinutes).toBe(180);
+ expect(tourPlannerFacts({ ...walk, durationSeconds: null }).automaticPlannedTotalMinutes).toBeNull();
+ });
});
describe('usePlannerHistory', () => {
diff --git a/client/src/components/Tours/planner/tourPlannerWaypoints.test.ts b/client/src/components/Tours/planner/tourPlannerWaypoints.test.ts
index 3b7875fc9..d2863dd64 100644
--- a/client/src/components/Tours/planner/tourPlannerWaypoints.test.ts
+++ b/client/src/components/Tours/planner/tourPlannerWaypoints.test.ts
@@ -1,8 +1,12 @@
-// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-005: the editor's waypoint model, shaping points and drawn legs (P2A-03, PAD-11).
+// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-007: the editor's waypoint model, shaping points, drawn legs
+// and a city walk's stations (P2A-03, PAD-11, CW-01 to CW-04).
+import type { TourWaypoint } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
normalizePlannerWaypoints,
+ plannerWaypointsOf,
sameWaypointSnapshot,
+ savedWaypointsOf,
tourWaypointKey,
withDrawnLegsRouted,
withShaping,
@@ -80,4 +84,66 @@ describe('tourPlannerWaypoints', () => {
expect(sameWaypointSnapshot([A, B, C], [A, B, C])).toBe(true);
expect(normalizePlannerWaypoints([C, A, B]).map((point) => point.role)).toEqual(['start', 'via', 'end']);
});
+
+ const SAVED: TourWaypoint[] = [
+ {
+ lat: 48.1,
+ lng: 11.5,
+ role: 'start',
+ sequence: 0,
+ key: 'stop-cathedral',
+ name: 'Cathedral',
+ stay_minutes: 30,
+ importance: 'must',
+ protected: true,
+ opening_hours: 'Mo-Su 09:00-18:00',
+ },
+ { lat: 48.105, lng: 11.505, role: 'via', sequence: 1, key: 'shape-1', shaping: true },
+ { lat: 48.11, lng: 11.51, role: 'end', sequence: 2, key: 'stop-market', stay_minutes: 0 },
+ ];
+ const ids = () => {
+ let next = 0;
+ return () => `p${++next}`;
+ };
+
+ it("FE-TOUR-WAYPOINTS-006: a saved walk's stations come into the editor with their key, name, stay, importance and hours", () => {
+ const points = plannerWaypointsOf(SAVED, ids());
+ expect(points).toEqual([
+ {
+ id: 'p1',
+ lat: 48.1,
+ lng: 11.5,
+ role: 'start',
+ key: 'stop-cathedral',
+ name: 'Cathedral',
+ stayMinutes: 30,
+ importance: 'must',
+ protected: true,
+ openingHours: 'Mo-Su 09:00-18:00',
+ },
+ { id: 'p2', lat: 48.105, lng: 11.505, role: 'via', key: 'shape-1', shaping: true },
+ // A stay of 0 is a point passed through, kept as such.
+ { id: 'p3', lat: 48.11, lng: 11.51, role: 'end', key: 'stop-market', stayMinutes: 0 },
+ ]);
+ expect(savedWaypointsOf(points, 'walk')).toEqual(SAVED);
+ });
+
+ it("FE-TOUR-WAYPOINTS-007: another type sends a station's key, name and hours, but no stay, importance or protection", () => {
+ const points = plannerWaypointsOf(SAVED, ids());
+ const plain = [
+ {
+ lat: 48.1,
+ lng: 11.5,
+ role: 'start',
+ sequence: 0,
+ key: 'stop-cathedral',
+ name: 'Cathedral',
+ opening_hours: 'Mo-Su 09:00-18:00',
+ },
+ { lat: 48.105, lng: 11.505, role: 'via', sequence: 1, key: 'shape-1', shaping: true },
+ { lat: 48.11, lng: 11.51, role: 'end', sequence: 2, key: 'stop-market' },
+ ];
+ expect(savedWaypointsOf(points, 'hike')).toEqual(plain);
+ expect(savedWaypointsOf(points)).toEqual(plain);
+ });
});
diff --git a/client/src/components/Tours/planner/tourRouting.test.ts b/client/src/components/Tours/planner/tourRouting.test.ts
index 8de25e25f..5fc254183 100644
--- a/client/src/components/Tours/planner/tourRouting.test.ts
+++ b/client/src/components/Tours/planner/tourRouting.test.ts
@@ -12,7 +12,7 @@ vi.mock('../../Map/valhallaRoute', () => ({
valhallaTurn,
}))
-import { enrichTourElevations, parseValhallaElevation, routeBikeTour, routeWalkingTour, type TourRouteResult } from './tourRouting'
+import { enrichTourElevations, parseValhallaElevation, routeBikeTour, routeCityWalk, routeWalkingTour, type TourRouteResult } from './tourRouting'
/** A route as the planner reads it, for the cases that expect one. */
const route = async (...args: Parameters) => (await routeWalkingTour(...args)) as TourRouteResult | null
@@ -229,4 +229,10 @@ describe('routing on land, bike profiles and unreachable points (BIK-01)', () =>
await routeWalkingTour([{ lat: 47.39, lng: 11.01, fixedLeg: free }, { lat: 47.392, lng: 11.012 }, { lat: 47.4, lng: 11.02 }])
).toEqual({ unreachable: 1, snappedMeters: 400 })
})
+
+ it('TOUR-ROUTING-033: a city walk goes on foot with no hiking limit and no choice of a hike (CW-01)', async () => {
+ valhallaRun.mockResolvedValueOnce(run([3, 4]))
+ expect(await routeCityWalk(waypoints)).toMatchObject({ distanceMeters: 1500, durationSeconds: 300 })
+ expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, {})
+ })
})
diff --git a/client/src/components/Tours/poi/tourPoiSearch.test.tsx b/client/src/components/Tours/poi/tourPoiSearch.test.tsx
index 0d55c22b5..91398df8b 100644
--- a/client/src/components/Tours/poi/tourPoiSearch.test.tsx
+++ b/client/src/components/Tours/poi/tourPoiSearch.test.tsx
@@ -13,7 +13,7 @@ import { usePoiInsertion, type PoiInsertionPlanner } from './usePoiInsertion';
import { useTourPoiMarkers } from './useTourPoiMarkers';
import { useTourPoiSearch } from './useTourPoiSearch';
-// FE-TOUR-POI-001 to FE-TOUR-POI-010
+// FE-TOUR-POI-001 to FE-TOUR-POI-015
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
@@ -246,3 +246,107 @@ describe('TourPoiSearchCard', () => {
expect(result.current.outcome).toEqual({ kind: 'measuring', name: 'Drinking water' });
});
});
+
+describe('a city walk along its places (CW-05)', () => {
+ const CAFE: TourPoi = {
+ ...SPRING,
+ id: 'node:3',
+ name: 'Cafe Luitpold',
+ category: 'cafe',
+ kind: 'amenity=cafe',
+ opening_hours: null,
+ };
+ const walkPlanner = () =>
+ ({ ...searchPlanner({ tourType: 'walk' }), ...insertionPlanner() }) as unknown as TourPlannerController;
+ const themeButton = (theme: string) => screen.getByTestId(`tour-poi-theme-${theme}`);
+
+ it('FE-TOUR-POI-011: a walk offers four themes; one sets its categories at once, a change of them leaves it', async () => {
+ const planner = walkPlanner();
+ render();
+ const themes = screen.getByTestId('tour-poi-themes');
+ expect(
+ within(themes)
+ .getAllByRole('button')
+ .map((button) => button.textContent)
+ ).toEqual(['Culture and history', 'Parks and views', 'Food and local life', 'A bit of everything']);
+ // The walk starts with its first three categories, which no theme is.
+ for (const theme of ['culture', 'parks', 'food', 'mixed'])
+ expect(themeButton(theme)).toHaveAttribute('aria-pressed', 'false');
+ expect(themes).toHaveTextContent('Your own pick of categories below; a theme sets them for you.');
+
+ fireEvent.click(themeButton('food'));
+ expect(themeButton('food')).toHaveAttribute('aria-pressed', 'true');
+ expect(themes).toHaveTextContent('Coffee shops, places to eat and markets along the route.');
+ expect(screen.getByTestId('tour-poi-categories')).toHaveTextContent('Coffee shops, Places to eat +1');
+ fireEvent.click(screen.getByTestId('tour-poi-search'));
+ await waitFor(() =>
+ expect(search).toHaveBeenCalledWith(
+ 4,
+ expect.objectContaining({ tour_type: 'walk', categories: ['cafe', 'restaurant', 'market'] }),
+ expect.any(AbortSignal)
+ )
+ );
+
+ fireEvent.click(screen.getByTestId('tour-poi-categories'));
+ fireEvent.click(screen.getByRole('button', { name: 'Markets' }));
+ expect(themeButton('food')).toHaveAttribute('aria-pressed', 'false');
+ expect(themes).toHaveTextContent('Your own pick of categories below; a theme sets them for you.');
+ });
+
+ it('FE-TOUR-POI-012: the category field names two categories, counts the rest, and says when none is chosen', () => {
+ render();
+ const field = screen.getByTestId('tour-poi-categories');
+ expect(field).toHaveTextContent('Sights, Museums and galleries +1');
+ expect(field).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(field);
+ expect(field).toHaveAttribute('aria-expanded', 'true');
+ const list = screen.getByRole('group', { name: 'What to look for' });
+ fireEvent.click(within(list).getByRole('button', { name: 'Parks and gardens' }));
+ expect(field).toHaveTextContent('Sights, Museums and galleries');
+ expect(field).not.toHaveTextContent('+1');
+ fireEvent.click(within(list).getByRole('button', { name: 'Sights' }));
+ fireEvent.click(within(list).getByRole('button', { name: 'Museums and galleries' }));
+ expect(field).toHaveTextContent('No category chosen');
+ expect(screen.getByTestId('tour-poi-search')).toBeDisabled();
+ });
+
+ it('FE-TOUR-POI-013: a place found for a theme says which theme suggested it', async () => {
+ search.mockResolvedValue({ ...ANSWER, pois: [CAFE, SPRING] });
+ render();
+ fireEvent.click(themeButton('food'));
+ fireEvent.click(screen.getByTestId('tour-poi-search'));
+ const cafe = await screen.findByTestId('tour-poi-node:3');
+ fireEvent.click(within(cafe).getByRole('button', { name: /Cafe Luitpold/ }));
+ expect(within(cafe).getByText('Suggested for Food and local life')).toBeInTheDocument();
+ fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
+ expect(screen.queryByText('Suggested for Food and local life')).not.toBeInTheDocument();
+ });
+
+ it('FE-TOUR-POI-014: a hike has no themes', () => {
+ render();
+ expect(screen.queryByTestId('tour-poi-themes')).not.toBeInTheDocument();
+ expect(screen.getByTestId('tour-poi-categories')).toHaveTextContent('Viewpoints, Huts and shelters +1');
+ });
+
+ it('FE-TOUR-POI-015: a place taken in keeps its name and opening hours, and a place without them adds none', () => {
+ const planner = insertionPlanner();
+ const { result } = renderHook(() => usePoiInsertion(planner), { wrapper: TranslationProvider });
+ act(() => {
+ result.current.insert(SPRING);
+ });
+ act(() => {
+ result.current.insert({ ...CAFE, id: 'node:4', name: '', along_m: 400 });
+ });
+ const added = vi
+ .mocked(planner.editWaypoints)
+ .mock.calls.map(
+ ([update]) =>
+ (update as (points: TourPlannerWaypoint[]) => TourPlannerWaypoint[])(POINTS).find(
+ (point) => !POINTS.includes(point)
+ ) as TourPlannerWaypoint
+ );
+ expect(added[0]).toMatchObject({ name: 'Spring', openingHours: 'Mo-Su 08:00-18:00' });
+ expect(added[1]).not.toHaveProperty('name');
+ expect(added[1]).not.toHaveProperty('openingHours');
+ });
+});
diff --git a/client/tests/helpers/msw/handlers/index.ts b/client/tests/helpers/msw/handlers/index.ts
index 87c80f1a6..fa2157469 100644
--- a/client/tests/helpers/msw/handlers/index.ts
+++ b/client/tests/helpers/msw/handlers/index.ts
@@ -14,6 +14,7 @@ import { reservationsHandlers } from './reservations';
import { filesHandlers } from './files';
import { tagsHandlers } from './tags';
import { dayNotesHandlers } from './dayNotes';
+import { toursHandlers } from './tours';
import { adminHandlers } from './admin';
import { sharedHandlers } from './shared';
import { externalHandlers } from './external';
@@ -35,6 +36,7 @@ export const defaultHandlers = [
...filesHandlers,
...tagsHandlers,
...dayNotesHandlers,
+ ...toursHandlers,
...adminHandlers,
...sharedHandlers,
...externalHandlers,
diff --git a/client/tests/helpers/msw/handlers/tours.ts b/client/tests/helpers/msw/handlers/tours.ts
new file mode 100644
index 000000000..d6c63e11e
--- /dev/null
+++ b/client/tests/helpers/msw/handlers/tours.ts
@@ -0,0 +1,9 @@
+import { tourVisitListResponseSchema } from '@trek/shared';
+import { contractHandler } from '../contract';
+
+export const toursHandlers = [
+ // CW-03: a trip opened with Tours on reads the visits of its city walks; none is checked off unless a test says so.
+ contractHandler('get', '/api/trips/:id/tour-visits', { response: tourVisitListResponseSchema }, () => ({
+ visits: [],
+ })),
+];
diff --git a/server/src/db/repositories/TourStationVisits.repository.ts b/server/src/db/repositories/TourStationVisits.repository.ts
index 6967644d4..d3e806e61 100644
--- a/server/src/db/repositories/TourStationVisits.repository.ts
+++ b/server/src/db/repositories/TourStationVisits.repository.ts
@@ -1,3 +1,4 @@
+import { currentTimestampKysely } from '../dialect/kysely-functions';
import type { TourStationVisits, TTourStationVisitsStatus } from '../entities/TourStationVisits.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
@@ -40,7 +41,10 @@ export class TourStationVisitsRepository extends TrekRepository {
const row = await this.db()
.selectFrom('day_assignments as da')
@@ -49,6 +53,7 @@ export class TourStationVisitsRepository extends TrekRepository {
+ const platform = this.getEntityManager().getPlatform();
await this.db()
.insertInto('tour_station_visits')
.values({ assignment_id, station_key, status })
.onConflict((oc) =>
oc.columns(['assignment_id', 'station_key']).doUpdateSet((eb) => ({
status: eb.ref('excluded.status'),
- updated_at: eb.fn('CURRENT_TIMESTAMP', []),
+ // The clock in the spelling of the live database: CURRENT_TIMESTAMP is no function call.
+ updated_at: currentTimestampKysely(platform),
})),
)
.execute();
diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts
index 539003028..f67b6d0c1 100644
--- a/server/src/db/repositories/Tours.repository.ts
+++ b/server/src/db/repositories/Tours.repository.ts
@@ -800,6 +800,6 @@ function toListRow<
planned: Number(r.planned),
has_waypoints: Number(r.has_waypoints),
has_recorded_track: Number(r.has_recorded_track),
- station_count: Number(r.station_count ?? 0),
+ station_count: Number(r.station_count),
};
}
diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts
index 4fb98ff83..f93532308 100644
--- a/server/src/nest/tours/tours.service.ts
+++ b/server/src/nest/tours/tours.service.ts
@@ -81,10 +81,17 @@ function hikingFields(input: TourCreateRequest): string | null {
/**
* CW-03: every point with its key, kept from the write where it came with one,
- * so the visits of a city walk's stations outlast a reorder or a new point.
+ * so the visits of a city walk's stations outlast a reorder or a new point. A
+ * key sent twice stays with its first point; a later one gets a fresh key, so
+ * checking one station off never marks another.
*/
-function keyedWaypoints(waypoints: TourWaypoint[]): TourWaypoint[] {
- return waypoints.map((point) => (point.key ? point : { ...point, key: randomUUID() }));
+function keyedWaypoints(waypoints: TourWaypoint[]): Array {
+ const taken = new Set();
+ return waypoints.map((point) => {
+ const key = point.key && !taken.has(point.key) ? point.key : randomUUID();
+ taken.add(key);
+ return { ...point, key };
+ });
}
@Injectable()
@@ -590,7 +597,7 @@ export class ToursService {
const waypoints = keyedWaypoints(await this.linkedWaypoints(tid, input.waypoints));
await this.waypointsRepo.insertForPlace(pid, waypoints);
// CW-03: a station that is gone takes its visits with it; a tour that is no city walk keeps none.
- const stations = input.tour_type === 'walk' ? waypoints.flatMap((point) => (point.key ? [point.key] : [])) : [];
+ const stations = input.tour_type === 'walk' ? waypoints.map((point) => point.key) : [];
await this.visitsRepo.dropStationsNotIn(pid, stations);
});
diff --git a/server/tests/e2e/tours-walk.e2e.test.ts b/server/tests/e2e/tours-walk.e2e.test.ts
new file mode 100644
index 000000000..0a552df4e
--- /dev/null
+++ b/server/tests/e2e/tours-walk.e2e.test.ts
@@ -0,0 +1,474 @@
+/**
+ * Tours e2e for city walks (CW-01 to CW-03, CW-10): a walk saved and read with
+ * its stations, the keys of its stations kept through an edit, and the
+ * stations checked off on the days the walk is on, through the real
+ * JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded
+ * temp SQLite db. The permission check is a container spy and the broadcast
+ * goes to a FakeRealtimeService, as in tours.e2e.test.ts.
+ *
+ * Fixtures: user 1 owns trips 5 and 6, user 2 is a member of trip 5 whom the
+ * permission spy denies 'place_edit' and 'day_edit', user 3 has no access to
+ * either trip. Trip 5 has days 3 and 4, trip 6 has day 7.
+ */
+import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
+import { Days } from '../../src/db/entities/Days.entity';
+import { Places } from '../../src/db/entities/Places.entity';
+import { TourStationVisits } from '../../src/db/entities/TourStationVisits.entity';
+import { Tours } from '../../src/db/entities/Tours.entity';
+import { Trips } from '../../src/db/entities/Trips.entity';
+import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
+import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
+import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
+import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
+import { PermissionsService } from '../../src/nest/permissions/permissions.service';
+import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
+import { RealtimeService } from '../../src/nest/realtime/realtime.service';
+import { ToursModule } from '../../src/nest/tours/tours.module';
+import { createSnapshotTestDb } from '../helpers/db-mock';
+import { countRows, deleteRows, insertRows } from '../helpers/factories/rows';
+import { setAddonEnabled } from '../helpers/factories/settings';
+import { addTripMember } from '../helpers/factories/trips';
+import { makeUser } from '../helpers/factories/users';
+import { FakeRealtimeService } from '../helpers/fake-realtime';
+import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
+import { TestUnitOfWorkModule } from '../helpers/test-uow';
+import { sessionCookie } from './harness';
+import { Test } from '@nestjs/testing';
+import { stationKeySchema } from '@trek/shared';
+
+import cookieParser from 'cookie-parser';
+import type { Server } from 'http';
+import request from 'supertest';
+import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
+
+vi.mock('../../src/nest/audit/audit-log.logger', () => ({
+ LOG_LEVEL: 'error',
+ logInfo: vi.fn(),
+ logDebug: vi.fn(),
+ logError: vi.fn(),
+ logWarn: vi.fn(),
+}));
+
+const { journeyHooks } = vi.hoisted(() => ({
+ journeyHooks: {
+ reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
+ onPlaceCreated: vi.fn().mockResolvedValue(undefined),
+ onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
+ onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+const db = createSnapshotTestDb();
+const realtime = new FakeRealtimeService();
+
+let orm: TestOrm;
+let checkPermission: MockInstance;
+
+const OWNER = 1;
+const VIEWER = 2;
+const OUTSIDER = 3;
+
+const GEOMETRY = [
+ [48.1, 11.5, 520],
+ [48.11, 11.51, 525],
+ [48.12, 11.52, 530],
+ [48.13, 11.53, 528],
+];
+/** Three stops and a shaping point: a must-see cathedral, a market passed through, a park of unknown stay. */
+const STATIONS = [
+ {
+ lat: 48.1,
+ lng: 11.5,
+ role: 'start',
+ sequence: 0,
+ name: 'Cathedral',
+ stay_minutes: 30,
+ importance: 'must',
+ protected: true,
+ opening_hours: 'Mo-Su 09:00-18:00',
+ },
+ { lat: 48.105, lng: 11.505, role: 'via', sequence: 1, shaping: true },
+ { lat: 48.11, lng: 11.51, role: 'via', sequence: 2, name: 'Market', stay_minutes: 0 },
+ { lat: 48.13, lng: 11.53, role: 'end', sequence: 3, name: 'Park', importance: 'optional' },
+];
+type Point = Record;
+const walkBody = (overrides: Record = {}) => ({
+ name: 'Old town',
+ tour_type: 'walk',
+ route_geometry: GEOMETRY,
+ waypoints: STATIONS,
+ duration_seconds: 3600,
+ ...overrides,
+});
+/** The points as a route in this order: numbered from 0, the first the start and the last the end. */
+const inOrder = (points: Point[]): Point[] =>
+ points.map((point, sequence) => ({
+ ...point,
+ sequence,
+ role: sequence === 0 ? 'start' : sequence === points.length - 1 ? 'end' : 'via',
+ }));
+
+describe('City walks e2e (real guards + temp SQLite)', () => {
+ let server: Server;
+ let app: Awaited>;
+
+ async function build() {
+ const moduleRef = await Test.createTestingModule({
+ imports: [
+ await TestUnitOfWorkModule.forRoot(db),
+ await createTestMikroOrmModule(db),
+ RealtimeModule,
+ ToursModule,
+ AssignmentsModule,
+ ],
+ })
+ .overrideProvider(RealtimeService)
+ .useValue(realtime)
+ .overrideProvider(JourneyDomainService)
+ .useValue(journeyHooks)
+ .compile();
+ const nest = moduleRef.createNestApplication();
+ nest.use(cookieParser());
+ nest.useGlobalPipes(new ZodValidationPipe());
+ nest.useGlobalFilters(new TrekExceptionFilter());
+ await nest.init();
+ return nest;
+ }
+
+ beforeAll(async () => {
+ orm = await createTestOrm(db);
+ // Pinned ids: the session cookies sign for exactly these users.
+ await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
+ await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
+ await makeUser(orm, {
+ id: 3,
+ username: 'outsider',
+ email: 'outsider@example.test',
+ role: 'user',
+ password_version: 0,
+ });
+ await insertRows(orm, Trips, [
+ { id: 5, user: 1, title: 'City days' },
+ { id: 6, user: 1, title: 'Other' },
+ ]);
+ await addTripMember(orm, 5, 2);
+ await insertRows(orm, Days, [
+ { id: 3, trip: 5, day_number: 1, date: '2026-07-10' },
+ { id: 4, trip: 5, day_number: 2 },
+ { id: 7, trip: 6, day_number: 1 },
+ ]);
+ app = await build();
+ checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
+ server = app.getHttpServer();
+ });
+
+ beforeEach(async () => {
+ checkPermission.mockImplementation(
+ (action: string, _role: string, _owner: number, userId: number) =>
+ !((action === 'place_edit' || action === 'day_edit') && userId === VIEWER),
+ );
+ await setAddonEnabled(orm, 'tours', true);
+ realtime.broadcastMock.mockClear();
+ await deleteRows(orm, DayAssignments);
+ await deleteRows(orm, Places);
+ });
+
+ afterAll(async () => {
+ await app.close();
+ await orm.close();
+ });
+
+ const cookie = (userId = OWNER) => sessionCookie(userId);
+
+ const saveWalk = async (tripId = 5, body: Record = walkBody()) => {
+ const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', cookie()).send(body);
+ expect(res.status).toBe(201);
+ return { placeId: res.body.tour.place_id as number, waypoints: res.body.waypoints as Point[] };
+ };
+
+ const assign = async (placeId: number, dayId = 3, tripId = 5) => {
+ const res = await request(server)
+ .post(`/api/trips/${tripId}/days/${dayId}/assignments`)
+ .set('Cookie', cookie())
+ .send({ place_id: placeId });
+ expect(res.status).toBe(201);
+ return res.body.assignment.id as number;
+ };
+
+ /** A walk of trip 5 on day 3, with the keys its stations were given, the shaping point's left out. */
+ const walkOnDay3 = async () => {
+ const { placeId, waypoints } = await saveWalk();
+ const [cathedral, shaping, market, park] = waypoints.map((point) => String(point.key));
+ return { placeId, waypoints, assignmentId: await assign(placeId), cathedral, shaping, market, park };
+ };
+
+ const mark = (assignmentId: number | string, key: string, body: unknown, userId = OWNER, tripId = 5) =>
+ request(server)
+ .put(`/api/trips/${tripId}/tour-visits/${assignmentId}/${key}`)
+ .set('Cookie', cookie(userId))
+ .set('X-Socket-Id', 'sock-1')
+ .send(body as object);
+
+ const visitsOf = async (tripId = 5, userId = OWNER) => {
+ const res = await request(server).get(`/api/trips/${tripId}/tour-visits`).set('Cookie', cookie(userId));
+ expect(res.status).toBe(200);
+ return res.body.visits as Array<{ assignment_id: number; station_key: string; status: string }>;
+ };
+
+ describe('a walk and its stations (CW-01, CW-02, CW-10)', () => {
+ it('saves a walk with a key on every point and its station fields, and lists how many stations it has', async () => {
+ const { placeId, waypoints } = await saveWalk();
+ for (const point of waypoints) expect(stationKeySchema.safeParse(point.key).success).toBe(true);
+ expect(waypoints.map(({ key: _key, ...point }) => point)).toEqual(STATIONS);
+
+ const list = await request(server).get('/api/trips/5/tours').set('Cookie', cookie(VIEWER));
+ expect(list.status).toBe(200);
+ expect(list.body.tours).toEqual([
+ expect.objectContaining({
+ place_id: placeId,
+ tour_type: 'walk',
+ station_count: 3,
+ max_hiking_difficulty: null,
+ }),
+ ]);
+ const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie(VIEWER));
+ expect(detail.status).toBe(200);
+ expect(detail.body.waypoints).toEqual(waypoints);
+ });
+
+ it("refuses a station's fields on a hike, and a hike's or a climber's on a walk", async () => {
+ const ferrata = { kind: 'ferrata', ref: 'way/1', name: null, geometry: [GEOMETRY[0], GEOMETRY[2]] };
+ const hike = (point: Point) =>
+ walkBody({
+ tour_type: 'hike',
+ waypoints: inOrder([{ lat: 48.1, lng: 11.5 }, point]),
+ });
+ const bodies = [
+ hike({ lat: 48.11, lng: 11.51, stay_minutes: 20 }),
+ hike({ lat: 48.11, lng: 11.51, importance: 'must' }),
+ hike({ lat: 48.11, lng: 11.51, protected: true }),
+ walkBody({
+ waypoints: inOrder([
+ { lat: 48.1, lng: 11.5, fixed_leg: ferrata },
+ { lat: 48.12, lng: 11.52 },
+ ]),
+ }),
+ walkBody({ max_hiking_difficulty: 3 }),
+ walkBody({ hiking_preferences: { avoid_ferries: true } }),
+ walkBody({
+ waypoints: inOrder([
+ { lat: 48.1, lng: 11.5, key: 'a/b' },
+ { lat: 48.12, lng: 11.52 },
+ ]),
+ }),
+ walkBody({
+ waypoints: inOrder([
+ { lat: 48.1, lng: 11.5, stay_minutes: 1441 },
+ { lat: 48.12, lng: 11.52 },
+ ]),
+ }),
+ ];
+ const statuses = await bodies.reduce>(
+ async (done, body) => [
+ ...(await done),
+ (await request(server).post('/api/trips/5/tours').set('Cookie', cookie()).send(body)).status,
+ ],
+ Promise.resolve([]),
+ );
+ expect(statuses).toEqual(bodies.map(() => 400));
+ expect(await countRows(orm, Tours)).toBe(0);
+ });
+
+ it('keeps the keys sent back through an edit and gives a new point a key of its own', async () => {
+ const { placeId, waypoints } = await saveWalk();
+ const [cathedral, , market, park] = waypoints;
+ const fountain = { lat: 48.12, lng: 11.52, name: 'Fountain' };
+ const res = await request(server)
+ .put(`/api/trips/5/tours/${placeId}`)
+ .set('Cookie', cookie())
+ .send(walkBody({ waypoints: inOrder([park, fountain, market, cathedral]), expected_revision: 1 }));
+ expect(res.status).toBe(200);
+ const keys = (res.body.waypoints as Point[]).map((point) => point.key);
+ expect([keys[0], keys[2], keys[3]]).toEqual([park.key, market.key, cathedral.key]);
+ expect(waypoints.map((point) => point.key)).not.toContain(keys[1]);
+ expect(stationKeySchema.safeParse(keys[1]).success).toBe(true);
+ expect(res.body.tour).toMatchObject({ station_count: 4, revision: 2 });
+ });
+ });
+
+ describe('stations checked off on a day (CW-03)', () => {
+ it('marks a station visited, then skipped, then takes it back, telling the trip each time', async () => {
+ const { assignmentId, cathedral } = await walkOnDay3();
+ expect(await visitsOf()).toEqual([]);
+
+ const visited = await mark(assignmentId, cathedral, { status: 'visited' });
+ expect(visited.status).toBe(200);
+ const expected = [{ assignment_id: assignmentId, station_key: cathedral, status: 'visited' }];
+ expect(visited.body).toEqual({ visits: expected });
+ expect(realtime.broadcastMock).toHaveBeenCalledWith(
+ '5',
+ 'tours:visits',
+ { assignmentId, visits: expected },
+ 'sock-1',
+ );
+
+ expect((await mark(assignmentId, cathedral, { status: 'skipped' })).status).toBe(200);
+ expect(await visitsOf(5, VIEWER)).toEqual([
+ { assignment_id: assignmentId, station_key: cathedral, status: 'skipped' },
+ ]);
+
+ const back = await mark(assignmentId, cathedral, { status: 'planned' });
+ expect(back.status).toBe(200);
+ expect(back.body).toEqual({ visits: [] });
+ expect(await visitsOf()).toEqual([]);
+ });
+
+ it('keeps a visit to the day it was made on', async () => {
+ const { placeId, assignmentId, cathedral, park } = await walkOnDay3();
+ const onDay4 = await assign(placeId, 4);
+ expect((await mark(assignmentId, cathedral, { status: 'visited' })).status).toBe(200);
+ expect((await mark(onDay4, park, { status: 'skipped' })).status).toBe(200);
+ expect(await visitsOf()).toEqual([
+ { assignment_id: assignmentId, station_key: cathedral, status: 'visited' },
+ { assignment_id: onDay4, station_key: park, status: 'skipped' },
+ ]);
+ });
+
+ it('a copy of the walk starts with nothing checked off', async () => {
+ const { placeId, assignmentId, cathedral, waypoints } = await walkOnDay3();
+ expect((await mark(assignmentId, cathedral, { status: 'visited' })).status).toBe(200);
+ const copy = await request(server)
+ .post(`/api/trips/5/tours/${placeId}/copy`)
+ .set('Cookie', cookie())
+ .send({ name: 'Old town again' });
+ expect(copy.status).toBe(201);
+ expect((copy.body.waypoints as Point[]).map((point) => point.key)).toEqual(waypoints.map((point) => point.key));
+ const copyOnDay4 = await assign(copy.body.tour.place_id as number, 4);
+ expect((await visitsOf()).map((visit) => visit.assignment_id)).toEqual([assignmentId]);
+ expect((await visitsOf()).map((visit) => visit.assignment_id)).not.toContain(copyOnDay4);
+ });
+
+ it('a station taken out of the walk takes its visits along, and a walk made a hike keeps none', async () => {
+ const { placeId, waypoints, assignmentId, cathedral, market, park } = await walkOnDay3();
+ const reduced = await [cathedral, market, park].reduce>(
+ async (done, key) => [...(await done), (await mark(assignmentId, key, { status: 'visited' })).status],
+ Promise.resolve([]),
+ );
+ expect(reduced).toEqual([200, 200, 200]);
+
+ const withoutMarket = inOrder([waypoints[0], waypoints[3]]);
+ const edit = await request(server)
+ .put(`/api/trips/5/tours/${placeId}`)
+ .set('Cookie', cookie())
+ .send(walkBody({ waypoints: withoutMarket, expected_revision: 1 }));
+ expect(edit.status).toBe(200);
+ expect((await visitsOf()).map((visit) => visit.station_key).sort()).toEqual([cathedral, park].sort());
+
+ const plain = withoutMarket.map(({ stay_minutes: _s, importance: _i, protected: _p, ...point }) => point);
+ const hike = await request(server)
+ .put(`/api/trips/5/tours/${placeId}`)
+ .set('Cookie', cookie())
+ .send(walkBody({ tour_type: 'hike', waypoints: plain, expected_revision: 2 }));
+ expect(hike.status).toBe(200);
+ expect(await visitsOf()).toEqual([]);
+ });
+
+ it('the visits go with the walk when it moves to another day, and go away with its removal from a day', async () => {
+ const { assignmentId, cathedral } = await walkOnDay3();
+ expect((await mark(assignmentId, cathedral, { status: 'visited' })).status).toBe(200);
+
+ const move = await request(server)
+ .put(`/api/trips/5/assignments/${assignmentId}/move`)
+ .set('Cookie', cookie())
+ .send({ new_day_id: 4, order_index: 0 });
+ expect(move.status).toBe(200);
+ expect(await visitsOf()).toEqual([{ assignment_id: assignmentId, station_key: cathedral, status: 'visited' }]);
+
+ const removed = await request(server)
+ .delete(`/api/trips/5/days/4/assignments/${assignmentId}`)
+ .set('Cookie', cookie());
+ expect(removed.status).toBe(200);
+ expect(await visitsOf()).toEqual([]);
+ expect(await countRows(orm, TourStationVisits)).toBe(0);
+ });
+ });
+
+ describe('refusals', () => {
+ it('404 for an assignment of another trip, a walk that is none, and a station the walk does not have', async () => {
+ const { placeId: hikeId } = await saveWalk(
+ 5,
+ walkBody({
+ name: 'Ridge',
+ tour_type: 'hike',
+ waypoints: inOrder([
+ { lat: 48.1, lng: 11.5 },
+ { lat: 48.12, lng: 11.52 },
+ ]),
+ }),
+ );
+ const hikeOnDay4 = await assign(hikeId, 4);
+ const { assignmentId, shaping } = await walkOnDay3();
+ const { placeId: elsewhere, waypoints } = await saveWalk(6);
+ const foreign = await assign(elsewhere, 7, 6);
+ const cases: Array<[number, string, string]> = [
+ [foreign, String(waypoints[0].key), 'Assignment not found'],
+ [999_999, 'stop-cathedral', 'Assignment not found'],
+ [hikeOnDay4, 'stop-cathedral', 'City walk not found'],
+ [assignmentId, 'stop-unknown', 'Station not found'],
+ [assignmentId, shaping, 'Station not found'],
+ ];
+ const answers = await cases.reduce>>(async (done, [id, key]) => {
+ const before = await done;
+ const res = await mark(id, key, { status: 'visited' });
+ return [...before, [res.status, res.body]];
+ }, Promise.resolve([]));
+ expect(answers).toEqual(cases.map(([, , error]) => [404, { error }]));
+ expect(await countRows(orm, TourStationVisits)).toBe(0);
+ expect(realtime.broadcastMock).not.toHaveBeenCalledWith('5', 'tours:visits', expect.anything(), 'sock-1');
+ });
+
+ it('404 for someone outside the trip and while the addon is off; 403 for a member who may not edit days', async () => {
+ const { assignmentId, cathedral } = await walkOnDay3();
+ const outsider = await mark(assignmentId, cathedral, { status: 'visited' }, OUTSIDER);
+ expect([outsider.status, outsider.body]).toEqual([404, { error: 'Trip not found' }]);
+ const outsiderRead = await request(server).get('/api/trips/5/tour-visits').set('Cookie', cookie(OUTSIDER));
+ expect([outsiderRead.status, outsiderRead.body]).toEqual([404, { error: 'Trip not found' }]);
+
+ const viewer = await mark(assignmentId, cathedral, { status: 'visited' }, VIEWER);
+ expect([viewer.status, viewer.body]).toEqual([403, { error: 'No permission' }]);
+ expect(await visitsOf(5, VIEWER)).toEqual([]);
+
+ await setAddonEnabled(orm, 'tours', false);
+ const off = await mark(assignmentId, cathedral, { status: 'visited' });
+ expect([off.status, off.body]).toEqual([404, { error: 'Tours addon is not enabled' }]);
+ const offRead = await request(server).get('/api/trips/5/tour-visits').set('Cookie', cookie());
+ expect([offRead.status, offRead.body]).toEqual([404, { error: 'Tours addon is not enabled' }]);
+ expect(await countRows(orm, TourStationVisits)).toBe(0);
+ });
+
+ it('400 for a key that is no station key, an assignment id that is no number and a body it does not know', async () => {
+ const { assignmentId, cathedral } = await walkOnDay3();
+ const badKey = await mark(assignmentId, 'ab', { status: 'visited' });
+ expect([badKey.status, badKey.body]).toEqual([400, { error: 'Invalid station key' }]);
+ const refused: Array<[number | string, unknown]> = [
+ ['abc', { status: 'visited' }],
+ [assignmentId, { status: 'done' }],
+ [assignmentId, { status: 'visited', note: 'late' }],
+ [assignmentId, {}],
+ ];
+ const statuses = await refused.reduce>(
+ async (done, [id, body]) => [...(await done), (await mark(id, cathedral, body)).status],
+ Promise.resolve([]),
+ );
+ expect(statuses).toEqual([400, 400, 400, 400]);
+ expect(await countRows(orm, TourStationVisits)).toBe(0);
+ });
+
+ it('401 without a session', async () => {
+ expect((await request(server).get('/api/trips/5/tour-visits')).status).toBe(401);
+ expect(
+ (await request(server).put('/api/trips/5/tour-visits/1/stop-cathedral').send({ status: 'visited' })).status,
+ ).toBe(401);
+ });
+ });
+});
diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts
index efcbd6530..2de94ab21 100644
--- a/server/tests/e2e/tours.e2e.test.ts
+++ b/server/tests/e2e/tours.e2e.test.ts
@@ -42,6 +42,7 @@ import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
+import { keyed } from '../helpers/tour-keys';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
@@ -337,7 +338,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect(res.body.tour.distance).toBeGreaterThan(0);
expect(res.body.tour.elevation_gain).toBe(100);
expect(res.body.tour.elevation_loss).toBe(50);
- expect(res.body.waypoints).toEqual(tourBody().waypoints);
+ expect(res.body.waypoints).toEqual(keyed(tourBody().waypoints));
expect(await placeColumns(placeId, 'trip_id', 'lat', 'lng', 'transport_mode', 'route_geometry')).toEqual({
trip_id: 5,
@@ -458,7 +459,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
duration: null,
max_hiking_difficulty: 5,
});
- expect(res.body.waypoints).toEqual(waypoints);
+ expect(res.body.waypoints).toEqual(keyed(waypoints));
expect(await placeColumns(placeId, 'name', 'lat', 'lng', 'route_geometry')).toEqual({
name: 'Summit',
lat: 46,
diff --git a/server/tests/helpers/factories/README.md b/server/tests/helpers/factories/README.md
index 7cb484a41..aa794760c 100644
--- a/server/tests/helpers/factories/README.md
+++ b/server/tests/helpers/factories/README.md
@@ -44,7 +44,7 @@ of the entity, the `SELECT *` shape with the `*_id` columns filled in):
| `todos.ts` | `makeTodoItem` |
| `files.ts` | `makeTripFile`, `linkFile` |
| `journeys.ts` | `makeJourney` (owner added as contributor), `makeJourneyEntry`, `addJourneyContributor`, `linkTripToJourney` |
-| `tours.ts` | `makeTour`, `addTourWaypoints` |
+| `tours.ts` | `makeTour`, `addTourWaypoints` (with a city walk's station columns), `markStationVisit` |
| `collections.ts` | `makeCollection`, `addCollectionMember`, `makeCollectionPlace`, `tagCollectionPlace`, `labelCollectionPlace` |
| `notifications.ts` | `makeNotification`, `setNotificationChannels`, `disableNotificationPref` |
| `settings.ts` | `setAppSetting`, `readAppSetting`, `setUserSetting`, `readUserSetting`, `setAddonEnabled`, `setCollabFeature` |
diff --git a/server/tests/helpers/factories/tours.ts b/server/tests/helpers/factories/tours.ts
index 1bec9b252..34454f2fd 100644
--- a/server/tests/helpers/factories/tours.ts
+++ b/server/tests/helpers/factories/tours.ts
@@ -1,4 +1,5 @@
-import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
+import { TourStationVisits, type TTourStationVisitsStatus } from '../../../src/db/entities/TourStationVisits.entity';
+import { TourWaypoints, type TTourWaypointsImportance } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import type { FactoryOrm } from './context';
import { findRow, insertRow, insertRows } from './rows';
@@ -19,25 +20,52 @@ export async function makeTour(orm: FactoryOrm, placeId: number, overrides: Enti
type WaypointRole = 'start' | 'via' | 'end';
+/** A city walk station's columns (CW-01 to CW-04), a shaping point's flag and the place a point stands for. */
+interface StationColumns {
+ station_key?: string | null;
+ name?: string | null;
+ stay_minutes?: number | null;
+ importance?: TTourWaypointsImportance | null;
+ /** 1 for a protected station. */
+ protected?: number;
+ opening_hours?: string | null;
+ /** 1 for a shaping point. */
+ shaping?: number;
+ /** The id of the place of the trip the point stands for. */
+ linkedPlace?: number | null;
+}
+
/**
* The tour's waypoints, in the order given and numbered from 0. Without a
- * role the first is the start, the last the end and the rest are vias.
+ * role the first is the start, the last the end and the rest are vias. A
+ * point may carry a station's columns; those it leaves out take their defaults.
*/
export async function addTourWaypoints(
orm: FactoryOrm,
placeId: number,
- points: Array<{ lat: number; lng: number; role?: WaypointRole }>,
+ points: Array<{ lat: number; lng: number; role?: WaypointRole } & StationColumns>,
): Promise {
const roleAt = (i: number): WaypointRole => (i === 0 ? 'start' : i === points.length - 1 ? 'end' : 'via');
await insertRows(
orm,
TourWaypoints,
- points.map((p, sequence) => ({
+ points.map(({ lat, lng, role, ...station }, sequence) => ({
+ ...station,
place: placeId,
- lat: p.lat,
- lng: p.lng,
- role: p.role ?? roleAt(sequence),
+ lat,
+ lng,
+ role: role ?? roleAt(sequence),
sequence,
})),
);
}
+
+/** CW-03: a station of a city walk visited or skipped on the day the assignment puts the walk on. */
+export async function markStationVisit(
+ orm: FactoryOrm,
+ assignmentId: number,
+ stationKey: string,
+ status: TTourStationVisitsStatus,
+): Promise {
+ await insertRow(orm, TourStationVisits, { assignment: assignmentId, station_key: stationKey, status });
+}
diff --git a/server/tests/helpers/tour-keys.ts b/server/tests/helpers/tour-keys.ts
new file mode 100644
index 000000000..d8d02eade
--- /dev/null
+++ b/server/tests/helpers/tour-keys.ts
@@ -0,0 +1,13 @@
+import { expect } from 'vitest';
+
+/** The key a point was sent with, if any. */
+const sentKey = (point: object): unknown => ('key' in point ? point.key : undefined);
+
+/**
+ * CW-03: the points of a save as the server keeps them. Every point carries a
+ * station key: the one it was sent with, or a fresh one in the shape of
+ * `stationKeySchema` where it came without.
+ */
+export function keyed(points: readonly T[]): Array {
+ return points.map((point) => ({ ...point, key: sentKey(point) ?? expect.stringMatching(/^[A-Za-z0-9_-]{6,64}$/) }));
+}
diff --git a/server/tests/unit/db/entities-index.test.ts b/server/tests/unit/db/entities-index.test.ts
index 9d08dd7b9..5c152e4ba 100644
--- a/server/tests/unit/db/entities-index.test.ts
+++ b/server/tests/unit/db/entities-index.test.ts
@@ -39,14 +39,15 @@ describe('ALL_ENTITIES', () => {
* + ferrata_tiles, the stored via ferrata grid cells
* + paddle_regions, paddle_features and tour_obstacle_reviews, the paddle obstacle data and decisions
* + trip_journal_links and trip_journal_link_members, the journal a trip shows and who it is open to
- * + paddle_water_graphs and paddle_region_requests, a region's water network and the regions that wait for an admin)
+ * + paddle_water_graphs and paddle_region_requests, a region's water network and the regions that wait for an admin
+ * + tour_station_visits, how far a city walk got on one of its days)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
- it('ENT-003: exactly 142 entities, every class name unique', () => {
- expect(ALL_ENTITIES.length).toBe(142);
+ it('ENT-003: exactly 143 entities, every class name unique', () => {
+ expect(ALL_ENTITIES.length).toBe(143);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
diff --git a/server/tests/unit/db/entity-class-fields.test.ts b/server/tests/unit/db/entity-class-fields.test.ts
index 2ac6aa9e6..ecec0a7c7 100644
--- a/server/tests/unit/db/entity-class-fields.test.ts
+++ b/server/tests/unit/db/entity-class-fields.test.ts
@@ -34,7 +34,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// + paddle_water_graphs: file, rev, bytes, built_at, stats and its bounding box (a network is only ever written
// whole, from one build of its region).
// + paddle_region_requests: region_id, name, reason (a request is only ever filed whole).
-const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 494;
+// + tour_station_visits: assignment_id, station_key, status (a visit is only ever written whole, by the
+// day plan that checks a city walk's station off).
+const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 497;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
diff --git a/server/tests/unit/db/repositories/TourStationVisits.repository.test.ts b/server/tests/unit/db/repositories/TourStationVisits.repository.test.ts
new file mode 100644
index 000000000..7692844ba
--- /dev/null
+++ b/server/tests/unit/db/repositories/TourStationVisits.repository.test.ts
@@ -0,0 +1,156 @@
+/**
+ * `TourStationVisitsRepository` (CW-03): how far a city walk got on its days,
+ * per day assignment and station key. Seeded through the ORM factories over
+ * the migrated snapshot schema.
+ */
+import { DayAssignments } from '../../../../src/db/entities/DayAssignments.entity';
+import { TourStationVisits } from '../../../../src/db/entities/TourStationVisits.entity';
+import type { TourStationVisitsRepository } from '../../../../src/db/repositories/TourStationVisits.repository';
+import { createSnapshotTestDb } from '../../../helpers/db-mock';
+import { makeDayAssignment } from '../../../helpers/factories/itinerary';
+import { makePlace } from '../../../helpers/factories/places';
+import { deleteRows, findRows } from '../../../helpers/factories/rows';
+import { makeTour, markStationVisit } from '../../../helpers/factories/tours';
+import { makeDay, makeTrip } from '../../../helpers/factories/trips';
+import { makeUser } from '../../../helpers/factories/users';
+import { resetTestDb } from '../../../helpers/test-db';
+import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
+
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+const testDb = createSnapshotTestDb();
+let t: TestOrm;
+let repo: TourStationVisitsRepository;
+
+beforeAll(async () => {
+ t = await createTestOrm(testDb);
+ repo = t.repo(TourStationVisits);
+});
+beforeEach(() => {
+ resetTestDb(testDb);
+ t.clear();
+});
+afterAll(async () => {
+ await t.close();
+ testDb.close();
+});
+
+/** Trip A with a walk on days 1 and 2 and a plain place on day 1; trip B with a walk of its own on its day. */
+async function fixture() {
+ const { user } = await makeUser(t);
+ const trip = await makeTrip(t, user.id);
+ const day1 = await makeDay(t, trip.id, { day_number: 1 });
+ const day2 = await makeDay(t, trip.id, { day_number: 2 });
+ const walk = await makePlace(t, trip.id, { name: 'Old town' });
+ await makeTour(t, walk.id, { tourTypeRef: 'walk' });
+ const cafe = await makePlace(t, trip.id, { name: 'Cafe' });
+ const onDay1 = await makeDayAssignment(t, day1.id, walk.id);
+ const onDay2 = await makeDayAssignment(t, day2.id, walk.id);
+ await makeDayAssignment(t, day1.id, cafe.id);
+
+ const other = await makeTrip(t, user.id);
+ const otherDay = await makeDay(t, other.id, { day_number: 1 });
+ const otherWalk = await makePlace(t, other.id, { name: 'Harbour' });
+ await makeTour(t, otherWalk.id, { tourTypeRef: 'walk' });
+ const foreign = await makeDayAssignment(t, otherDay.id, otherWalk.id);
+ return { trip, day1, day2, walk, cafe, onDay1, onDay2, other, otherDay, otherWalk, foreign };
+}
+
+describe('TourStationVisitsRepository', () => {
+ it('TSVREPO-001: assignmentInTrip names the place and day of an assignment of the trip, nothing across trips', async () => {
+ const { trip, day1, walk, onDay1, foreign } = await fixture();
+ expect(await repo.assignmentInTrip(onDay1.id, trip.id)).toEqual({ place_id: walk.id, day_id: day1.id });
+ expect(await repo.assignmentInTrip(foreign.id, trip.id)).toBeUndefined();
+ expect(await repo.assignmentInTrip(999_999, trip.id)).toBeUndefined();
+ });
+
+ it('TSVREPO-002: assignmentOnDay finds the walk on that day of that trip, and the first of two on one day', async () => {
+ const { trip, day1, day2, walk, cafe, onDay1, onDay2, other, otherDay } = await fixture();
+ expect(await repo.assignmentOnDay(trip.id, walk.id, day1.id)).toBe(onDay1.id);
+ expect(await repo.assignmentOnDay(trip.id, walk.id, day2.id)).toBe(onDay2.id);
+ expect(await repo.assignmentOnDay(other.id, walk.id, day1.id)).toBeUndefined();
+ expect(await repo.assignmentOnDay(trip.id, walk.id, otherDay.id)).toBeUndefined();
+ expect(await repo.assignmentOnDay(trip.id, cafe.id, day2.id)).toBeUndefined();
+
+ // A second assignment on day 2 placed first in the day keeps the first one found by id.
+ await makeDayAssignment(t, day2.id, walk.id, { order_index: -1 });
+ expect(await repo.assignmentOnDay(trip.id, walk.id, day2.id)).toBe(onDay2.id);
+ });
+
+ it('TSVREPO-003: set marks a station, and a second set replaces its status on the same row', async () => {
+ const { onDay1 } = await fixture();
+ await repo.set(onDay1.id, 'stop-b', 'visited');
+ await repo.set(onDay1.id, 'stop-a', 'skipped');
+ expect(await repo.listForAssignment(onDay1.id)).toEqual([
+ { assignment_id: onDay1.id, station_key: 'stop-a', status: 'skipped' },
+ { assignment_id: onDay1.id, station_key: 'stop-b', status: 'visited' },
+ ]);
+
+ await repo.set(onDay1.id, 'stop-a', 'visited');
+ const rows = await findRows(t, TourStationVisits, { assignment: onDay1.id }, { station_key: 'asc' });
+ expect(rows.map((row) => [row.station_key, row.status])).toEqual([
+ ['stop-a', 'visited'],
+ ['stop-b', 'visited'],
+ ]);
+ expect(rows[0]?.updated_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
+ });
+
+ it('TSVREPO-004: clear takes back only that station on that day', async () => {
+ const { onDay1, onDay2 } = await fixture();
+ await markStationVisit(t, onDay1.id, 'stop-a', 'visited');
+ await markStationVisit(t, onDay1.id, 'stop-b', 'skipped');
+ await markStationVisit(t, onDay2.id, 'stop-a', 'visited');
+
+ await repo.clear(onDay1.id, 'stop-a');
+ expect(await repo.listForAssignment(onDay1.id)).toEqual([
+ { assignment_id: onDay1.id, station_key: 'stop-b', status: 'skipped' },
+ ]);
+ expect(await repo.listForAssignment(onDay2.id)).toEqual([
+ { assignment_id: onDay2.id, station_key: 'stop-a', status: 'visited' },
+ ]);
+ });
+
+ it('TSVREPO-005: listForTrip lists every visit of the trip by assignment and key, none of another trip', async () => {
+ const { trip, onDay1, onDay2, foreign } = await fixture();
+ await markStationVisit(t, onDay2.id, 'stop-a', 'skipped');
+ await markStationVisit(t, onDay1.id, 'stop-b', 'visited');
+ await markStationVisit(t, onDay1.id, 'stop-a', 'visited');
+ await markStationVisit(t, foreign.id, 'stop-a', 'visited');
+
+ expect(await repo.listForTrip(trip.id)).toEqual([
+ { assignment_id: onDay1.id, station_key: 'stop-a', status: 'visited' },
+ { assignment_id: onDay1.id, station_key: 'stop-b', status: 'visited' },
+ { assignment_id: onDay2.id, station_key: 'stop-a', status: 'skipped' },
+ ]);
+ });
+
+ it('TSVREPO-006: dropStationsNotIn keeps the named stations on every day, drops the rest, and leaves other walks alone', async () => {
+ const { walk, onDay1, onDay2, foreign } = await fixture();
+ await markStationVisit(t, onDay1.id, 'stop-a', 'visited');
+ await markStationVisit(t, onDay1.id, 'stop-b', 'visited');
+ await markStationVisit(t, onDay2.id, 'stop-a', 'skipped');
+ await markStationVisit(t, onDay2.id, 'stop-b', 'visited');
+ await markStationVisit(t, foreign.id, 'stop-b', 'visited');
+
+ await repo.dropStationsNotIn(walk.id, ['stop-a']);
+ expect((await repo.listForAssignment(onDay1.id)).map((v) => v.station_key)).toEqual(['stop-a']);
+ expect((await repo.listForAssignment(onDay2.id)).map((v) => v.station_key)).toEqual(['stop-a']);
+
+ await repo.dropStationsNotIn(walk.id, []);
+ expect(await repo.listForAssignment(onDay1.id)).toEqual([]);
+ expect(await repo.listForAssignment(onDay2.id)).toEqual([]);
+ expect(await repo.listForAssignment(foreign.id)).toEqual([
+ { assignment_id: foreign.id, station_key: 'stop-b', status: 'visited' },
+ ]);
+ });
+
+ it('TSVREPO-007: a removed day assignment takes its visits with it', async () => {
+ const { onDay1, onDay2 } = await fixture();
+ await markStationVisit(t, onDay1.id, 'stop-a', 'visited');
+ await markStationVisit(t, onDay2.id, 'stop-a', 'visited');
+
+ await deleteRows(t, DayAssignments, { id: onDay1.id });
+ expect(await repo.listForAssignment(onDay1.id)).toEqual([]);
+ expect(await repo.listForAssignment(onDay2.id)).toHaveLength(1);
+ });
+});
diff --git a/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts
index 132b7448d..4ed43c23e 100644
--- a/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts
+++ b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts
@@ -160,4 +160,40 @@ describe('TourWaypointsRepository', () => {
expect(await repo.placeNames(trip.id, [hut.id, elsewhere.id])).toEqual(new Map([[hut.id, 'Hut']]));
expect(await repo.placeNames(trip.id, [])).toEqual(new Map());
});
+
+ it("TWREPO-009: a city walk's station keeps its key, name, stay, importance, protection and hours; a plain point carries none (CW-01 to CW-04)", async () => {
+ const { trip, place } = tourInTrip();
+ const station: TourWaypointRow = {
+ lat: 48.1,
+ lng: 11.5,
+ role: 'start',
+ sequence: 0,
+ key: 'stop-cathedral',
+ name: 'Cathedral',
+ stay_minutes: 30,
+ importance: 'must',
+ protected: true,
+ opening_hours: 'Mo-Su 09:00-18:00',
+ };
+ const passedThrough: TourWaypointRow = {
+ lat: 48.11,
+ lng: 11.51,
+ role: 'via',
+ sequence: 1,
+ key: 'stop-bridge',
+ stay_minutes: 0,
+ importance: 'optional',
+ };
+ const plain: TourWaypointRow = { lat: 48.12, lng: 11.52, role: 'end', sequence: 2 };
+ await repo.insertForPlace(place.id, [station, passedThrough, plain]);
+
+ const points = await repo.listForPlace(place.id);
+ expect(points).toEqual([station, passedThrough, plain]);
+ for (const field of ['key', 'name', 'stay_minutes', 'importance', 'protected', 'opening_hours']) {
+ expect(points[2]).not.toHaveProperty(field);
+ }
+ expect(points[1]).not.toHaveProperty('protected');
+ expect(points[1]).not.toHaveProperty('name');
+ expect(await repo.listForTrip(trip.id)).toEqual(points.map((point) => ({ place_id: place.id, ...point })));
+ });
});
diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts
index 5beac8bce..d7d490f3a 100644
--- a/server/tests/unit/db/repositories/Tours.repository.test.ts
+++ b/server/tests/unit/db/repositories/Tours.repository.test.ts
@@ -11,6 +11,7 @@ import type { ToursRepository } from '../../../../src/db/repositories/Tours.repo
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
import { insertRow, updateRows } from '../../../helpers/factories/rows';
+import { addTourWaypoints, makeTour } from '../../../helpers/factories/tours';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTour } from '../../../helpers/tours-repos';
@@ -90,6 +91,7 @@ describe('ToursRepository reads', () => {
max_hiking_difficulty: 5,
planned: 0,
has_waypoints: 1,
+ station_count: 1,
derived_from_place_id: null,
origin: 'trek',
boat_type: null,
@@ -121,6 +123,7 @@ describe('ToursRepository reads', () => {
max_hiking_difficulty: 2,
planned: 1,
has_waypoints: 0,
+ station_count: 0,
derived_from_place_id: null,
origin: 'trek',
boat_type: null,
@@ -190,6 +193,30 @@ describe('ToursRepository reads', () => {
expect(await repo.isOnDay(day1.id, plain.id)).toBe(false);
expect(await repo.isOnDay(day1.id, tour.id, onDay1.id)).toBe(false);
});
+
+ it('TOURREPO-013: station_count counts the points that are no shaping points, 0 for a tour without any (CW-03)', async () => {
+ const { trip } = fixture();
+ const walk = createPlace(testDb, trip.id, { name: 'Old town' });
+ const bare = createPlace(testDb, trip.id, { name: 'Bare' });
+ await makeTour(t, walk.id, { tourTypeRef: 'walk' });
+ await makeTour(t, bare.id, { tourTypeRef: 'walk' });
+ await addTourWaypoints(t, walk.id, [
+ { lat: 48.1, lng: 11.5, station_key: 'stop-a' },
+ { lat: 48.11, lng: 11.51, shaping: 1 },
+ { lat: 48.12, lng: 11.52, station_key: 'stop-b' },
+ { lat: 48.13, lng: 11.53, station_key: 'stop-c' },
+ ]);
+
+ const counts = new Map((await repo.listForTrip(trip.id)).map((row) => [row.place_id, row.station_count]));
+ expect(counts).toEqual(
+ new Map([
+ [walk.id, 3],
+ [bare.id, 0],
+ ]),
+ );
+ expect((await repo.findInTrip(trip.id, walk.id))?.station_count).toBe(3);
+ expect((await repo.findInTrip(trip.id, bare.id))?.station_count).toBe(0);
+ });
});
describe('ToursRepository writes', () => {
@@ -208,6 +235,7 @@ describe('ToursRepository writes', () => {
wanderer_ref: null,
planned: 0,
has_waypoints: 0,
+ station_count: 0,
derived_from_place_id: null,
origin: 'trek',
boat_type: null,
diff --git a/server/tests/unit/helpers/orm-factories.test.ts b/server/tests/unit/helpers/orm-factories.test.ts
index c41ef8926..f701eff29 100644
--- a/server/tests/unit/helpers/orm-factories.test.ts
+++ b/server/tests/unit/helpers/orm-factories.test.ts
@@ -14,6 +14,7 @@ import { NotificationChannelPreferences } from '../../../src/db/entities/Notific
import { PackingBags } from '../../../src/db/entities/PackingBags.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { Tags } from '../../../src/db/entities/Tags.entity';
+import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
@@ -60,7 +61,7 @@ import {
} from '../../helpers/factories/settings';
import { makeTodoItem } from '../../helpers/factories/todos';
import { makeInviteToken, makeMcpToken, makeOauthClient } from '../../helpers/factories/tokens';
-import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
+import { addTourWaypoints, makeTour, markStationVisit } from '../../helpers/factories/tours';
import {
addTripMember,
datesBetween,
@@ -281,6 +282,23 @@ describe('ORM test factories: journeys, tours, collections and the rest', () =>
]);
const roles = (await findRows(orm, TourWaypoints, { place: place.id }, { sequence: 'asc' })).map((w) => w.role);
expect(roles).toEqual(['start', 'via', 'end']);
+
+ // A city walk's station keeps its columns, and a visit lands on the day assignment (CW-03).
+ const walk = await makePlace(orm, trip.id);
+ await makeTour(orm, walk.id, { tourTypeRef: 'walk' });
+ await addTourWaypoints(orm, walk.id, [
+ { lat: 1, lng: 1, station_key: 'stop-a1', name: 'Cathedral', stay_minutes: 0, importance: 'must', protected: 1 },
+ { lat: 2, lng: 2, shaping: 1 },
+ ]);
+ const [station, shaping] = await findRows(orm, TourWaypoints, { place: walk.id }, { sequence: 'asc' });
+ expect(station).toMatchObject({ station_key: 'stop-a1', name: 'Cathedral', stay_minutes: 0, importance: 'must' });
+ expect([station?.protected, shaping?.shaping, shaping?.station_key]).toEqual([1, 1, null]);
+ const assignment = await makeDayAssignment(orm, (await makeDay(orm, trip.id)).id, walk.id);
+ await markStationVisit(orm, assignment.id, 'stop-a1', 'skipped');
+ expect(await findRow(orm, TourStationVisits, { assignment: assignment.id })).toMatchObject({
+ station_key: 'stop-a1',
+ status: 'skipped',
+ });
});
it('ORMF-032: collections, notifications, settings, plugins and tokens land where the app reads them', async () => {
diff --git a/server/tests/unit/nest/tour-visits.controller.test.ts b/server/tests/unit/nest/tour-visits.controller.test.ts
new file mode 100644
index 000000000..41cc166f4
--- /dev/null
+++ b/server/tests/unit/nest/tour-visits.controller.test.ts
@@ -0,0 +1,71 @@
+/**
+ * TourVisitsController (CW-03): the route metadata of the visits of a city
+ * walk, its delegation to TourVisitsService, and the station key check it
+ * makes before the service is asked.
+ */
+import 'reflect-metadata';
+
+import { AddonGuard } from '../../../src/nest/addons/addon.guard';
+import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
+import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
+import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
+import { TourVisitsController } from '../../../src/nest/tours/tour-visits.controller';
+import type { TourVisitsService } from '../../../src/nest/tours/tour-visits.service';
+import { BadRequestException, RequestMethod } from '@nestjs/common';
+import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
+import type { TourVisitListResponse } from '@trek/shared';
+
+import { describe, expect, it, vi } from 'vitest';
+
+const answer: TourVisitListResponse = {
+ visits: [{ assignment_id: 9, station_key: 'stop-cathedral', status: 'visited' }],
+};
+
+function controllerWith() {
+ const service = { list: vi.fn(async () => answer), update: vi.fn(async () => answer) };
+ return { service, controller: new TourVisitsController(service as unknown as TourVisitsService) };
+}
+
+describe('TourVisitsController', () => {
+ it('TOUR-VISITS-CTL-001: sits behind the addon guard before auth and trip access; only a write needs day_edit', () => {
+ expect(Reflect.getMetadata(PATH_METADATA, TourVisitsController)).toBe('api/trips/:tripId/tour-visits');
+ expect(Reflect.getMetadata(GUARDS_METADATA, TourVisitsController)).toEqual([
+ AddonGuard,
+ JwtAuthGuard,
+ TripAccessGuard,
+ ]);
+ expect(Reflect.getMetadata(REQUIRE_ADDON, TourVisitsController)).toEqual({ addonId: 'tours', label: 'Tours' });
+ const routes = [
+ ['list', RequestMethod.GET, '/', undefined],
+ ['update', RequestMethod.PUT, ':assignmentId/:stationKey', 'day_edit'],
+ ] as const;
+ for (const [handler, method, path, permission] of routes) {
+ const fn = TourVisitsController.prototype[handler];
+ expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method);
+ expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path);
+ expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBe(permission);
+ }
+ });
+
+ it('TOUR-VISITS-CTL-002: hands the trip, assignment, key, body and socket id to the service and answers what it answers', async () => {
+ const { service, controller } = controllerWith();
+ expect(await controller.list('7')).toBe(answer);
+ expect(service.list).toHaveBeenCalledExactlyOnceWith('7');
+
+ expect(await controller.update('7', 9, 'stop-cathedral', { status: 'visited' }, 'sock-1')).toBe(answer);
+ expect(service.update).toHaveBeenCalledExactlyOnceWith('7', 9, 'stop-cathedral', { status: 'visited' }, 'sock-1');
+ });
+
+ it('TOUR-VISITS-CTL-003: a key that is no station key is a 400 before the service is asked', async () => {
+ const { service, controller } = controllerWith();
+ const refusals = ['ab', 'a/b/c/d', 'x'.repeat(65)].map((key) =>
+ controller.update('7', 9, key, { status: 'visited' }).catch((err: unknown) => err),
+ );
+ expect(await Promise.all(refusals)).toEqual([
+ new BadRequestException('Invalid station key'),
+ new BadRequestException('Invalid station key'),
+ new BadRequestException('Invalid station key'),
+ ]);
+ expect(service.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/tests/unit/nest/tour-visits.service.test.ts b/server/tests/unit/nest/tour-visits.service.test.ts
new file mode 100644
index 000000000..2fd9a8b65
--- /dev/null
+++ b/server/tests/unit/nest/tour-visits.service.test.ts
@@ -0,0 +1,291 @@
+/**
+ * TourVisitsService (CW-03, CW-09): the stations of a city walk checked off on
+ * one of its days, and how far the walk got on a day as an assistant reads it.
+ * The service comes out of ToursModule over a snapshot test db; rows are
+ * seeded through the ORM factories and broadcasts go to a FakeRealtimeService.
+ */
+import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
+import { TourVisitsService, type WalkOnDay, type WalkProgress } from '../../../src/nest/tours/tour-visits.service';
+import { ToursModule } from '../../../src/nest/tours/tours.module';
+import { createSnapshotTestDb } from '../../helpers/db-mock';
+import { makeDayAssignment } from '../../helpers/factories/itinerary';
+import { makePlace } from '../../helpers/factories/places';
+import { countRows } from '../../helpers/factories/rows';
+import { addTourWaypoints, makeTour, markStationVisit } from '../../helpers/factories/tours';
+import { makeDay, makeTrip } from '../../helpers/factories/trips';
+import { makeUser } from '../../helpers/factories/users';
+import { resetTestDb } from '../../helpers/test-db';
+import { createTestModule, type TestModule } from '../../helpers/test-module';
+import { MikroORM } from '@mikro-orm/core';
+import { NotFoundException } from '@nestjs/common';
+
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+const db = createSnapshotTestDb();
+let t: TestModule;
+let visits: TourVisitsService;
+const orm = () => t.get(MikroORM);
+
+beforeAll(async () => {
+ t = await createTestModule({ db, imports: [ToursModule] });
+ visits = t.get(TourVisitsService);
+});
+beforeEach(() => {
+ resetTestDb(db);
+ t.realtime.broadcastMock.mockClear();
+});
+afterAll(async () => {
+ await t.close();
+ db.close();
+});
+
+/**
+ * A trip with a city walk on days 1 and 2: a must-see cathedral that stands
+ * for the museum, a shaping point, a market passed through, a point saved
+ * before keys and a park. A hike and a plain cafe are on day 1 too; day 3
+ * holds nothing. Another trip has its own walk on its own day.
+ */
+async function fixture() {
+ const { user } = await makeUser(orm());
+ const trip = await makeTrip(orm(), user.id);
+ const day1 = await makeDay(orm(), trip.id, { day_number: 1 });
+ const day2 = await makeDay(orm(), trip.id, { day_number: 2 });
+ const day3 = await makeDay(orm(), trip.id, { day_number: 3 });
+ const museum = await makePlace(orm(), trip.id, { name: 'Museum' });
+ const walk = await makePlace(orm(), trip.id, { name: 'Old town' });
+ await makeTour(orm(), walk.id, { tourTypeRef: 'walk', duration: 95 });
+ await addTourWaypoints(orm(), walk.id, [
+ {
+ lat: 48.1,
+ lng: 11.5,
+ station_key: 'stop-cathedral',
+ name: 'Cathedral',
+ stay_minutes: 30,
+ importance: 'must',
+ protected: 1,
+ linkedPlace: museum.id,
+ },
+ { lat: 48.105, lng: 11.505, station_key: 'shape-1', shaping: 1 },
+ { lat: 48.11, lng: 11.51, station_key: 'stop-market', stay_minutes: 0, importance: 'optional' },
+ { lat: 48.12, lng: 11.52 },
+ { lat: 48.13, lng: 11.53, station_key: 'stop-park', name: 'Park', stay_minutes: 45 },
+ ]);
+ const hike = await makePlace(orm(), trip.id, { name: 'Ridge' });
+ await makeTour(orm(), hike.id);
+ await addTourWaypoints(orm(), hike.id, [
+ { lat: 47, lng: 11, station_key: 'ridge-a' },
+ { lat: 47.1, lng: 11.1, station_key: 'ridge-b' },
+ ]);
+ const cafe = await makePlace(orm(), trip.id, { name: 'Cafe' });
+ const onDay1 = await makeDayAssignment(orm(), day1.id, walk.id);
+ const onDay2 = await makeDayAssignment(orm(), day2.id, walk.id);
+ const hikeOnDay1 = await makeDayAssignment(orm(), day1.id, hike.id);
+ const cafeOnDay1 = await makeDayAssignment(orm(), day1.id, cafe.id);
+
+ const other = await makeTrip(orm(), user.id);
+ const otherDay = await makeDay(orm(), other.id, { day_number: 1 });
+ const otherWalk = await makePlace(orm(), other.id, { name: 'Harbour' });
+ await makeTour(orm(), otherWalk.id, { tourTypeRef: 'walk' });
+ const foreign = await makeDayAssignment(orm(), otherDay.id, otherWalk.id);
+ return {
+ trip,
+ day1,
+ day2,
+ day3,
+ museum,
+ walk,
+ hike,
+ cafe,
+ onDay1,
+ onDay2,
+ hikeOnDay1,
+ cafeOnDay1,
+ other,
+ otherDay,
+ otherWalk,
+ foreign,
+ };
+}
+
+describe('TourVisitsService', () => {
+ it('TOUR-VISITS-001: list reads every visit on the days of the trip, none of another trip', async () => {
+ const { trip, onDay1, onDay2, foreign } = await fixture();
+ await markStationVisit(orm(), onDay2.id, 'stop-park', 'skipped');
+ await markStationVisit(orm(), onDay1.id, 'stop-cathedral', 'visited');
+ await markStationVisit(orm(), foreign.id, 'stop-park', 'visited');
+
+ expect(await visits.list(String(trip.id))).toEqual({
+ visits: [
+ { assignment_id: onDay1.id, station_key: 'stop-cathedral', status: 'visited' },
+ { assignment_id: onDay2.id, station_key: 'stop-park', status: 'skipped' },
+ ],
+ });
+ });
+
+ it('TOUR-VISITS-002: update marks a station visited, then skipped, then takes it back, and tells the trip each time', async () => {
+ const { trip, onDay1, onDay2 } = await fixture();
+ const tripId = String(trip.id);
+ await markStationVisit(orm(), onDay2.id, 'stop-cathedral', 'visited');
+
+ expect(await visits.update(tripId, onDay1.id, 'stop-cathedral', { status: 'visited' }, 'sock-1')).toEqual({
+ visits: [{ assignment_id: onDay1.id, station_key: 'stop-cathedral', status: 'visited' }],
+ });
+ expect(t.realtime.broadcastMock).toHaveBeenLastCalledWith(
+ tripId,
+ 'tours:visits',
+ {
+ assignmentId: onDay1.id,
+ visits: [{ assignment_id: onDay1.id, station_key: 'stop-cathedral', status: 'visited' }],
+ },
+ 'sock-1',
+ );
+
+ const skipped = await visits.update(tripId, onDay1.id, 'stop-cathedral', { status: 'skipped' });
+ expect(skipped.visits).toEqual([{ assignment_id: onDay1.id, station_key: 'stop-cathedral', status: 'skipped' }]);
+ expect(t.realtime.broadcastMock).toHaveBeenLastCalledWith(
+ tripId,
+ 'tours:visits',
+ { assignmentId: onDay1.id, visits: skipped.visits },
+ undefined,
+ );
+
+ expect(await visits.update(tripId, onDay1.id, 'stop-cathedral', { status: 'planned' }, 'sock-2')).toEqual({
+ visits: [],
+ });
+ expect(t.realtime.broadcastMock).toHaveBeenLastCalledWith(
+ tripId,
+ 'tours:visits',
+ { assignmentId: onDay1.id, visits: [] },
+ 'sock-2',
+ );
+ expect(t.realtime.broadcastMock).toHaveBeenCalledTimes(3);
+ // The same walk on day 2 keeps what was checked off there.
+ expect((await visits.walkOnDay(trip.id, onDay2.id)).visits).toEqual([
+ { assignment_id: onDay2.id, station_key: 'stop-cathedral', status: 'visited' },
+ ]);
+ });
+
+ it('TOUR-VISITS-003: walkOnDay reads the walk on its day without shaping points, and 404s anything else', async () => {
+ const { trip, day1, walk, onDay1, hikeOnDay1, cafeOnDay1, foreign } = await fixture();
+ await markStationVisit(orm(), onDay1.id, 'stop-market', 'skipped');
+
+ const walkOnDay = await visits.walkOnDay(String(trip.id), onDay1.id);
+ expect(walkOnDay).toEqual({
+ assignmentId: onDay1.id,
+ dayId: day1.id,
+ placeId: walk.id,
+ stations: expect.any(Array),
+ visits: [{ assignment_id: onDay1.id, station_key: 'stop-market', status: 'skipped' }],
+ });
+ expect(walkOnDay.stations.map((station) => station.sequence)).toEqual([0, 2, 3, 4]);
+
+ await expect(visits.walkOnDay(trip.id, foreign.id)).rejects.toEqual(new NotFoundException('Assignment not found'));
+ await expect(visits.walkOnDay(trip.id, 999_999)).rejects.toEqual(new NotFoundException('Assignment not found'));
+ await expect(visits.walkOnDay(trip.id, hikeOnDay1.id)).rejects.toEqual(
+ new NotFoundException('City walk not found'),
+ );
+ await expect(visits.walkOnDay(trip.id, cafeOnDay1.id)).rejects.toEqual(
+ new NotFoundException('City walk not found'),
+ );
+ });
+
+ it('TOUR-VISITS-004: a station the walk does not have is a 404 that writes and tells nothing', async () => {
+ const { trip, onDay1, hikeOnDay1 } = await fixture();
+ const tripId = String(trip.id);
+ const refusals = [
+ visits.update(tripId, onDay1.id, 'stop-unknown', { status: 'visited' }),
+ // A shaping point steers the line only; it is no station.
+ visits.update(tripId, onDay1.id, 'shape-1', { status: 'visited' }),
+ visits.update(tripId, hikeOnDay1.id, 'ridge-a', { status: 'visited' }),
+ ];
+ const errors = await Promise.all(refusals.map((refusal) => refusal.catch((err: unknown) => err)));
+ expect(errors).toEqual([
+ new NotFoundException('Station not found'),
+ new NotFoundException('Station not found'),
+ new NotFoundException('City walk not found'),
+ ]);
+ expect(await countRows(orm(), TourStationVisits, {})).toBe(0);
+ expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
+ });
+
+ it('TOUR-VISITS-005: progress lists every station with its status that day and sums what is still open', async () => {
+ const { trip, day1, museum, walk, onDay1, onDay2 } = await fixture();
+ await markStationVisit(orm(), onDay1.id, 'stop-cathedral', 'visited');
+ await markStationVisit(orm(), onDay1.id, 'stop-market', 'skipped');
+ await markStationVisit(orm(), onDay2.id, 'stop-park', 'visited');
+ const before = await countRows(orm(), TourStationVisits, {});
+
+ expect(await visits.progress(trip.id, walk.id, day1.id)).toEqual({
+ place_id: walk.id,
+ day_id: day1.id,
+ assignment_id: onDay1.id,
+ stations: [
+ {
+ position: 1,
+ name: 'Cathedral',
+ lat: 48.1,
+ lng: 11.5,
+ status: 'visited',
+ importance: 'must',
+ protected: true,
+ stay_minutes: 30,
+ linked_place_id: museum.id,
+ },
+ {
+ position: 2,
+ name: null,
+ lat: 48.11,
+ lng: 11.51,
+ status: 'skipped',
+ importance: 'optional',
+ protected: false,
+ stay_minutes: 0,
+ linked_place_id: null,
+ },
+ {
+ position: 3,
+ name: null,
+ lat: 48.12,
+ lng: 11.52,
+ status: 'planned',
+ importance: 'normal',
+ protected: false,
+ stay_minutes: null,
+ linked_place_id: null,
+ },
+ {
+ position: 4,
+ name: 'Park',
+ lat: 48.13,
+ lng: 11.53,
+ status: 'planned',
+ importance: 'normal',
+ protected: false,
+ stay_minutes: 45,
+ linked_place_id: null,
+ },
+ ],
+ open: { stations: 2, stay_minutes_known: 45, stays_unknown: 1 },
+ walking_minutes: 95,
+ });
+ expect(await countRows(orm(), TourStationVisits, {})).toBe(before);
+ });
+
+ it('TOUR-VISITS-006: progress of a walk without stations or walking time, and of a walk not on that day', async () => {
+ const { trip, day3, walk, other, otherDay, otherWalk, foreign } = await fixture();
+ expect(await visits.progress(other.id, otherWalk.id, otherDay.id)).toEqual({
+ place_id: otherWalk.id,
+ day_id: otherDay.id,
+ assignment_id: foreign.id,
+ stations: [],
+ open: { stations: 0, stay_minutes_known: 0, stays_unknown: 0 },
+ walking_minutes: null,
+ });
+ await expect(visits.progress(trip.id, walk.id, day3.id)).rejects.toEqual(
+ new NotFoundException('City walk not on this day'),
+ );
+ await expect(visits.progress(trip.id, otherWalk.id, otherDay.id)).rejects.toEqual(
+ new NotFoundException('City walk not on this day'),
+ );
+ });
+});
diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts
index b49e628af..f491d5323 100644
--- a/server/tests/unit/nest/tours.mcp.test.ts
+++ b/server/tests/unit/nest/tours.mcp.test.ts
@@ -1,12 +1,14 @@
/**
* ToursMcp (P2D-MCP-01): the saved tours of a trip, read only, over the same
- * service the REST list and detail use. The suite takes its subject from the
- * container over a snapshot test db; the addon gate of the tools is the
- * registry's and is covered with the other addon-gated tools.
+ * services the REST list, detail and city walk visits (CW-09) use. The suite
+ * takes its subject from the container over a snapshot test db; the addon gate
+ * of the tools is the registry's and is covered with the other addon-gated tools.
*/
import { Places } from '../../../src/db/entities/Places.entity';
+import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import type { McpContext } from '../../../src/nest-mcp';
+import { TourVisitsService } from '../../../src/nest/tours/tour-visits.service';
import { ToursMcp } from '../../../src/nest/tours/tours.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { ToursService } from '../../../src/nest/tours/tours.service';
@@ -19,7 +21,8 @@ import {
createTrip,
createUser,
} from '../../helpers/factories';
-import { insertRows, updateRows } from '../../helpers/factories/rows';
+import { countRows, insertRows, updateRows } from '../../helpers/factories/rows';
+import { addTourWaypoints, makeTour, markStationVisit } from '../../helpers/factories/tours';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { createTestOrm } from '../../helpers/test-orm';
@@ -173,4 +176,78 @@ describe('ToursMcp', () => {
expect(plan.waypoints[0]).toMatchObject({ linked_place_id: hut.id, linked_place_name: 'Hut' });
expect(plan.waypoints[1].linked_place_name).toBeUndefined();
});
+
+ /** CW-09: a city walk on day 1 with three stops, the first visited and the second skipped that day. */
+ async function walkFixture() {
+ const base = fixture();
+ const walk = createPlace(db, base.trip.id, { name: 'Old town' });
+ const orm = await createTestOrm(db);
+ await makeTour(orm, walk.id, { tourTypeRef: 'walk', duration: 80 });
+ await addTourWaypoints(orm, walk.id, [
+ { lat: 48.1, lng: 11.5, station_key: 'stop-cathedral', name: 'Cathedral', stay_minutes: 30 },
+ { lat: 48.11, lng: 11.51, station_key: 'stop-market', stay_minutes: 20 },
+ { lat: 48.12, lng: 11.52, station_key: 'stop-park', importance: 'must' },
+ ]);
+ const onDay1 = createDayAssignment(db, base.day1.id, walk.id);
+ await markStationVisit(orm, onDay1.id, 'stop-cathedral', 'visited');
+ await markStationVisit(orm, onDay1.id, 'stop-market', 'skipped');
+ await orm.close();
+ return { ...base, walk, onDay1 };
+ }
+
+ it('TOURS-MCP-008: get_city_walk_progress reads the stations of a walk with their status that day, and changes nothing', async () => {
+ const { member, trip, day1, walk, onDay1 } = await walkFixture();
+ const orm = await createTestOrm(db);
+ const before = await countRows(orm, TourStationVisits, {});
+ const progress = read(
+ await mcp.getCityWalkProgress({ tripId: trip.id, placeId: walk.id, dayId: day1.id }, ctx(member.id)),
+ );
+ expect(progress).toMatchObject({ place_id: walk.id, day_id: day1.id, assignment_id: onDay1.id });
+ expect(
+ progress.stations.map((station: { name: string | null; status: string }) => [station.name, station.status]),
+ ).toEqual([
+ ['Cathedral', 'visited'],
+ [null, 'skipped'],
+ [null, 'planned'],
+ ]);
+ expect(progress.open).toEqual({ stations: 1, stay_minutes_known: 0, stays_unknown: 1 });
+ expect(progress.walking_minutes).toBe(80);
+ expect(await countRows(orm, TourStationVisits, {})).toBe(before);
+ await orm.close();
+ });
+
+ it('TOURS-MCP-009: someone outside the trip learns nothing, and a day without the walk names no walk', async () => {
+ const { user, outsider, trip, day1, day2, ridge, walk } = await walkFixture();
+ expect(
+ read(await mcp.getCityWalkProgress({ tripId: trip.id, placeId: walk.id, dayId: day1.id }, ctx(outsider.id))),
+ ).toEqual({ error: 'Trip not found or access denied.' });
+ const cafe = createPlace(db, trip.id, { name: 'Plain' });
+ createDayAssignment(db, day1.id, cafe.id);
+ const notThere = [
+ { placeId: walk.id, dayId: day2.id },
+ { placeId: ridge.id, dayId: day1.id },
+ { placeId: cafe.id, dayId: day1.id },
+ ];
+ const answers = await notThere.reduce>(
+ async (done, args) => [
+ ...(await done),
+ read(await mcp.getCityWalkProgress({ tripId: trip.id, ...args }, ctx(user.id))),
+ ],
+ Promise.resolve([]),
+ );
+ expect(answers).toEqual(notThere.map(() => ({ error: 'No city walk of this trip is on that day.' })));
+ });
+
+ it('TOURS-MCP-010: a failure other than a missing walk is not passed off as no walk', async () => {
+ const { user, trip, day1, walk } = await walkFixture();
+ const failure = new Error('database gone');
+ const progress = vi.spyOn(t.get(TourVisitsService), 'progress').mockRejectedValueOnce(failure);
+ try {
+ await expect(
+ mcp.getCityWalkProgress({ tripId: trip.id, placeId: walk.id, dayId: day1.id }, ctx(user.id)),
+ ).rejects.toBe(failure);
+ } finally {
+ progress.mockRestore();
+ }
+ });
});
diff --git a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts
index 7bdacf3a3..993b92d40 100644
--- a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts
+++ b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts
@@ -27,6 +27,7 @@ import { ToursService } from '../../../src/nest/tours/tours.service';
import type { User } from '../../../src/types';
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { buildPlaceImportService } from '../../helpers/place-import';
+import { keyed } from '../../helpers/tour-keys';
import type { EntityManager } from '@mikro-orm/core';
import { HttpException, NotFoundException, RequestMethod, type ExecutionContext } from '@nestjs/common';
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
@@ -365,7 +366,7 @@ describe('Real ToursService update trip boundary (stubbed repositories)', () =>
expect.objectContaining({ tour_type: 'hike', duration: null }),
);
expect(f.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
- expect(f.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, input.waypoints);
+ expect(f.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(input.waypoints));
expect(f.placesRepo.findWithTagsAndRatings).toHaveBeenCalledWith(42);
expect(f.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket');
expect(f.toursRepo.findProvenance).toHaveBeenCalledExactlyOnceWith(7, 42);
diff --git a/server/tests/unit/nest/tours.service.permissions.test.ts b/server/tests/unit/nest/tours.service.permissions.test.ts
index d5306b8ed..fd90f1cfb 100644
--- a/server/tests/unit/nest/tours.service.permissions.test.ts
+++ b/server/tests/unit/nest/tours.service.permissions.test.ts
@@ -15,6 +15,7 @@ import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { buildPlaceImportService } from '../../helpers/place-import';
+import { keyed } from '../../helpers/tour-keys';
import { NotFoundException } from '@nestjs/common';
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
@@ -121,6 +122,7 @@ function makeService() {
insertForPlace: inTx(undefined),
deleteForPlace: inTx(undefined),
};
+ const visitsRepo = { dropStationsNotIn: inTx(undefined) };
const placesRepo = {
insertTourPlace: inTx(42),
updateTourRoute: inTx(true),
@@ -141,12 +143,12 @@ function makeService() {
toursRepo as unknown as ToursRepository,
{ isEnabled: vi.fn(async () => true) } as unknown as TourTypesRepository,
waypointsRepo as unknown as TourWaypointsRepository,
- { dropStationsNotIn: vi.fn(async () => undefined) } as unknown as TourStationVisitsRepository,
+ visitsRepo as unknown as TourStationVisitsRepository,
placesRepo as unknown as PlacesRepository,
buildPlaceImportService(),
noPaddleChecks(),
);
- return { uow, toursRepo, waypointsRepo, placesRepo, places, place, service };
+ return { uow, toursRepo, waypointsRepo, visitsRepo, placesRepo, places, place, service };
}
describe('ToursService planner contracts (mock-only; SQLite rollback semantics are covered in tours.service.test.ts)', () => {
@@ -193,7 +195,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
bike_profile: null,
});
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
- expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints);
+ expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(request.waypoints));
expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42);
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true });
expect(result.waypoints).toEqual(request.waypoints);
@@ -311,10 +313,15 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
bike_profile: null,
});
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
- expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints);
+ expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(update.waypoints));
expect(setup.waypointsRepo.deleteForPlace.mock.invocationCallOrder[0]).toBeLessThan(
setup.waypointsRepo.insertForPlace.mock.invocationCallOrder[0],
);
+ // CW-03: a hike keeps no visits; they are dropped in the same transaction, after the new points.
+ expect(setup.visitsRepo.dropStationsNotIn).toHaveBeenCalledExactlyOnceWith(42, []);
+ expect(setup.waypointsRepo.insertForPlace.mock.invocationCallOrder[0]).toBeLessThan(
+ setup.visitsRepo.dropStationsNotIn.mock.invocationCallOrder[0],
+ );
expect(setup.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: setup.place }, 'socket-2');
});
diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts
index 80c205bc5..63194c4fc 100644
--- a/server/tests/unit/nest/tours.service.test.ts
+++ b/server/tests/unit/nest/tours.service.test.ts
@@ -19,6 +19,7 @@ import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { buildPlaceImportService } from '../../helpers/place-import';
import { resetTestDb } from '../../helpers/test-db';
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
+import { keyed } from '../../helpers/tour-keys';
import {
createTestToursRepo,
createTestTourStationVisitsRepo,
@@ -179,7 +180,7 @@ describe('ToursService planner creation', () => {
description: 'A quiet ridge above the lake.',
website: 'https://www.komoot.com/tour/42',
});
- expect(result.waypoints).toEqual(request.waypoints);
+ expect(result.waypoints).toEqual(keyed(request.waypoints));
expect(broadcast).toHaveBeenNthCalledWith(1, tripId, 'tours:changed', { placeIds: [placeId] }, 'socket-1');
expect(broadcast).toHaveBeenNthCalledWith(
2,
@@ -386,7 +387,7 @@ describe('ToursService updates', () => {
const result = await service.updateTour(tripId, String(created.tour.place_id), update, 'socket-2');
expect(result.tour).toMatchObject({ place_id: created.tour.place_id, name: 'Updated ridge walk' });
- expect(result.waypoints).toEqual(update.waypoints);
+ expect(result.waypoints).toEqual(keyed(update.waypoints));
const storedPlace = await firstPlace();
expect({
name: storedPlace.name,
@@ -501,7 +502,7 @@ describe('ToursService updates', () => {
).rejects.toThrow();
expect((await firstPlace()).name).toBe('Ridge walk');
- expect((await service.getTour(tripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints);
+ expect((await service.getTour(tripId, String(created.tour.place_id))).waypoints).toEqual(keyed(request.waypoints));
expect(broadcast).not.toHaveBeenCalled();
});
@@ -518,7 +519,9 @@ describe('ToursService updates', () => {
trip_id: Number(otherTripId),
name: 'Ridge walk',
});
- expect((await service.getTour(otherTripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints);
+ expect((await service.getTour(otherTripId, String(created.tour.place_id))).waypoints).toEqual(
+ keyed(request.waypoints),
+ );
expect(broadcast).not.toHaveBeenCalled();
});
@@ -609,7 +612,7 @@ describe('ToursService restoring a recorded track (PR1-05B)', () => {
);
(await sharedTestOrm(testDb)).clear();
await expect(service.restoreRecordedTrack(tripId, placeId)).rejects.toMatchObject(refusal);
- expect((await service.getTour(tripId, placeId)).waypoints).toEqual(request.waypoints);
+ expect((await service.getTour(tripId, placeId)).waypoints).toEqual(keyed(request.waypoints));
});
it('PR1-05B-SVC-004: a plain place, a tour of another trip or a malformed id is a 404', async () => {
diff --git a/server/tests/unit/nest/tours.walk.service.test.ts b/server/tests/unit/nest/tours.walk.service.test.ts
new file mode 100644
index 000000000..81ffe7ab8
--- /dev/null
+++ b/server/tests/unit/nest/tours.walk.service.test.ts
@@ -0,0 +1,216 @@
+/**
+ * ToursService and city walks (CW-01 to CW-03): every point of a save keeps a
+ * key, a walk lists how many stations it has, and the visits of its stations
+ * follow the stations through an edit, never into a copy. The services come
+ * out of ToursModule over a snapshot test db; rows are seeded and read
+ * through the ORM factories.
+ */
+import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
+import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
+import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
+import { TourVisitsService } from '../../../src/nest/tours/tour-visits.service';
+import { ToursModule } from '../../../src/nest/tours/tours.module';
+import { ToursService } from '../../../src/nest/tours/tours.service';
+import { createSnapshotTestDb } from '../../helpers/db-mock';
+import { makeDayAssignment } from '../../helpers/factories/itinerary';
+import { countRows, findRows } from '../../helpers/factories/rows';
+import { markStationVisit } from '../../helpers/factories/tours';
+import { makeDay, makeTrip } from '../../helpers/factories/trips';
+import { makeUser } from '../../helpers/factories/users';
+import { resetTestDb } from '../../helpers/test-db';
+import { createTestModule, type TestModule } from '../../helpers/test-module';
+import { MikroORM } from '@mikro-orm/core';
+import { stationKeySchema, tourCreateRequestSchema, type TourCreateInput, type TourWaypoint } from '@trek/shared';
+
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const db = createSnapshotTestDb();
+let t: TestModule;
+let tours: ToursService;
+let visits: TourVisitsService;
+const orm = () => t.get(MikroORM);
+
+beforeAll(async () => {
+ t = await createTestModule({
+ db,
+ imports: [ToursModule],
+ overrides: [
+ {
+ provide: JourneyDomainService,
+ useValue: { onPlaceCreated: vi.fn(async () => undefined), onPlaceUpdated: vi.fn(async () => undefined) },
+ },
+ ],
+ });
+ tours = t.get(ToursService);
+ visits = t.get(TourVisitsService);
+});
+beforeEach(() => resetTestDb(db));
+afterAll(async () => {
+ await t.close();
+ db.close();
+});
+
+const GEOMETRY: Array<[number, number, number]> = [
+ [48.1, 11.5, 520],
+ [48.11, 11.51, 525],
+ [48.12, 11.52, 530],
+ [48.13, 11.53, 528],
+];
+
+/** Three stops and a shaping point: a cathedral with a key of its own, a market passed through and a park. */
+const STATIONS: TourWaypoint[] = [
+ {
+ lat: 48.1,
+ lng: 11.5,
+ role: 'start',
+ sequence: 0,
+ key: 'stop-cathedral',
+ name: 'Cathedral',
+ stay_minutes: 30,
+ importance: 'must',
+ protected: true,
+ opening_hours: 'Mo-Su 09:00-18:00',
+ },
+ { lat: 48.105, lng: 11.505, role: 'via', sequence: 1, shaping: true },
+ { lat: 48.11, lng: 11.51, role: 'via', sequence: 2, name: 'Market', stay_minutes: 0 },
+ { lat: 48.13, lng: 11.53, role: 'end', sequence: 3, name: 'Park', importance: 'optional' },
+];
+
+const walkBody = (overrides: Partial = {}) =>
+ tourCreateRequestSchema.parse({
+ name: 'Old town',
+ tour_type: 'walk',
+ route_geometry: GEOMETRY,
+ waypoints: STATIONS,
+ duration_seconds: 3600,
+ ...overrides,
+ });
+
+/** A trip with two days, as its owner sees it. */
+async function tripWithDays() {
+ const { user } = await makeUser(orm());
+ const trip = await makeTrip(orm(), user.id);
+ const day1 = await makeDay(orm(), trip.id, { day_number: 1 });
+ const day2 = await makeDay(orm(), trip.id, { day_number: 2 });
+ return { tripId: String(trip.id), day1, day2 };
+}
+
+/** The keys of a tour's points as stored, in route order. */
+const storedKeys = async (placeId: number) =>
+ (await findRows(orm(), TourWaypoints, { place: placeId }, { sequence: 'asc' })).map((point) => point.station_key);
+
+describe('ToursService and city walks', () => {
+ it('TOURS-WALK-001: a walk keeps a key it was sent, gives the other points one, and counts its stations', async () => {
+ const { tripId } = await tripWithDays();
+ const created = await tours.createTour(tripId, walkBody());
+ const placeId = created.tour.place_id;
+
+ const keys = created.waypoints.map((point) => point.key);
+ expect(keys[0]).toBe('stop-cathedral');
+ for (const key of keys) expect(stationKeySchema.safeParse(key).success).toBe(true);
+ expect(new Set(keys).size).toBe(4);
+ expect(await storedKeys(placeId)).toEqual(keys);
+ expect(created.waypoints[0]).toEqual({ ...STATIONS[0] });
+ expect(created.waypoints[2]).toMatchObject({ name: 'Market', stay_minutes: 0 });
+ expect(created.tour).toMatchObject({ tour_type: 'walk', station_count: 3, max_hiking_difficulty: null });
+
+ const hike = await tours.createTour(
+ tripId,
+ walkBody({ name: 'Ridge', tour_type: 'hike', waypoints: inOrder([STATIONS[2], STATIONS[3]].map(plainPoint)) }),
+ );
+ const listed = new Map((await tours.listTours(tripId)).map((item) => [item.place_id, item.station_count]));
+ expect(listed).toEqual(
+ new Map([
+ [placeId, 3],
+ [hike.tour.place_id, null],
+ ]),
+ );
+ });
+
+ it('TOURS-WALK-002: a station taken out of a walk takes its visits on every day; a walk made a hike keeps none', async () => {
+ const { tripId, day1, day2 } = await tripWithDays();
+ const created = await tours.createTour(tripId, walkBody());
+ const placeId = created.tour.place_id;
+ const [cathedral, , market, park] = created.waypoints;
+ const onDay1 = await makeDayAssignment(orm(), day1.id, placeId);
+ const onDay2 = await makeDayAssignment(orm(), day2.id, placeId);
+ await markStationVisit(orm(), onDay1.id, cathedral.key, 'visited');
+ await markStationVisit(orm(), onDay1.id, market.key, 'skipped');
+ await markStationVisit(orm(), onDay2.id, market.key, 'visited');
+ await markStationVisit(orm(), onDay2.id, park.key, 'visited');
+
+ // The market is left out; the others come back with their keys, in another order.
+ const kept = inOrder([park, cathedral]);
+ await tours.updateTour(tripId, String(placeId), walkBody({ waypoints: kept, expected_revision: 1 }));
+ const statusOf = async (assignmentId: number) =>
+ (await visits.walkOnDay(tripId, assignmentId)).visits.map((visit) => [visit.station_key, visit.status]);
+ expect(await statusOf(onDay1.id)).toEqual([[cathedral.key, 'visited']]);
+ expect(await statusOf(onDay2.id)).toEqual([[park.key, 'visited']]);
+ expect(await storedKeys(placeId)).toEqual([park.key, cathedral.key]);
+
+ await tours.updateTour(
+ tripId,
+ String(placeId),
+ walkBody({ tour_type: 'hike', waypoints: kept.map(plainPoint), expected_revision: 2 }),
+ );
+ expect(await countRows(orm(), TourStationVisits, {})).toBe(0);
+ expect(await storedKeys(placeId)).toEqual([park.key, cathedral.key]);
+ });
+
+ it('TOURS-WALK-003: a copy keeps the keys of its stations but none of the visits', async () => {
+ const { tripId, day1 } = await tripWithDays();
+ const created = await tours.createTour(tripId, walkBody());
+ const onDay1 = await makeDayAssignment(orm(), day1.id, created.tour.place_id);
+ await markStationVisit(orm(), onDay1.id, 'stop-cathedral', 'visited');
+
+ const copy = await tours.copyTour(tripId, String(created.tour.place_id), 'Old town again');
+ expect(copy.waypoints.map((point) => point.key)).toEqual(created.waypoints.map((point) => point.key));
+ expect(copy.tour).toMatchObject({ tour_type: 'walk', station_count: 3 });
+ const copyOnDay1 = await makeDayAssignment(orm(), day1.id, copy.tour.place_id);
+ expect((await visits.walkOnDay(tripId, copyOnDay1.id)).visits).toEqual([]);
+ expect((await visits.walkOnDay(tripId, onDay1.id)).visits).toHaveLength(1);
+ });
+
+ it('TOURS-WALK-004: two points sent with one key are kept apart, the first keeping it', async () => {
+ const { tripId } = await tripWithDays();
+ // A loop back to the start sends the start's key on its end point too.
+ const created = await tours.createTour(
+ tripId,
+ walkBody({ waypoints: inOrder([STATIONS[0], STATIONS[2], STATIONS[0]]) }),
+ );
+ const keys = created.waypoints.map((point) => point.key);
+ expect(keys[0]).toBe('stop-cathedral');
+ expect(keys[2]).not.toBe('stop-cathedral');
+ expect(stationKeySchema.safeParse(keys[2]).success).toBe(true);
+ expect(new Set(keys).size).toBe(3);
+ });
+
+ it('TOURS-WALK-005: a GPX file taken in as a city walk has no stations yet', async () => {
+ const { tripId } = await tripWithDays();
+ const gpx = `
+Harbour round
+5
+7
+`;
+ const imported = await tours.importGpxAsTour(tripId, Buffer.from(gpx), 'harbour.gpx', undefined, {
+ tour_type: 'walk',
+ });
+ expect(imported?.tours).toEqual([expect.objectContaining({ tour_type: 'walk', station_count: 0 })]);
+ expect((await tours.listTours(tripId))[0]).toMatchObject({ tour_type: 'walk', station_count: 0 });
+ });
+});
+
+/** A point without a station's stay, importance or protection, as a hike takes it. */
+function plainPoint(point: TourWaypoint): TourWaypoint {
+ const { stay_minutes: _stay, importance: _importance, protected: _protected, ...plain } = point;
+ return plain;
+}
+
+/** The points as a route in this order: numbered from 0, the first the start and the last the end. */
+function inOrder(points: TourWaypoint[]): TourWaypoint[] {
+ return points.map((point, sequence) => ({
+ ...point,
+ sequence,
+ role: sequence === 0 ? 'start' : sequence === points.length - 1 ? 'end' : 'via',
+ }));
+}
diff --git a/server/tests/unit/nest/trips.copy-walk.test.ts b/server/tests/unit/nest/trips.copy-walk.test.ts
new file mode 100644
index 000000000..4bae1a515
--- /dev/null
+++ b/server/tests/unit/nest/trips.copy-walk.test.ts
@@ -0,0 +1,75 @@
+/**
+ * TripsService.copy and a city walk (CW-03): the copied walk keeps the keys
+ * of its stations, and its days start with nothing checked off.
+ *
+ * Real in-memory SQLite over the container: the service comes out of
+ * TripsModule, and the rows are seeded and read through the ORM factories.
+ */
+import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
+import { Places } from '../../../src/db/entities/Places.entity';
+import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
+import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
+import { TripsModule } from '../../../src/nest/trips/trips.module';
+import { TripsService } from '../../../src/nest/trips/trips.service';
+import { createSnapshotTestDb } from '../../helpers/db-mock';
+import { makeDayAssignment } from '../../helpers/factories/itinerary';
+import { makePlace } from '../../helpers/factories/places';
+import { countRows, findRow, findRows } from '../../helpers/factories/rows';
+import { addTourWaypoints, makeTour, markStationVisit } from '../../helpers/factories/tours';
+import { makeTrip, readTripDays } from '../../helpers/factories/trips';
+import { makeUser } from '../../helpers/factories/users';
+import { createTestModule, type TestModule } from '../../helpers/test-module';
+import { MikroORM } from '@mikro-orm/core';
+
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+const db = createSnapshotTestDb();
+let t: TestModule;
+let svc: TripsService;
+const orm = () => t.get(MikroORM);
+
+beforeAll(async () => {
+ t = await createTestModule({ db, imports: [TripsModule] });
+ svc = t.get(TripsService);
+});
+
+afterAll(async () => {
+ await t.close();
+ db.close();
+});
+
+describe('copy: a city walk (CW-03)', () => {
+ it('TRIP-SVC-095: a copied city walk keeps the keys of its stations, and its days have no visits', async () => {
+ const { user } = await makeUser(orm());
+ const trip = await makeTrip(orm(), user.id, {
+ title: 'City days',
+ start_date: '2025-07-01',
+ end_date: '2025-07-01',
+ });
+ const [day] = await readTripDays(orm(), trip.id);
+ const walk = await makePlace(orm(), trip.id, { name: 'Old town' });
+ await makeTour(orm(), walk.id, { tourTypeRef: 'walk' });
+ await addTourWaypoints(orm(), walk.id, [
+ { lat: 48.1, lng: 11.5, station_key: 'stop-cathedral', name: 'Cathedral', stay_minutes: 30 },
+ { lat: 48.11, lng: 11.51, station_key: 'stop-market', importance: 'must', protected: 1 },
+ ]);
+ const assignment = await makeDayAssignment(orm(), day.id, walk.id);
+ await markStationVisit(orm(), assignment.id, 'stop-cathedral', 'visited');
+
+ const newTripId = await svc.copy(trip.id, user.id, 'City days again');
+
+ const copiedWalk = await findRow(orm(), Places, { trip: newTripId, name: 'Old town' });
+ const points = await findRows(orm(), TourWaypoints, { place: copiedWalk?.id }, { sequence: 'asc' });
+ expect(
+ points.map((point) => [point.station_key, point.name, point.stay_minutes, point.importance, point.protected]),
+ ).toEqual([
+ ['stop-cathedral', 'Cathedral', 30, null, 0],
+ ['stop-market', null, null, 'must', 1],
+ ]);
+ const copiedAssignments = await findRows(orm(), DayAssignments, { place: copiedWalk?.id });
+ expect(copiedAssignments).toHaveLength(1);
+ expect(await countRows(orm(), TourStationVisits, { assignment: copiedAssignments[0]?.id })).toBe(0);
+ // The source keeps what it checked off.
+ expect(await countRows(orm(), TourStationVisits, { assignment: assignment.id })).toBe(1);
+ });
+});
diff --git a/shared/src/tours/tour-poi-search.schema.spec.ts b/shared/src/tours/tour-poi-search.schema.spec.ts
index 00927e55f..90084de55 100644
--- a/shared/src/tours/tour-poi-search.schema.spec.ts
+++ b/shared/src/tours/tour-poi-search.schema.spec.ts
@@ -1,8 +1,11 @@
import {
TOUR_POI_CATEGORIES,
TOUR_POI_CATEGORIES_BY_TYPE,
+ TOUR_POI_MAX_CATEGORIES,
tourPoiSearchRequestSchema,
tourPoiSearchResponseSchema,
+ WALK_THEME_KEYS,
+ WALK_THEMES,
} from './tour-poi-search.schema';
import { describe, expect, it } from 'vitest';
@@ -78,3 +81,25 @@ describe('tour POI search contracts (PLN-02)', () => {
expect(tourPoiSearchResponseSchema.safeParse(answer).success).toBe(false);
});
});
+
+describe('city walk themes (CW-05)', () => {
+ it('offers four themes, each a search a walk may ask for', () => {
+ expect(WALK_THEME_KEYS).toEqual(['culture', 'parks', 'food', 'mixed']);
+ for (const key of WALK_THEME_KEYS) {
+ const categories = WALK_THEMES[key];
+ expect(categories.length).toBeGreaterThan(0);
+ expect(categories.length).toBeLessThanOrEqual(TOUR_POI_MAX_CATEGORIES);
+ for (const category of categories) expect(TOUR_POI_CATEGORIES_BY_TYPE.walk).toContain(category);
+ const search = { tour_type: 'walk', categories: [...categories], corridor_m: 250, line };
+ expect(tourPoiSearchRequestSchema.safeParse(search).success).toBe(true);
+ }
+ });
+
+ it("keeps a walk's own kinds to a walk's list, and a hike's to a hike's", () => {
+ for (const category of ['museum', 'park', 'cafe', 'restaurant', 'market'] as const) {
+ expect(TOUR_POI_CATEGORIES_BY_TYPE.walk).toContain(category);
+ expect(TOUR_POI_CATEGORIES_BY_TYPE.hike).not.toContain(category);
+ }
+ expect(TOUR_POI_CATEGORIES_BY_TYPE.walk).not.toContain('peak');
+ });
+});
diff --git a/shared/src/tours/tour-walk.schema.spec.ts b/shared/src/tours/tour-walk.schema.spec.ts
new file mode 100644
index 000000000..a776208c6
--- /dev/null
+++ b/shared/src/tours/tour-walk.schema.spec.ts
@@ -0,0 +1,67 @@
+import {
+ TOUR_VISIT_STATUSES,
+ tourVisitListResponseSchema,
+ tourVisitSchema,
+ tourVisitUpdateRequestSchema,
+ WALK_IMPORT_URL_MAX,
+ walkImportPreviewRequestSchema,
+ walkImportPreviewResponseSchema,
+} from './tour-walk.schema';
+
+import { describe, expect, it } from 'vitest';
+
+describe('city walk visits (CW-03)', () => {
+ const visit = { assignment_id: 9, station_key: 'stop-cathedral', status: 'visited' };
+
+ it('reads a visit of a station on a day, visited or skipped', () => {
+ expect(TOUR_VISIT_STATUSES).toEqual(['visited', 'skipped']);
+ expect(tourVisitListResponseSchema.parse({ visits: [visit, { ...visit, status: 'skipped' }] }).visits).toHaveLength(
+ 2,
+ );
+ expect(tourVisitListResponseSchema.parse({ visits: [] })).toEqual({ visits: [] });
+ });
+
+ it('refuses a visit that is planned, has no assignment or names no station key', () => {
+ expect(tourVisitSchema.safeParse({ ...visit, status: 'planned' }).success).toBe(false);
+ expect(tourVisitSchema.safeParse({ ...visit, assignment_id: 0 }).success).toBe(false);
+ expect(tourVisitSchema.safeParse({ ...visit, station_key: 'a/b' }).success).toBe(false);
+ });
+
+ it('takes a station back to planned, marks it visited or skipped, and takes nothing else', () => {
+ for (const status of ['planned', 'visited', 'skipped']) {
+ expect(tourVisitUpdateRequestSchema.parse({ status })).toEqual({ status });
+ }
+ expect(tourVisitUpdateRequestSchema.safeParse({ status: 'done' }).success).toBe(false);
+ expect(tourVisitUpdateRequestSchema.safeParse({}).success).toBe(false);
+ expect(tourVisitUpdateRequestSchema.safeParse({ status: 'visited', note: 'late' }).success).toBe(false);
+ });
+});
+
+describe('city walk import preview contract (CW-06, CW-07)', () => {
+ it('takes one link, trimmed, up to its length, and nothing else', () => {
+ expect(walkImportPreviewRequestSchema.parse({ url: ' https://maps.app.goo.gl/abc ' })).toEqual({
+ url: 'https://maps.app.goo.gl/abc',
+ });
+ expect(walkImportPreviewRequestSchema.safeParse({ url: ' ' }).success).toBe(false);
+ expect(walkImportPreviewRequestSchema.safeParse({ url: 'x'.repeat(WALK_IMPORT_URL_MAX) }).success).toBe(true);
+ expect(walkImportPreviewRequestSchema.safeParse({ url: 'x'.repeat(WALK_IMPORT_URL_MAX + 1) }).success).toBe(false);
+ expect(walkImportPreviewRequestSchema.safeParse({ url: 'https://maps.app.goo.gl/abc', mode: 'walk' }).success).toBe(
+ false,
+ );
+ });
+
+ it('answers the stops the link names, a stop without coordinates left without them', () => {
+ const answer = walkImportPreviewResponseSchema.parse({
+ stations: [
+ { name: 'Cathedral', lat: 48.1, lng: 11.5 },
+ { name: null, lat: null, lng: null },
+ ],
+ resolved_url: 'https://www.google.com/maps/dir/...',
+ });
+ expect(answer.stations[1]).toEqual({ name: null, lat: null, lng: null });
+ expect(
+ walkImportPreviewResponseSchema.safeParse({ stations: [{ name: 'X', lat: 91, lng: 0 }], resolved_url: '' })
+ .success,
+ ).toBe(false);
+ });
+});
diff --git a/shared/src/tours/tours.schema.test.ts b/shared/src/tours/tours.schema.test.ts
index 70882713c..c2c5cd149 100644
--- a/shared/src/tours/tours.schema.test.ts
+++ b/shared/src/tours/tours.schema.test.ts
@@ -5,8 +5,10 @@ import {
hikingPreferencesOf,
MAX_PADDLE_SPEED_KMH,
MAX_PLANNED_TOUR_DURATION_MINUTES,
+ MAX_STATION_STAY_MINUTES,
recommendedPaddleSpeedKmh,
plannedTourDurationMinutesSchema,
+ stationKeySchema,
tourCreateRequestSchema,
tourListItemSchema,
tourMaxHikingDifficultySchema,
@@ -412,3 +414,100 @@ describe('bike tours (BIK-01 to BIK-04)', () => {
).toBe(true);
});
});
+
+describe('city walks (CW-01 to CW-03, CW-10)', () => {
+ const walk = { ...baseRequest, tour_type: 'walk' as const };
+ const station = {
+ key: '0b7e7c5a-5d43-4c79-9b52-2f7f0d0e2c11',
+ name: 'Cathedral',
+ stay_minutes: 30,
+ importance: 'must' as const,
+ protected: true,
+ opening_hours: 'Mo-Su 09:00-18:00',
+ };
+ const withFirst = (request: T, extra: object) => ({
+ ...request,
+ waypoints: [{ ...request.waypoints[0], ...extra }, request.waypoints[1]],
+ });
+
+ it('saves a walk with its stations, and no hiking limit', () => {
+ const parsed = tourCreateRequestSchema.parse(withFirst(walk, station));
+ expect(parsed).toMatchObject({ tour_type: 'walk', max_hiking_difficulty: null });
+ expect(parsed.waypoints[0]).toMatchObject(station);
+ });
+
+ it("keeps a station's stay, importance and protection to a city walk", () => {
+ for (const extra of [{ stay_minutes: 20 }, { importance: 'optional' }, { protected: true }]) {
+ expect(() => tourCreateRequestSchema.parse(withFirst(baseRequest, extra))).toThrow(
+ /stations belong to a city walk/,
+ );
+ }
+ // A key, a name and opening hours are any point's, and protection switched off is no protection.
+ const named = withFirst(baseRequest, { key: 'stop-a1', name: 'Hut', opening_hours: '24/7', protected: false });
+ expect(tourCreateRequestSchema.safeParse(named).success).toBe(true);
+ });
+
+ it('refuses a walk over a via ferrata, with a hiking limit or with hiking preferences', () => {
+ const ferrata = {
+ kind: 'ferrata' as const,
+ ref: 'way/1',
+ name: null,
+ geometry: [
+ [48, 11],
+ [48.01, 11.02],
+ ],
+ };
+ expect(() => tourCreateRequestSchema.parse(withFirst(walk, { fixed_leg: ferrata }))).toThrow(
+ /a city walk has no via ferrata/,
+ );
+ expect(() => tourCreateRequestSchema.parse({ ...walk, max_hiking_difficulty: 3 })).toThrow(
+ /a city walk has no hiking difficulty/,
+ );
+ expect(() => tourCreateRequestSchema.parse({ ...walk, hiking_preferences: { avoid_steps: true } })).toThrow(
+ /a city walk has no hiking preferences/,
+ );
+ });
+
+ it('takes a station key of 6 to 64 letters, digits, dashes and underscores', () => {
+ expect(stationKeySchema.safeParse('0b7e7c5a-5d43-4c79-9b52-2f7f0d0e2c11').success).toBe(true);
+ expect(stationKeySchema.safeParse('stop_1').success).toBe(true);
+ expect(stationKeySchema.safeParse('x'.repeat(64)).success).toBe(true);
+ for (const key of ['stop1', 'x'.repeat(65), 'stop/1', 'stop 1', 'stöp-1']) {
+ expect(stationKeySchema.safeParse(key).success).toBe(false);
+ }
+ });
+
+ it('takes a stay from 0, a point passed through, to a whole day', () => {
+ expect(MAX_STATION_STAY_MINUTES).toBe(1440);
+ for (const stay_minutes of [0, 1440, null]) {
+ expect(tourCreateRequestSchema.safeParse(withFirst(walk, { stay_minutes })).success).toBe(true);
+ }
+ for (const stay_minutes of [1441, -1, 12.5]) {
+ expect(tourCreateRequestSchema.safeParse(withFirst(walk, { stay_minutes })).success).toBe(false);
+ }
+ });
+
+ it("lists a walk's station count, and reads an item cached before it existed", () => {
+ const item = tourListItemSchema.parse({
+ place_id: 1,
+ name: 'Old town',
+ tour_type: 'walk',
+ distance: null,
+ elevation_gain: null,
+ elevation_loss: null,
+ duration: null,
+ difficulty: null,
+ wanderer_ref: null,
+ match_confidence: null,
+ max_hiking_difficulty: null,
+ planned: false,
+ caution: false,
+ has_waypoints: true,
+ station_count: 3,
+ });
+ expect(item.station_count).toBe(3);
+ const { station_count: _count, ...cached } = item;
+ expect(tourListItemSchema.parse(cached).station_count).toBeUndefined();
+ expect(tourListItemSchema.safeParse({ ...item, station_count: -1 }).success).toBe(false);
+ });
+});