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); + }); +});