From e5f01e570a9e954759bfb500304bc8af6e32cf96 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 19 Oct 2026 23:58:57 +0000 Subject: [PATCH] fix(tours): check a city walk's stops off and save a stop from its dialog, with the walk under test (CW-01 to CW-05, CW-09) Marking a stop of a city walk visited or skipped answered with a server error every time: the write that keeps a changed status stamped its time as a function call, which neither SQLite nor Postgres takes. It now uses the clock the database itself spells, so a stop checked off, skipped or taken back stays that way and the day plan of everyone on the trip follows. The Save button of a stop's dialog in the planner did nothing, because it sat outside the dialog's form. It saves the name, stay, importance and protection again. A walk saved with two points that carry one key, as a loop back to the start does, keeps that key on the first point and gives the other one of its own, so checking one off never marks both. A walk put on one day twice is read for an assistant from its first assignment. The server tests cover the visits from the repository to the routes: per day, through a moved and a removed assignment, a copy of the walk and a copied trip without visits, a removed stop taking its visits along, and the refusals for another trip, a stop the walk does not have, a member who may not edit days and an unknown key or body. The MCP progress of a walk, the station fields and counts of the tour list, and the contracts in shared are tested too; the existing tests expect the keys every saved point now carries, the station count of a list row and the new entity. The client tests cover the stop dialog and its pills, the walk themes and categories of the search along a tour, the stop menu of the waypoint list, the walk's timing, routing and type choice, and answer the trip planner's read of the visits in the tests instead of leaving it unanswered. --- .../Tours/planner/StationDialog.test.tsx | 157 ++++++ .../Tours/planner/StationDialog.tsx | 6 +- .../Tours/planner/TourPlannerPanels.test.tsx | 2 +- .../Tours/planner/TourTypeFields.test.tsx | 17 + .../Tours/planner/TourWaypointList.test.tsx | 47 +- .../Tours/planner/tourPlannerParts.test.ts | 28 ++ .../planner/tourPlannerWaypoints.test.ts | 68 ++- .../Tours/planner/tourRouting.test.ts | 8 +- .../Tours/poi/tourPoiSearch.test.tsx | 106 +++- client/tests/helpers/msw/handlers/index.ts | 2 + client/tests/helpers/msw/handlers/tours.ts | 9 + .../TourStationVisits.repository.ts | 11 +- .../src/db/repositories/Tours.repository.ts | 2 +- server/src/nest/tours/tours.service.ts | 15 +- server/tests/e2e/tours-walk.e2e.test.ts | 474 ++++++++++++++++++ server/tests/e2e/tours.e2e.test.ts | 5 +- server/tests/helpers/factories/README.md | 2 +- server/tests/helpers/factories/tours.ts | 42 +- server/tests/helpers/tour-keys.ts | 13 + server/tests/unit/db/entities-index.test.ts | 7 +- .../tests/unit/db/entity-class-fields.test.ts | 4 +- .../TourStationVisits.repository.test.ts | 156 ++++++ .../TourWaypoints.repository.test.ts | 36 ++ .../db/repositories/Tours.repository.test.ts | 28 ++ .../tests/unit/helpers/orm-factories.test.ts | 20 +- .../unit/nest/tour-visits.controller.test.ts | 71 +++ .../unit/nest/tour-visits.service.test.ts | 291 +++++++++++ server/tests/unit/nest/tours.mcp.test.ts | 85 +++- ...s.permissions.boundaries.mock-only.test.ts | 3 +- .../nest/tours.service.permissions.test.ts | 15 +- server/tests/unit/nest/tours.service.test.ts | 13 +- .../unit/nest/tours.walk.service.test.ts | 216 ++++++++ .../tests/unit/nest/trips.copy-walk.test.ts | 75 +++ .../src/tours/tour-poi-search.schema.spec.ts | 25 + shared/src/tours/tour-walk.schema.spec.ts | 67 +++ shared/src/tours/tours.schema.test.ts | 99 ++++ 36 files changed, 2182 insertions(+), 43 deletions(-) create mode 100644 client/src/components/Tours/planner/StationDialog.test.tsx create mode 100644 client/tests/helpers/msw/handlers/tours.ts create mode 100644 server/tests/e2e/tours-walk.e2e.test.ts create mode 100644 server/tests/helpers/tour-keys.ts create mode 100644 server/tests/unit/db/repositories/TourStationVisits.repository.test.ts create mode 100644 server/tests/unit/nest/tour-visits.controller.test.ts create mode 100644 server/tests/unit/nest/tour-visits.service.test.ts create mode 100644 server/tests/unit/nest/tours.walk.service.test.ts create mode 100644 server/tests/unit/nest/trips.copy-walk.test.ts create mode 100644 shared/src/tours/tour-walk.schema.spec.ts 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); + }); +});