From f4750352e61b04e64c2a5ee5d0418912779a2d3a Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 16:30:55 +0200 Subject: [PATCH] fix(tours): fit the merged tracks together after integration Types, test expectations and helpers the seven tracks each changed on their own, brought in line with the integrated code. tours.service.ts and the tours repository grew past the size limit with every track and hand their row and write helpers to modules of their own. --- .../src/components/Tours/TourDetailDialog.tsx | 2 +- .../activity/TourActivityCompare.test.tsx | 2 +- .../Tours/activity/TourActivityCompare.tsx | 6 +- .../activity/TourActivitySection.test.tsx | 4 +- .../Tours/activity/TourActivitySection.tsx | 2 +- ...Compare.test.ts => planComparison.test.ts} | 2 +- ...urActivityCompare.ts => planComparison.ts} | 0 .../components/Tours/hike/hikeFacets.test.ts | 1 + .../components/Tours/hike/routeMatch.test.ts | 1 + .../Tours/hike/useHikeWays.test.tsx | 1 + .../Tours/planner/LegVariantsCard.test.tsx | 19 +- .../Tours/planner/RoundHikeCard.test.tsx | 4 +- .../components/Tours/run/runMarks.test.tsx | 2 +- .../Tours/stages/savedTourStages.ts | 2 +- client/src/components/shared/DialogShell.tsx | 2 +- .../tripPlanner/TourPlannerMapStage.test.tsx | 2 +- server/scripts/eslint-baseline.json | 4 +- server/src/db/json-columns.ts | 42 +-- .../src/db/repositories/Tours.repository.ts | 54 +--- server/src/db/repositories/tour-rows.ts | 48 +++ .../src/nest/tours/tour-activities.service.ts | 4 +- .../src/nest/tours/tour-activity-stats.mcp.ts | 2 +- server/src/nest/tours/tour-change-undo.mcp.ts | 4 +- server/src/nest/tours/tour-change-undo.ts | 4 +- .../nest/tours/tour-horse-routes.service.ts | 2 +- server/src/nest/tours/tour-item.helpers.ts | 113 +++++++ server/src/nest/tours/tour-plan-read.ts | 15 +- server/src/nest/tours/tour-write.helpers.ts | 158 ++++++++++ server/src/nest/tours/tours.mcp.ts | 5 +- server/src/nest/tours/tours.service.ts | 289 ++---------------- .../src/nest/tours/wanderer-watch.service.ts | 13 +- server/src/nest/trips/trips.service.ts | 11 +- server/src/nest/wanderer/wanderer.service.ts | 6 +- .../e2e/tours-activity-stats.e2e.test.ts | 7 +- server/tests/e2e/tours-activity.e2e.test.ts | 7 +- server/tests/e2e/tours-horse.e2e.test.ts | 7 +- server/tests/e2e/tours-run.e2e.test.ts | 6 +- server/tests/e2e/tours-ski.e2e.test.ts | 9 +- server/tests/helpers/factories.ts | 4 +- .../db/repositories/Tours.repository.test.ts | 14 +- .../unit/nest/assignments.service.test.ts | 33 +- .../nest/collection-tours.service.test.ts | 4 +- .../unit/nest/tour-activities.mcp.test.ts | 2 +- .../unit/nest/tour-activity-stats.mcp.test.ts | 23 +- .../unit/nest/tour-change-save.mcp.test.ts | 4 +- .../unit/nest/tour-change-undo.mcp.test.ts | 19 +- .../tests/unit/nest/tour-change.mcp.test.ts | 18 +- .../nest/tour-horse-routes.controller.test.ts | 5 +- .../nest/tour-horse-routes.service.test.ts | 10 +- .../nest/tour-packing-link.service.test.ts | 34 +-- .../tests/unit/nest/tour-packing.mcp.test.ts | 23 +- .../tests/unit/nest/tours.gpx.atomic.test.ts | 10 +- server/tests/unit/nest/tours.mcp.test.ts | 10 +- server/tests/unit/nest/tours.service.test.ts | 1 - .../unit/nest/wanderer-watch.service.test.ts | 2 +- shared/src/tours/tour-stages.spec.ts | 9 +- 56 files changed, 582 insertions(+), 505 deletions(-) rename client/src/components/Tours/activity/{tourActivityCompare.test.ts => planComparison.test.ts} (98%) rename client/src/components/Tours/activity/{tourActivityCompare.ts => planComparison.ts} (100%) create mode 100644 server/src/db/repositories/tour-rows.ts create mode 100644 server/src/nest/tours/tour-item.helpers.ts create mode 100644 server/src/nest/tours/tour-write.helpers.ts diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 2e3d9c3e3..d779e81d3 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -50,7 +50,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'; import { TourDayRowAvatar } from './TourDayRowFacts'; import { TourGpxExportButton } from './TourGpxExportButton'; import TourActivitySection from './activity/TourActivitySection'; -import type { PlanValues } from './activity/tourActivityCompare'; +import type { PlanValues } from './activity/planComparison'; import { TourSaveToCollectionButton } from './collection/TourSaveToCollectionButton'; import { PaddleCheckSection } from './paddle/PaddleCheckSection'; import { TourPackingSection } from './packing/TourPackingSection'; diff --git a/client/src/components/Tours/activity/TourActivityCompare.test.tsx b/client/src/components/Tours/activity/TourActivityCompare.test.tsx index 256746699..0a68feff0 100644 --- a/client/src/components/Tours/activity/TourActivityCompare.test.tsx +++ b/client/src/components/Tours/activity/TourActivityCompare.test.tsx @@ -7,7 +7,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import type { TourActivity } from '@trek/shared'; import { describe, expect, it, vi } from 'vitest'; import TourActivityCompare, { formatDifference } from './TourActivityCompare'; -import type { PlanValues } from './tourActivityCompare'; +import type { PlanValues } from './planComparison'; const plan: PlanValues = { distanceKm: 2, diff --git a/client/src/components/Tours/activity/TourActivityCompare.tsx b/client/src/components/Tours/activity/TourActivityCompare.tsx index f21626223..da26cb192 100644 --- a/client/src/components/Tours/activity/TourActivityCompare.tsx +++ b/client/src/components/Tours/activity/TourActivityCompare.tsx @@ -18,7 +18,7 @@ import { type CompareKey, type CompareRow, type PlanValues, -} from './tourActivityCompare'; +} from './planComparison'; type Translate = (key: string, params?: Record) => string; @@ -167,7 +167,9 @@ function deviationFields( unit: DistanceUnit, t: Translate ): TourMetricField[] { - const deviation = planDeviation(activity.geometry, plan.line); + // Latitude and longitude only: how far the track strayed is measured on the map, not in height. + const recorded = activity.geometry.map(([lat, lng]) => [lat, lng]); + const deviation = planDeviation(recorded, plan.line); const offLabel = t('tours.activity.compare.offPlan'); if (!deviation) return [{ label: offLabel, value: t('tours.activity.compare.noLine') }]; const offInfo = { diff --git a/client/src/components/Tours/activity/TourActivitySection.test.tsx b/client/src/components/Tours/activity/TourActivitySection.test.tsx index 178019e92..07f9f2496 100644 --- a/client/src/components/Tours/activity/TourActivitySection.test.tsx +++ b/client/src/components/Tours/activity/TourActivitySection.test.tsx @@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore'; import type { Day } from '../../../types'; import TourActivitySection from './TourActivitySection'; -import type { PlanValues } from './tourActivityCompare'; +import type { PlanValues } from './planComparison'; import type { TourActivities } from './useTourActivities'; const hook = vi.hoisted(() => ({ state: null as unknown })); @@ -116,7 +116,7 @@ describe('TourActivitySection (ACT-01)', () => { expect(current.askRemove).toHaveBeenCalledWith(current.activities?.[0]); hook.state = state({ assigning: {}, removing: activity() }); - rerender(); + rerender(); expect(screen.getByTestId('tour-activity-dialog')).toBeInTheDocument(); expect(screen.getByText('tours.activity.removeTitle')).toBeInTheDocument(); }); diff --git a/client/src/components/Tours/activity/TourActivitySection.tsx b/client/src/components/Tours/activity/TourActivitySection.tsx index 31e8b455a..c22664304 100644 --- a/client/src/components/Tours/activity/TourActivitySection.tsx +++ b/client/src/components/Tours/activity/TourActivitySection.tsx @@ -16,7 +16,7 @@ import { CardAction, TourNotice } from '../tourParts'; import TourActivityCompare from './TourActivityCompare'; import TourActivityDialog from './TourActivityDialog'; import { ACTUAL_TRACK_COLOR } from './activityCompareParts'; -import type { PlanValues } from './tourActivityCompare'; +import type { PlanValues } from './planComparison'; import { analysisOfLine, executionOf, executionsOf, recordedDay, type TourExecution } from './tourActivityModel'; import { useActivityComparison, type ActivityComparisonState } from './useActivityComparison'; import { useTourActivities, type TourActivities } from './useTourActivities'; diff --git a/client/src/components/Tours/activity/tourActivityCompare.test.ts b/client/src/components/Tours/activity/planComparison.test.ts similarity index 98% rename from client/src/components/Tours/activity/tourActivityCompare.test.ts rename to client/src/components/Tours/activity/planComparison.test.ts index 7add581f5..b9bace26a 100644 --- a/client/src/components/Tours/activity/tourActivityCompare.test.ts +++ b/client/src/components/Tours/activity/planComparison.test.ts @@ -4,7 +4,7 @@ // how far the recorded line strayed from the planned one. import type { TourActivityMetrics } from '@trek/shared'; import { describe, expect, it } from 'vitest'; -import { compareRows, MEASURED_POINTS, OFF_PLAN_M, planDeviation, type PlanValues } from './tourActivityCompare'; +import { compareRows, MEASURED_POINTS, OFF_PLAN_M, planDeviation, type PlanValues } from './planComparison'; const plan: PlanValues = { distanceKm: 8, diff --git a/client/src/components/Tours/activity/tourActivityCompare.ts b/client/src/components/Tours/activity/planComparison.ts similarity index 100% rename from client/src/components/Tours/activity/tourActivityCompare.ts rename to client/src/components/Tours/activity/planComparison.ts diff --git a/client/src/components/Tours/hike/hikeFacets.test.ts b/client/src/components/Tours/hike/hikeFacets.test.ts index e9d696c11..9b80ee1fd 100644 --- a/client/src/components/Tours/hike/hikeFacets.test.ts +++ b/client/src/components/Tours/hike/hikeFacets.test.ts @@ -15,6 +15,7 @@ const edge = (from: number, wayId: number | null, lengthM = 100): RouteWayEdge = lengthM, surface: 'paved', way: 'path', + sac: 'none', wayId, }); diff --git a/client/src/components/Tours/hike/routeMatch.test.ts b/client/src/components/Tours/hike/routeMatch.test.ts index 26b1d0486..b6cf626c4 100644 --- a/client/src/components/Tours/hike/routeMatch.test.ts +++ b/client/src/components/Tours/hike/routeMatch.test.ts @@ -17,6 +17,7 @@ const edge = (from: number, to: number, wayId = 1): RouteWayEdge => ({ lengthM: 100, surface: 'unknown', way: 'path', + sac: 'none', wayId, }); diff --git a/client/src/components/Tours/hike/useHikeWays.test.tsx b/client/src/components/Tours/hike/useHikeWays.test.tsx index c37f10cf9..c2cc88cc4 100644 --- a/client/src/components/Tours/hike/useHikeWays.test.tsx +++ b/client/src/components/Tours/hike/useHikeWays.test.tsx @@ -33,6 +33,7 @@ const edge = (from: number, wayId: number): RouteWayEdge => ({ // The router's own reading, which no facet may take for a recorded value. surface: 'paved', way: 'path', + sac: 'none', wayId, }); const WAYS: RouteWays = { line: ROUTE, edges: [edge(0, 1), edge(1, 2), edge(2, 3)] }; diff --git a/client/src/components/Tours/planner/LegVariantsCard.test.tsx b/client/src/components/Tours/planner/LegVariantsCard.test.tsx index 80eb7ab59..4a6e1dda9 100644 --- a/client/src/components/Tours/planner/LegVariantsCard.test.tsx +++ b/client/src/components/Tours/planner/LegVariantsCard.test.tsx @@ -73,6 +73,11 @@ beforeEach(() => { seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } }); }); +/** The last of the elements found: the card rendered most recently. */ +function lastOf(items: T[]): T | undefined { + return items[items.length - 1]; +} + describe('LegVariantsCard', () => { it('FE-TOUR-LEG-VARIANTS-CARD-001: names the section by its ends and lists each way with its figures', () => { renderCard(comparison()); @@ -129,9 +134,9 @@ describe('LegVariantsCard', () => { expect(actions.apply).toHaveBeenCalledTimes(1); renderCard(comparison({ selected: 'current' })); - expect(screen.getAllByTestId('leg-variants-apply').at(-1)).toBeDisabled(); + expect(lastOf(screen.getAllByTestId('leg-variants-apply'))).toBeDisabled(); renderCard(comparison({ selected: 'a' }), { isSaving: true }); - expect(screen.getAllByTestId('leg-variants-apply').at(-1)).toBeDisabled(); + expect(lastOf(screen.getAllByTestId('leg-variants-apply'))).toBeDisabled(); }); it('FE-TOUR-LEG-VARIANTS-CARD-005: while a choice is checked the list stands still and the button says what happens', async () => { @@ -146,15 +151,15 @@ describe('LegVariantsCard', () => { renderCard(comparison({ phase: 'loading', variants: [way('current')] })); expect(screen.getByRole('status')).toHaveTextContent('Looking for other ways'); renderCard(comparison({ phase: 'none', variants: [way('current')] })); - expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('The router knows no other way'); + expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('The router knows no other way'); renderCard(comparison({ phase: 'failed', variants: [way('current')] })); - expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('The router did not answer'); + expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('The router did not answer'); renderCard(comparison({ notice: 'notHeld' })); - expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('The router does not keep to this way'); + expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('The router does not keep to this way'); renderCard(comparison({ notice: 'stale' })); - expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('The tour changed in the meantime'); + expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('The tour changed in the meantime'); renderCard(comparison({ notice: 'failed' })); - expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('This way could not be checked'); + expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('This way could not be checked'); }); it('FE-TOUR-LEG-VARIANTS-CARD-007: the overlay shows only while a section is compared; cancel, the cross and Escape close it', async () => { diff --git a/client/src/components/Tours/planner/RoundHikeCard.test.tsx b/client/src/components/Tours/planner/RoundHikeCard.test.tsx index f87d03145..4a1bc80e9 100644 --- a/client/src/components/Tours/planner/RoundHikeCard.test.tsx +++ b/client/src/components/Tours/planner/RoundHikeCard.test.tsx @@ -1,7 +1,7 @@ // FE-TOUR-ROUND-HIKE-CARD-001 to FE-TOUR-ROUND-HIKE-CARD-009: the card over the planner map that // looks for round hikes out of a stop and takes one on purpose (RTE-03). import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, within } from '../../../../tests/helpers/render'; import { seedStore } from '../../../../tests/helpers/store'; import { idleRoundHikes } from '../../../../tests/helpers/tourPlannerStub'; @@ -116,7 +116,7 @@ describe('RoundHikeCard', () => { expect(screen.getByLabelText('Distance')).toHaveValue('6.2'); expect(screen.getByText('mi')).toBeInTheDocument(); await userEvent.click(screen.getByTestId('round-hike-find')); - expect(actions.find.mock.calls[0][0]).toBeCloseTo(9_978, 0); + expect(vi.mocked(actions.find).mock.calls[0][0]).toBeCloseTo(9_978, 0); }); it('FE-TOUR-ROUND-HIKE-CARD-004: while it looks, the bar fills, the rounds found show and nothing can be taken yet', async () => { diff --git a/client/src/components/Tours/run/runMarks.test.tsx b/client/src/components/Tours/run/runMarks.test.tsx index 180095940..9d8033dea 100644 --- a/client/src/components/Tours/run/runMarks.test.tsx +++ b/client/src/components/Tours/run/runMarks.test.tsx @@ -88,7 +88,7 @@ describe('a run wherever a tour shows (RUN-01)', () => { expect(screen.queryByTestId('tour-detail-run-profile')).toBeNull(); }); - it('FE-TOUR-RUN-MARK-007: a trail run goes by its own profile, and its time and pace read as any run's (RUN-02)', () => { + it("FE-TOUR-RUN-MARK-007: a trail run goes by its own profile, and its time and pace read as any run's (RUN-02)", () => { const trail = { ...run, run_profile: 'trail' as const, run_pace_seconds_per_km: 450, duration: 60 }; render(); expect(screen.getByTestId('tour-detail-run-profile')).toHaveTextContent('Trail running'); diff --git a/client/src/components/Tours/stages/savedTourStages.ts b/client/src/components/Tours/stages/savedTourStages.ts index 83de39825..a3aa0d8af 100644 --- a/client/src/components/Tours/stages/savedTourStages.ts +++ b/client/src/components/Tours/stages/savedTourStages.ts @@ -13,7 +13,7 @@ export interface SavedTourStage extends TourStage { export function savedStagePoints(tour: TourListItem, saved: readonly TourWaypoint[]): TourPlannerWaypoint[] { let next = 0; const points = plannerWaypointsOf(saved, () => `saved-${next++}`); - return normalizePlannerWaypoints(points, openedTourFields(tour).tourType === 'kayak'); + return normalizePlannerWaypoints(points, openedTourFields(tour).tourType); } /** diff --git a/client/src/components/shared/DialogShell.tsx b/client/src/components/shared/DialogShell.tsx index 90f22dd89..fbc4c4546 100644 --- a/client/src/components/shared/DialogShell.tsx +++ b/client/src/components/shared/DialogShell.tsx @@ -8,7 +8,7 @@ import { focusDialog, trapTab } from './dialogFocus' import { useDiscardGuard } from './useDiscardGuard' /** A font size that follows the user's text size setting for its tier. */ -export const fs = (px: number, tier: 'caption' | 'body' | 'subtitle' = 'caption'): CSSProperties => ({ fontSize: `calc(${px}px * var(--fs-scale-${tier}, 1))` }) +export const fs = (px: number, tier: 'caption' | 'body' | 'subtitle' | 'title' = 'caption'): CSSProperties => ({ fontSize: `calc(${px}px * var(--fs-scale-${tier}, 1))` }) const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint' const BACKDROP = 'trek-modal-backdrop trek-backdrop-enter fixed inset-0 z-[10000] flex justify-center bg-[rgba(15,23,42,0.5)] px-4' // theme-lint-disable: the dim every shared/Modal draws diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx index 7db4a25b8..ad0e1d13e 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx @@ -341,7 +341,7 @@ describe('TourPlannerMapStage', () => { it('FE-PAGE-TOURSTAGE-007: switched on, the layer asks for the area the flat map reported and draws what it found, grouped', () => { renderStage(planner('view-saved')); const area = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 }; - act(() => stage.flatMap.props?.onViewportChange?.(area)); + act(() => stage.flatMap.props?.onViewportChange?.(area, 12)); // HOR-01: the rail's horse route search looks in the same view. expect(useTourMapViewportStore.getState().bounds).toEqual(area); act(() => stage.flatMap.props?.ferrataLayer?.onToggle()); diff --git a/server/scripts/eslint-baseline.json b/server/scripts/eslint-baseline.json index 64cc40db0..bcb2fc39a 100644 --- a/server/scripts/eslint-baseline.json +++ b/server/scripts/eslint-baseline.json @@ -1,6 +1,6 @@ { "src": { - "@typescript-eslint/no-explicit-any": 90, + "@typescript-eslint/no-explicit-any": 89, "@typescript-eslint/no-non-null-assertion": 264, "@typescript-eslint/no-require-imports": 18, "@typescript-eslint/no-unsafe-function-type": 1, @@ -21,7 +21,7 @@ "@typescript-eslint/no-require-imports": 25, "@typescript-eslint/no-unsafe-function-type": 7, "@typescript-eslint/no-unused-vars": 96, - "no-await-in-loop": 453, + "no-await-in-loop": 452, "no-console": 18, "no-empty": 2, "no-useless-escape": 1, diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index 8187120da..7c00d3df5 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -236,18 +236,29 @@ export const TOUR_CHANGE_STEP_CHANGES: JsonColumn = { fallback: () => [], }; -/** A point of a saved line: latitude, longitude and the height where it has one. */ -const savedLinePoint = z.union([z.tuple([z.number(), z.number()]), z.tuple([z.number(), z.number(), z.number()])]); +/** + * A point of a saved line: latitude, longitude and the height where it has one. + * Checked by hand rather than as a union of tuples: the server compiles without + * strictNullChecks, where a tuple schema reads as one of optional elements and + * would not hand back the GeometryPoint the restore writes. + */ +const savedLinePoint = z.custom( + (value) => + Array.isArray(value) && + (value.length === 2 || value.length === 3) && + value.every((n: unknown) => typeof n === 'number'), +); /** A tour as it was before a saved step (MCP-T03), as its JSON column holds it. */ -export interface TourChangeStepBefore { - route_geometry: GeometryPoint[] | null; - waypoints: TourWaypoint[]; - distance: number | null; - duration: number | null; - elevation_gain: number | null; - elevation_loss: number | null; -} +const tourChangeStepBeforeSchema = z.object({ + route_geometry: z.array(savedLinePoint).nullable(), + waypoints: z.array(tourWaypointSchema), + distance: z.number().nullable(), + duration: z.number().nullable(), + elevation_gain: z.number().nullable(), + elevation_loss: z.number().nullable(), +}); +export type TourChangeStepBefore = z.infer; /** * MCP-T03: a tour as it was before a saved step changed it: its line (null @@ -257,16 +268,7 @@ export interface TourChangeStepBefore { */ export const TOUR_CHANGE_STEP_BEFORE: JsonColumn = { column: 'tour_change_steps.before_state', - schema: z - .object({ - route_geometry: z.array(savedLinePoint).nullable(), - waypoints: z.array(tourWaypointSchema), - distance: z.number().nullable(), - duration: z.number().nullable(), - elevation_gain: z.number().nullable(), - elevation_loss: z.number().nullable(), - }) - .nullable(), + schema: tourChangeStepBeforeSchema.nullable(), fallback: () => null, }; diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index b29cfbf73..0e1b61e0c 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -2,6 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en import type { DB } from '../kysely/db'; import type { AssertRowKeys } from './_shared/rows'; import { TrekRepository } from './_shared/trek-repository'; +import { toLibraryRow, toListRow } from './tour-rows'; import type { BikeProfile, PaddleBoatType, @@ -588,17 +589,6 @@ export class ToursRepository extends TrekRepository { .execute(); } - /** - * What a copied trip's tour keeps besides its facet and its points: its - * import record, its obstacle decisions and its packing link, onto its copy. - * Called inside the trip copy's transaction. - */ - async copyTourRelations(source_place_id: number, place_id: number): Promise { - await this.copySource(source_place_id, place_id); - await this.copyObstacleReviews(source_place_id, place_id); - await this.copyPackingLink(source_place_id, place_id); - } - /** The days every tour of a trip sits on, each tour and day once, in day order, with the stage a day holds (MDT-02). */ async listTourDays(trip_id: number): Promise { return await this.db() @@ -973,45 +963,3 @@ export class ToursRepository extends TrekRepository { await this.nativeUpdate({ place: place_id }, { recorded_route_geometry: null }); } } - -function toLibraryRow & { has_waypoints: unknown }>( - r: R, -): TourLibraryRow { - return { - place_id: r.place_id, - name: r.name, - description: r.description, - tour_type: r.tour_type, - distance: r.distance, - elevation_gain: r.elevation_gain, - elevation_loss: r.elevation_loss, - duration: r.duration, - planned_duration_minutes: r.planned_duration_minutes, - break_additional_minutes: r.break_additional_minutes, - max_hiking_difficulty: r.max_hiking_difficulty, - origin: r.origin, - trip_id: r.trip_id, - trip_title: r.trip_title, - has_waypoints: Number(r.has_waypoints), - }; -} - -function toListRow< - R extends Omit & { - planned: unknown; - has_waypoints: unknown; - has_recorded_track: unknown; - route_missing: unknown; - stage_ends: unknown; - }, ->(r: R): TourListRow { - return { - ...r, - planned: Number(r.planned), - has_waypoints: Number(r.has_waypoints), - has_recorded_track: Number(r.has_recorded_track), - route_missing: Number(r.route_missing), - // Postgres counts in a bigint, which its driver hands back as text. - stage_ends: Number(r.stage_ends), - }; -} diff --git a/server/src/db/repositories/tour-rows.ts b/server/src/db/repositories/tour-rows.ts new file mode 100644 index 000000000..84dd8c44c --- /dev/null +++ b/server/src/db/repositories/tour-rows.ts @@ -0,0 +1,48 @@ +/** + * The rows the tours repository answers, as its queries read them: counts and + * flags come back from SQLite and Postgres in different spellings, and are + * made numbers here. + */ +import type { TourLibraryRow, TourListRow } from './Tours.repository'; + +export function toLibraryRow & { has_waypoints: unknown }>( + r: R, +): TourLibraryRow { + return { + place_id: r.place_id, + name: r.name, + description: r.description, + tour_type: r.tour_type, + distance: r.distance, + elevation_gain: r.elevation_gain, + elevation_loss: r.elevation_loss, + duration: r.duration, + planned_duration_minutes: r.planned_duration_minutes, + break_additional_minutes: r.break_additional_minutes, + max_hiking_difficulty: r.max_hiking_difficulty, + origin: r.origin, + trip_id: r.trip_id, + trip_title: r.trip_title, + has_waypoints: Number(r.has_waypoints), + }; +} + +export function toListRow< + R extends Omit & { + planned: unknown; + has_waypoints: unknown; + has_recorded_track: unknown; + route_missing: unknown; + stage_ends: unknown; + }, +>(r: R): TourListRow { + return { + ...r, + planned: Number(r.planned), + has_waypoints: Number(r.has_waypoints), + has_recorded_track: Number(r.has_recorded_track), + route_missing: Number(r.route_missing), + // Postgres counts in a bigint, which its driver hands back as text. + stage_ends: Number(r.stage_ends), + }; +} diff --git a/server/src/nest/tours/tour-activities.service.ts b/server/src/nest/tours/tour-activities.service.ts index d15a70603..bd61f6495 100644 --- a/server/src/nest/tours/tour-activities.service.ts +++ b/server/src/nest/tours/tour-activities.service.ts @@ -13,8 +13,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import type { TourActivityAssignRequest, TourActivityRefusal, TourDetailResponse } from '@trek/shared'; /** A refusal with its code in the body, so the app can say what went wrong in its own words. */ -function refused(status: 400 | 409, code: TourActivityRefusal, message: string): DomainError { - return new DomainError(status, message, { code, details: { code } }); +function refused(httpStatus: 400 | 409, code: TourActivityRefusal, message: string): DomainError { + return new DomainError(httpStatus, message, { code, details: { code } }); } /** diff --git a/server/src/nest/tours/tour-activity-stats.mcp.ts b/server/src/nest/tours/tour-activity-stats.mcp.ts index f2442148d..89806ca9c 100644 --- a/server/src/nest/tours/tour-activity-stats.mcp.ts +++ b/server/src/nest/tours/tour-activity-stats.mcp.ts @@ -59,7 +59,7 @@ export class TourActivityStatsMcp { @Tool({ name: 'get_tour_activity_stats', description: - 'The tours the user has done, summed: how many, distance in km, ascent and descent in metres, as a whole and by type, for a period (from and to, YYYY-MM-DD, both included) and one type, or all of them. A tour counts once the last day it is on is over (today is UTC here), and only where the user takes part: a day of the tour names the user among its participants, or names none, which means everyone of the trip. A day naming only others is theirs. basis "plan" means the values come from the planning of the trip, not from a measurement, and must be said so; basis "track" means a recorded track linked to the tour replaced them. A tour on several days counts once; a whole route stays out while a stage planned from it (derived_from_place_id) counts; a paddle tour sums no heights; null means unknown, never zero. Tours the user left out (excluded true) are listed but not summed. uncounted names the tours of the user's trips that are on no day or only on the user's days without a date.', + 'The tours the user has done, summed: how many, distance in km, ascent and descent in metres, as a whole and by type, for a period (from and to, YYYY-MM-DD, both included) and one type, or all of them. A tour counts once the last day it is on is over (today is UTC here), and only where the user takes part: a day of the tour names the user among its participants, or names none, which means everyone of the trip. A day naming only others is theirs. basis "plan" means the values come from the planning of the trip, not from a measurement, and must be said so; basis "track" means a recorded track linked to the tour replaced them. A tour on several days counts once; a whole route stays out while a stage planned from it (derived_from_place_id) counts; a paddle tour sums no heights; null means unknown, never zero. Tours the user left out (excluded true) are listed but not summed. uncounted names the tours of the user\'s trips that are on no day or only on the user\'s days without a date.', inputSchema: { from: query.from, to: query.to, type: query.type }, annotations: TOOL_ANNOTATIONS_READONLY, when: addonGate(ADDON_IDS.TOURS), diff --git a/server/src/nest/tours/tour-change-undo.mcp.ts b/server/src/nest/tours/tour-change-undo.mcp.ts index 1aaa1f9c3..a161a2269 100644 --- a/server/src/nest/tours/tour-change-undo.mcp.ts +++ b/server/src/nest/tours/tour-change-undo.mcp.ts @@ -48,7 +48,7 @@ export class TourChangeUndoMcp { @Tool({ name: 'list_tour_change_steps', description: - 'List the changes saved to a tour with save_tour_change, the latest first (at most listed_at_most): each step with its id, saved_at, base_revision (the revision it was prepared on), revision (the one it raised the tour to) and the changes as the traveller confirmed them. standing says whether it can be taken back: latest (it is the tour\'s latest change; prepare_tour_undo checks the rest), undone (taken back already, undone names the revision that raised the tour to and when), later_change (something was saved on top of it since, in the planner or by another step, so taking it back would lose that) or no_earlier_state (the record of the tour before it cannot be read). Edits saved in the planner are no steps and are not listed; a draft in the planner is never seen here. Reads only.', + "List the changes saved to a tour with save_tour_change, the latest first (at most listed_at_most): each step with its id, saved_at, base_revision (the revision it was prepared on), revision (the one it raised the tour to) and the changes as the traveller confirmed them. standing says whether it can be taken back: latest (it is the tour's latest change; prepare_tour_undo checks the rest), undone (taken back already, undone names the revision that raised the tour to and when), later_change (something was saved on top of it since, in the planner or by another step, so taking it back would lose that) or no_earlier_state (the record of the tour before it cannot be read). Edits saved in the planner are no steps and are not listed; a draft in the planner is never seen here. Reads only.", inputSchema: { tripId: idSchema, placeId }, annotations: TOOL_ANNOTATIONS_READONLY, when: addonGate(ADDON_IDS.TOURS), @@ -89,7 +89,7 @@ export class TourChangeUndoMcp { @Tool({ name: 'undo_tour_change', description: - 'Take back a saved step of a tour that prepare_tour_undo showed and the traveller confirmed: the tour is restored exactly as it was before the step, its line, its stations with their keys, and the distance and walking time stored then, whole or not at all. Call it only after the traveller said yes to taking back exactly that step, with step_id and expected_revision as undo of the preview gave them and confirmed true. Everything is checked again before anything is written: a tour changed since the preview is refused as a conflict (prepare it again), and a step that is no longer the latest change, was taken back already, or would now remove a station visited or skipped meanwhile is refused with the reason; the tour then stays as it is. A step is taken back once, and taking back raises the tour\'s revision like any other save. The answer: undone, step (its id, its revision and undone_revision, the revision taking it back raised the tour to), the tour as now saved (revision, distance_km, walking_minutes, route_pending) and the stations in order with their keys. Read the result with get_tour_plan. It never touches a draft in the planner. Needs place editing permission on the trip.', + "Take back a saved step of a tour that prepare_tour_undo showed and the traveller confirmed: the tour is restored exactly as it was before the step, its line, its stations with their keys, and the distance and walking time stored then, whole or not at all. Call it only after the traveller said yes to taking back exactly that step, with step_id and expected_revision as undo of the preview gave them and confirmed true. Everything is checked again before anything is written: a tour changed since the preview is refused as a conflict (prepare it again), and a step that is no longer the latest change, was taken back already, or would now remove a station visited or skipped meanwhile is refused with the reason; the tour then stays as it is. A step is taken back once, and taking back raises the tour's revision like any other save. The answer: undone, step (its id, its revision and undone_revision, the revision taking it back raised the tour to), the tour as now saved (revision, distance_km, walking_minutes, route_pending) and the stations in order with their keys. Read the result with get_tour_plan. It never touches a draft in the planner. Needs place editing permission on the trip.", inputSchema: { tripId: idSchema, placeId, diff --git a/server/src/nest/tours/tour-change-undo.ts b/server/src/nest/tours/tour-change-undo.ts index 942d0e93f..135992989 100644 --- a/server/src/nest/tours/tour-change-undo.ts +++ b/server/src/nest/tours/tour-change-undo.ts @@ -16,9 +16,7 @@ import { walkStationsOf, type TourWaypoint } from '@trek/shared'; export type StepStanding = 'latest' | 'undone' | 'later_change' | 'no_earlier_state'; /** The standing of a step, the latest one carrying the tour as it kept it before. */ -export type Standing = - | { kind: 'latest'; before: TourChangeStepBefore } - | { kind: Exclude }; +export type Standing = { kind: 'latest'; before: TourChangeStepBefore } | { kind: Exclude }; /** What a recorded step says of itself for its standing. */ interface StepFacts { diff --git a/server/src/nest/tours/tour-horse-routes.service.ts b/server/src/nest/tours/tour-horse-routes.service.ts index ea5d1a98c..fa0461164 100644 --- a/server/src/nest/tours/tour-horse-routes.service.ts +++ b/server/src/nest/tours/tour-horse-routes.service.ts @@ -62,7 +62,7 @@ export class TourHorseRoutesService { /** One query for the box, shared with every request for it until it settles. */ private fetch(key: string, box: FerrataBbox, now: number): Promise { const running = this.pending.get(key); - if (running) return running; + if (running !== undefined) return running; const run = this.query(box).then((answer) => { this.keep(key, answer, now); return answer; diff --git a/server/src/nest/tours/tour-item.helpers.ts b/server/src/nest/tours/tour-item.helpers.ts new file mode 100644 index 000000000..7289150ad --- /dev/null +++ b/server/src/nest/tours/tour-item.helpers.ts @@ -0,0 +1,113 @@ +/** + * The read half of ToursService that needs no database: a tour as it is + * listed, and where it came from as its provenance names it. + */ +import { TOUR_HIKING_PREFERENCES, TOUR_TRACK_MARKS } from '../../db/json-columns'; +import type { TourActivityRefRow } from '../../db/repositories/TourActivities.repository'; +import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository'; +import type { TourListRow, TourProvenanceRow } from '../../db/repositories/Tours.repository'; +import { utcSuffix } from '../../db/types'; +import { decodeJson } from '../../utils/json-column'; +import { activityFacts } from './tour-activities.helpers'; +import { LOW_CONFIDENCE_THRESHOLD } from './tours.helpers'; +import { + tourStageStopsOf, + walkStationFacts, + type BikeProfile, + type PaddleBoatType, + type PaddleLakeStyle, + type PaddleWaterContext, + type RunProfile, + type SkiDiscipline, + type SkiTechnique, + type TourListItem, + type TourOrigin, + type TourProvenanceSource, + type WinterActivity, +} from '@trek/shared'; + +/** MDT-02: the stops a multi-day tour's stages run between; null where its points do not tell them. */ +function stageStopsOrNull(points: Parameters[0]): TourListItem['stage_stops'] { + const stops = tourStageStopsOf(points); + return stops.length > 0 ? stops : null; +} + +/** + * A tour as it is listed; a city walk with the stations its points make and their stays (CW-02, CW-03), + * every tour with the recordings assigned to it and the tour it documents itself (ACT-01). + */ +export function tourItemOf( + r: TourListRow, + points: readonly TourWaypointRow[] = [], + activities: readonly TourActivityRefRow[] = [], +): TourListItem { + return { + place_id: r.place_id, + name: r.name, + description: r.description, + website: r.website, + tour_type: r.tour_type as TourListItem['tour_type'], + distance: r.distance, + elevation_gain: r.elevation_gain, + elevation_loss: r.elevation_loss, + duration: r.duration, + planned_duration_minutes: r.planned_duration_minutes, + break_additional_minutes: r.break_additional_minutes, + difficulty: r.difficulty, + wanderer_ref: r.wanderer_ref, + match_confidence: r.match_confidence, + max_hiking_difficulty: r.max_hiking_difficulty, + derived_from_place_id: r.derived_from_place_id, + origin: r.origin as TourOrigin, + bike_profile: r.bike_profile as BikeProfile | null, + winter_activity: r.winter_activity as WinterActivity | null, + run_profile: r.run_profile as RunProfile | null, + run_pace_seconds_per_km: r.run_pace_seconds_per_km, + ski_discipline: r.ski_discipline as SkiDiscipline | null, + ski_technique: r.ski_technique as SkiTechnique | null, + boat_type: r.boat_type as PaddleBoatType | null, + water_context: r.water_context as PaddleWaterContext | null, + paddle_speed_kmh: r.paddle_speed_kmh, + portage_speed_kmh: r.portage_speed_kmh, + paddle_lake_style: r.paddle_lake_style as PaddleLakeStyle | null, + track_marks: decodeJson(TOUR_TRACK_MARKS, r.track_marks, 'tour'), + has_recorded_track: Boolean(r.has_recorded_track), + planned: Boolean(r.planned), + caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD, + has_waypoints: Boolean(r.has_waypoints), + revision: r.revision, + hiking_preferences: decodeJson(TOUR_HIKING_PREFERENCES, r.hiking_preferences, 'tour'), + ...walkStationFacts(r.tour_type, points), + route_pending: Boolean(r.route_missing) && Boolean(r.has_waypoints), + track_started_at: utcSuffix(r.track_started_at), + track_ended_at: utcSuffix(r.track_ended_at), + ...activityFacts(r.place_id, activities), + // MDT-01: a multi-day tour's stages, one more than the stops that end one. + stage_count: r.stage_ends > 0 ? r.stage_ends + 1 : null, + // MDT-02: the stops they run between, by which a day holds one of them. + stage_stops: r.stage_ends > 0 ? stageStopsOrNull(points) : null, + }; +} + +/** What the source of a tour carried, read from the tour's own record and its import's snapshot row. */ +export function provenanceSource(origin: TourOrigin, row: TourProvenanceRow): TourProvenanceSource { + if (origin === 'gpx') return { kind: 'gpx', fileName: row.origin_file_name }; + if (origin === 'wanderer') { + // The reference `instance#trail` is on the tour itself, so a snapshot whose row is missing still names its source. + const [refInstance, refTrail] = splitWandererRef(row.wanderer_ref); + return { + kind: 'wanderer', + instance: row.source_instance ?? refInstance, + externalId: row.source_external_id ?? refTrail, + externalUrl: row.source_external_url, + externalUpdated: row.source_external_updated, + importedAt: row.source_imported_at, + }; + } + return { kind: 'trek' }; +} + +function splitWandererRef(ref: string | null): [string, string] { + const at = ref?.lastIndexOf('#') ?? -1; + return ref && at > 0 ? [ref.slice(0, at), ref.slice(at + 1)] : ['', '']; +} diff --git a/server/src/nest/tours/tour-plan-read.ts b/server/src/nest/tours/tour-plan-read.ts index 943c03dc1..fd765f1e7 100644 --- a/server/src/nest/tours/tour-plan-read.ts +++ b/server/src/nest/tours/tour-plan-read.ts @@ -9,7 +9,13 @@ */ import type { WalkProgress } from './tour-visits.service'; import { computeTourMetrics, thinnedLine, type GeometryPoint } from './tours.helpers'; -import { walkStationsOf, type TourDetailResponse, type TourFixedLegKind, type TourWaypoint } from '@trek/shared'; +import { + walkStationsOf, + type TourDetailResponse, + type TourFixedLeg, + type TourFixedLegKind, + type TourWaypoint, +} from '@trek/shared'; /** The most points of the saved line an answer carries: its shape without the thousands of points. */ export const TOUR_PLAN_LINE_POINTS = 200; @@ -84,6 +90,11 @@ interface PlanLegSource { recorded: boolean; } +/** A fixed leg's line as points of a line: latitude and longitude, no height. */ +function legLine(geometry: TourFixedLeg['geometry']): GeometryPoint[] { + return geometry.map(([lat, lng]): GeometryPoint => [lat, lng]); +} + /** * The legs between consecutive control points, each saying whether the router * planned it, it follows a recorded track, or it is a fixed line. @@ -100,7 +111,7 @@ export function planLegs(points: readonly TourWaypoint[], { recorded }: PlanLegS to_key: to.key ?? null, kind: leg?.kind ?? unfixed, name: leg?.kind === 'ferrata' ? leg.name : null, - length_km: leg ? roundKm(computeTourMetrics(leg.geometry).distanceKm) : null, + length_km: leg ? roundKm(computeTourMetrics(legLine(leg.geometry)).distanceKm) : null, }; }); } diff --git a/server/src/nest/tours/tour-write.helpers.ts b/server/src/nest/tours/tour-write.helpers.ts new file mode 100644 index 000000000..553681bd5 --- /dev/null +++ b/server/src/nest/tours/tour-write.helpers.ts @@ -0,0 +1,158 @@ +/** + * The write half of ToursService that needs no database: the fields a create + * or an update stores, the checks a write passes before its transaction, and + * the keys its points keep. + */ +import { TOUR_HIKING_PREFERENCES } from '../../db/json-columns'; +import type { TourUpdate } from '../../db/repositories/Tours.repository'; +import { encodeJson } from '../../utils/json-column'; +import { badRequest } from '../common/domain-error'; +import { computeTourMetrics } from './tours.helpers'; +import type { TourCreateCommand } from './tours.service'; +import { + hikingPreferencesOf, + maxTourMinutesFor, + plannedTimeLimitMessage, + plannedTourDurationMinutesSchema, + tourStageCountOf, + tourStageIssues, + tourWebsiteSchema, + type TourWaypoint, + type WinterActivity, +} from '@trek/shared'; + +import { randomUUID } from 'node:crypto'; + +/** MF-08: a save's hiking preferences as stored: a hike's switched-on ones as JSON, else NULL. */ +function hikingFields(input: TourCreateCommand): string | null { + const preferences = input.tour_type === 'hike' ? hikingPreferencesOf(input.hiking_preferences) : null; + return preferences ? encodeJson(TOUR_HIKING_PREFERENCES, preferences) : null; +} + +/** + * HIK-01: a save's winter label as stored. Only a hike keeps one; another type + * takes it off. A hike's save that leaves the field out (a client from before + * the label) keeps the one the tour has, rather than taking it off unasked. + */ +function winterField(input: TourCreateCommand): WinterActivity | null | undefined { + return input.tour_type === 'hike' ? input.winter_activity : null; +} + +/** + * RUN-01: a save's run profile and pace as stored. Only a run keeps them; + * another type takes them off. A run without a pace keeps none: its time stays + * unknown rather than reckoned at a pace nobody chose. + */ +function runFields(input: TourCreateCommand): Pick { + if (input.tour_type !== 'run') return { run_profile: null, run_pace_seconds_per_km: null }; + return { run_profile: input.run_profile ?? null, run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? 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. A + * key sent twice stays with its first point; a later one gets a fresh key, so + * checking one station off never marks another. + */ +export 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 }; + }); +} + +/** + * The route a create or an update writes: metrics derived from the geometry, duration stored in minutes. + * Without a line (CW-11) distance, heights and walking time stay unknown until the planner plans the way. + */ +export function tourRouteFields(input: TourCreateCommand): TourUpdate { + const metrics = input.route_geometry ? computeTourMetrics(input.route_geometry) : null; + // A paddle tour drawn here has its heights read off the banks at the points set, not + // off the water: its ascent and descent stay unknown rather than wrong. + const heightsKnown = input.tour_type !== 'kayak'; + return { + tour_type: input.tour_type, + distance: metrics?.distanceKm ?? null, + elevation_gain: heightsKnown ? (metrics?.elevationGainM ?? null) : null, + elevation_loss: heightsKnown ? (metrics?.elevationLossM ?? null) : null, + duration: input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60), + ...(input.planned_duration_minutes !== undefined + ? { planned_duration_minutes: input.planned_duration_minutes } + : {}), + ...(input.break_additional_minutes !== undefined + ? { break_additional_minutes: input.break_additional_minutes } + : {}), + match_confidence: 1, + max_hiking_difficulty: input.max_hiking_difficulty, + bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null, + winter_activity: winterField(input), + ...runFields(input), + // SKI-02: TREK plans no ski track, so a planned save is never one and takes its classification off. + ski_discipline: null, + ski_technique: null, + boat_type: input.boat_type ?? null, + water_context: input.water_context ?? null, + paddle_speed_kmh: input.paddle_speed_kmh ?? null, + portage_speed_kmh: input.portage_speed_kmh ?? null, + paddle_lake_style: input.paddle_lake_style ?? null, + // MF-08: only a hike keeps preferences, and only those switched on; none is NULL. + hiking_preferences: hikingFields(input), + }; +} + +function validateInformationalMetadata(input: TourCreateCommand): void { + if (input.description != null && input.description.length > 2000) { + throw badRequest('description must be 2000 characters or less'); + } + if (input.website != null && !tourWebsiteSchema.safeParse(input.website).success) { + throw badRequest('website must be a valid HTTPS URL without credentials'); + } +} + +function validatePlannedTimes(input: TourCreateCommand): void { + for (const value of [input.planned_duration_minutes, input.break_additional_minutes]) { + if (value !== undefined && !plannedTourDurationMinutesSchema.safeParse(value).success) { + throw badRequest('planned Tour times must be whole minutes from 0 to 1440'); + } + } + // MDT-01: a multi-day tour is held to a day a stage, a day tour to one day. + const stages = tourStageCountOf(input.waypoints); + if ( + input.planned_duration_minutes == null && + input.duration_seconds != null && + Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) > maxTourMinutesFor(stages) + ) { + throw badRequest(plannedTimeLimitMessage(stages)); + } +} + +/** + * MDT-01: the stage ends sit at stops between start and end, as the contract + * holds a request to; checked here too for a write that did not come through + * it (an assistant's city walk), so no surface can store one out of place. + */ +function validateStageEnds(input: TourCreateCommand): void { + const [issue] = tourStageIssues(input.waypoints); + if (issue) throw badRequest(issue.message); +} + +/** Every check a write passes before its transaction: its texts, its stage ends and its planned times. */ +export function checkTourCommand(input: TourCreateCommand): void { + validateInformationalMetadata(input); + validateStageEnds(input); + validatePlannedTimes(input); +} + +/** Where a tour without a line starts (CW-11): at its first point. */ +export function firstPointOf(points: readonly { lat: number; lng: number }[]): [number, number] | undefined { + const point = points[0]; + return point ? [point.lat, point.lng] : undefined; +} + +/** The file name a file import keeps: the name as uploaded, without a path, cut to what the column shows. */ +export function importFileName(name: string | undefined): string | null { + const base = name?.split('/').pop()?.trim(); + return base ? base.slice(0, 255) : null; +} diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index 378196911..0fed48e6a 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -128,10 +128,7 @@ export class ToursMcp { when: addonGate(ADDON_IDS.TOURS), access: { group: 'trips', mode: 'read' }, }) - async getTourPlan( - { tripId, placeId, dayId }: { tripId: number; placeId: number; dayId?: number }, - ctx: McpContext, - ) { + async getTourPlan({ tripId, placeId, dayId }: { tripId: number; placeId: number; dayId?: number }, ctx: McpContext) { if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess(); let read: PlanRead; try { diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index a85ea3578..ff72c3ccb 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -4,32 +4,19 @@ import { TourStationVisits } from '../../db/entities/TourStationVisits.entity'; import { TourTypes } from '../../db/entities/TourTypes.entity'; import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; import { Tours } from '../../db/entities/Tours.entity'; -import { TOUR_HIKING_PREFERENCES, TOUR_TRACK_MARKS } from '../../db/json-columns'; import type { PlacesRepository } from '../../db/repositories/Places.repository'; -import type { TourActivitiesRepository, TourActivityRefRow } from '../../db/repositories/TourActivities.repository'; +import type { TourActivitiesRepository } from '../../db/repositories/TourActivities.repository'; import type { TourStationVisitsRepository } from '../../db/repositories/TourStationVisits.repository'; import type { TourTypesRepository } from '../../db/repositories/TourTypes.repository'; -import type { - TourWaypointRow, - TourWaypointsRepository, - TourWaypointWithPlaceRow, -} from '../../db/repositories/TourWaypoints.repository'; -import type { - TourDayRow, - TourListRow, - TourProvenanceRow, - ToursRepository, - TourUpdate, -} from '../../db/repositories/Tours.repository'; -import { utcSuffix } from '../../db/types'; -import { decodeJson, encodeJson } from '../../utils/json-column'; +import type { TourWaypointsRepository, TourWaypointWithPlaceRow } from '../../db/repositories/TourWaypoints.repository'; +import type { TourDayRow, ToursRepository, TourUpdate } from '../../db/repositories/Tours.repository'; import { AssignmentsService } from '../assignments/assignments.service'; import { badRequest, DomainError } from '../common/domain-error'; import { toRowId } from '../common/row-id'; import { UnitOfWork } from '../database/unit-of-work'; import { PlaceImportService } from '../place-import/place-import.service'; import { PlacesService } from '../places/places.service'; -import { activityFacts, toActivity, trackTimesFor } from './tour-activities.helpers'; +import { toActivity, trackTimesFor } from './tour-activities.helpers'; import { copiedFacet, copyStart, @@ -39,24 +26,15 @@ import { type TourCopySource, } from './tour-copy.helpers'; import { readTourFile } from './tour-file-import.helpers'; +import { provenanceSource, tourItemOf } from './tour-item.helpers'; import { TourPaddleCheckService } from './tour-paddle-check.service'; import { advanceTourRevision } from './tour-revision'; import { toVisit } from './tour-visits.service'; -import { - computeTourMetrics, - localHashOf, - parseRouteGeometry, - LOW_CONFIDENCE_THRESHOLD, - type GeometryPoint, -} from './tours.helpers'; +import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers'; +import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { - hikingPreferencesOf, - maxTourMinutesFor, - plannedTimeLimitMessage, - plannedTourDurationMinutesSchema, - tourWebsiteSchema, type BikeProfile, type CityWalkCreateRequest, type PaddleBoatType, @@ -73,21 +51,13 @@ import { type TourListItem, type TourOrigin, type TourProvenance, - type TourProvenanceSource, type TourWaypoint, - tourStageCountOf, - tourStageIssues, tourStageKeysOf, - tourStageStopsOf, type WandererDifficulty, - type WinterActivity, - walkStationFacts, walkStationsOf, wandererDifficultyToSac, } from '@trek/shared'; -import { randomUUID } from 'node:crypto'; - export interface ImportGpxAsTourResult { tours: TourListItem[]; caution: boolean; @@ -108,10 +78,13 @@ const HIKE_IMPORT: TourGpxImportFields = { tour_type: 'hike' }; * What a create writes: the request as the contract reads it, or for a city * walk an assistant created from its stations (CW-11) the way the server's * router planned on foot, which has no heights, or no line when it planned - * none, which the planner then plans when the walk is opened. + * none, which the planner then plans when the walk is opened. Its points are + * the tour's points as the rest of the domain reads them, so a change saved + * or taken back from MCP (MCP-T03/T04) hands in its points unconverted. */ -export type TourCreateCommand = Omit & { +export type TourCreateCommand = Omit & { route_geometry: GeometryPoint[] | null; + waypoints: TourWaypoint[]; }; /** A route write checked and ready for its transaction: the write, where the tour starts and its fields. */ @@ -133,52 +106,6 @@ interface CityWalkFootRoute { seconds: number; } -/** MF-08: a save's hiking preferences as stored: a hike's switched-on ones as JSON, else NULL. */ -function hikingFields(input: TourCreateCommand): string | null { - const preferences = input.tour_type === 'hike' ? hikingPreferencesOf(input.hiking_preferences) : null; - return preferences ? encodeJson(TOUR_HIKING_PREFERENCES, preferences) : null; -} - -/** - * HIK-01: a save's winter label as stored. Only a hike keeps one; another type - * takes it off. A hike's save that leaves the field out (a client from before - * the label) keeps the one the tour has, rather than taking it off unasked. - */ -function winterField(input: TourCreateCommand): WinterActivity | null | undefined { - return input.tour_type === 'hike' ? input.winter_activity : null; -} - -/** - * RUN-01: a save's run profile and pace as stored. Only a run keeps them; - * another type takes them off. A run without a pace keeps none: its time stays - * unknown rather than reckoned at a pace nobody chose. - */ -function runFields(input: TourCreateCommand): Pick { - if (input.tour_type !== 'run') return { run_profile: null, run_pace_seconds_per_km: null }; - return { run_profile: input.run_profile ?? null, run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? 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. 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[]): 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 }; - }); -} - -/** MDT-02: the stops a multi-day tour's stages run between; null where its points do not tell them. */ -function stageStopsOrNull(points: Parameters[0]): TourListItem['stage_stops'] { - const stops = tourStageStopsOf(points); - return stops.length > 0 ? stops : null; -} - @Injectable() export class ToursService { private readonly logger = new Logger(ToursService.name); @@ -197,102 +124,6 @@ export class ToursService { private readonly assignments: AssignmentsService, ) {} - /** - * A tour as it is listed; a city walk with the stations its points make and their stays (CW-02, CW-03), - * every tour with the recordings assigned to it and the tour it documents itself (ACT-01). - */ - private toItem( - r: TourListRow, - points: readonly TourWaypointRow[] = [], - activities: readonly TourActivityRefRow[] = [], - ): TourListItem { - return { - place_id: r.place_id, - name: r.name, - description: r.description, - website: r.website, - tour_type: r.tour_type as TourListItem['tour_type'], - distance: r.distance, - elevation_gain: r.elevation_gain, - elevation_loss: r.elevation_loss, - duration: r.duration, - planned_duration_minutes: r.planned_duration_minutes, - break_additional_minutes: r.break_additional_minutes, - difficulty: r.difficulty, - wanderer_ref: r.wanderer_ref, - match_confidence: r.match_confidence, - max_hiking_difficulty: r.max_hiking_difficulty, - derived_from_place_id: r.derived_from_place_id, - origin: r.origin as TourOrigin, - bike_profile: r.bike_profile as BikeProfile | null, - winter_activity: r.winter_activity as WinterActivity | null, - run_profile: r.run_profile as RunProfile | null, - run_pace_seconds_per_km: r.run_pace_seconds_per_km, - ski_discipline: r.ski_discipline as SkiDiscipline | null, - ski_technique: r.ski_technique as SkiTechnique | null, - boat_type: r.boat_type as PaddleBoatType | null, - water_context: r.water_context as PaddleWaterContext | null, - paddle_speed_kmh: r.paddle_speed_kmh, - portage_speed_kmh: r.portage_speed_kmh, - paddle_lake_style: r.paddle_lake_style as PaddleLakeStyle | null, - track_marks: decodeJson(TOUR_TRACK_MARKS, r.track_marks, 'tour'), - has_recorded_track: Boolean(r.has_recorded_track), - planned: Boolean(r.planned), - caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD, - has_waypoints: Boolean(r.has_waypoints), - revision: r.revision, - hiking_preferences: decodeJson(TOUR_HIKING_PREFERENCES, r.hiking_preferences, 'tour'), - ...walkStationFacts(r.tour_type, points), - route_pending: Boolean(r.route_missing) && Boolean(r.has_waypoints), - track_started_at: utcSuffix(r.track_started_at), - track_ended_at: utcSuffix(r.track_ended_at), - ...activityFacts(r.place_id, activities), - // MDT-01: a multi-day tour's stages, one more than the stops that end one. - stage_count: r.stage_ends > 0 ? r.stage_ends + 1 : null, - // MDT-02: the stops they run between, by which a day holds one of them. - stage_stops: r.stage_ends > 0 ? stageStopsOrNull(points) : null, - }; - } - - /** - * The route a create or an update writes: metrics derived from the geometry, duration stored in minutes. - * Without a line (CW-11) distance, heights and walking time stay unknown until the planner plans the way. - */ - private routeFields(input: TourCreateCommand): TourUpdate { - const metrics = input.route_geometry ? computeTourMetrics(input.route_geometry) : null; - // A paddle tour drawn here has its heights read off the banks at the points set, not - // off the water: its ascent and descent stay unknown rather than wrong. - const heightsKnown = input.tour_type !== 'kayak'; - return { - tour_type: input.tour_type, - distance: metrics?.distanceKm ?? null, - elevation_gain: heightsKnown ? (metrics?.elevationGainM ?? null) : null, - elevation_loss: heightsKnown ? (metrics?.elevationLossM ?? null) : null, - duration: input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60), - ...(input.planned_duration_minutes !== undefined - ? { planned_duration_minutes: input.planned_duration_minutes } - : {}), - ...(input.break_additional_minutes !== undefined - ? { break_additional_minutes: input.break_additional_minutes } - : {}), - match_confidence: 1, - max_hiking_difficulty: input.max_hiking_difficulty, - bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null, - winter_activity: winterField(input), - ...runFields(input), - // SKI-02: TREK plans no ski track, so a planned save is never one and takes its classification off. - ski_discipline: null, - ski_technique: null, - boat_type: input.boat_type ?? null, - water_context: input.water_context ?? null, - paddle_speed_kmh: input.paddle_speed_kmh ?? null, - portage_speed_kmh: input.portage_speed_kmh ?? null, - paddle_lake_style: input.paddle_lake_style ?? null, - // MF-08: only a hike keeps preferences, and only those switched on; none is NULL. - hiking_preferences: hikingFields(input), - }; - } - /** * `tour_types` knows every key the contract accepts, most of them disabled. * Checked before the write, so a disabled type is a 400 and not a tour the @@ -303,42 +134,6 @@ export class ToursService { if (!(await this.tourTypesRepo.isEnabled(key))) throw badRequest('Tour type is not available'); } - private validateInformationalMetadata(input: TourCreateCommand): void { - if (input.description != null && input.description.length > 2000) { - throw badRequest('description must be 2000 characters or less'); - } - if (input.website != null && !tourWebsiteSchema.safeParse(input.website).success) { - throw badRequest('website must be a valid HTTPS URL without credentials'); - } - } - - private validatePlannedTimes(input: TourCreateCommand): void { - for (const value of [input.planned_duration_minutes, input.break_additional_minutes]) { - if (value !== undefined && !plannedTourDurationMinutesSchema.safeParse(value).success) { - throw badRequest('planned Tour times must be whole minutes from 0 to 1440'); - } - } - // MDT-01: a multi-day tour is held to a day a stage, a day tour to one day. - const stages = tourStageCountOf(input.waypoints); - if ( - input.planned_duration_minutes == null && - input.duration_seconds != null && - Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) > maxTourMinutesFor(stages) - ) { - throw badRequest(plannedTimeLimitMessage(stages)); - } - } - - /** - * MDT-01: the stage ends sit at stops between start and end, as the contract - * holds a request to; checked here too for a write that did not come through - * it (an assistant's city walk), so no surface can store one out of place. - */ - private validateStageEnds(input: TourCreateCommand): void { - const [issue] = tourStageIssues(input.waypoints); - if (issue) throw badRequest(issue.message); - } - /** All tours (the facet + owning place) for a trip, newest first; a paddle tour with its check bench (PAD-09). */ async listTours(tripId: string): Promise { const tid = toRowId(tripId); @@ -348,7 +143,7 @@ export class ToursService { const activities = await this.activitiesRepo.listForTrip(tid); return await this.paddleChecks.forTrip( tid, - rows.map((r) => this.toItem(r, listedPoints.get(r.place_id) ?? [], activities)), + rows.map((r) => tourItemOf(r, listedPoints.get(r.place_id) ?? [], activities)), ); } @@ -362,9 +157,7 @@ export class ToursService { rows: readonly { place_id: number; tour_type: string; stage_ends?: number }[], ): Promise> { const byPlace = new Map(); - const read = rows - .filter((row) => row.tour_type === 'walk' || (row.stage_ends ?? 0) > 0) - .map((row) => row.place_id); + const read = rows.filter((row) => row.tour_type === 'walk' || (row.stage_ends ?? 0) > 0).map((row) => row.place_id); for (const point of await this.waypointsRepo.listForPlaces(read)) { const points = byPlace.get(point.place_id); if (points) points.push(point); @@ -419,7 +212,7 @@ export class ToursService { const documents = await this.activitiesRepo.findByTrack(row.place_id); const refs = documents ? [...activities, documents] : activities; const checked = await this.paddleChecks.forTour( - this.toItem(row, stored, refs), + tourItemOf(row, stored, refs), stored, place?.route_geometry ?? null, ); @@ -470,14 +263,12 @@ export class ToursService { // The controller's TripAccessGuard already resolved this trip id. const tid = toRowId(tripId)!; await this.assertTourTypeEnabled(input.tour_type); - this.validateInformationalMetadata(input); - this.validateStageEnds(input); - this.validatePlannedTimes(input); + checkTourCommand(input); const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints); if (!start || (input.route_geometry === null && input.waypoints.length < 2)) { throw badRequest('A tour needs at least two points'); } - const fields = this.routeFields(input); + const fields = tourRouteFields(input); const derivedFrom = input.derived_from_place_id ?? null; const placeId = await this.uow.transactional(async () => { @@ -523,7 +314,7 @@ export class ToursService { this.logger.warn(`Committed Tour ${placeId}: place notification failed`); } const checked = await this.paddleChecks.forTour( - this.toItem(row, waypoints), + tourItemOf(row, waypoints), waypoints, place.route_geometry ?? null, ); @@ -806,12 +597,10 @@ export class ToursService { */ async routeWrite(input: TourCreateCommand): Promise { await this.assertTourTypeEnabled(input.tour_type); - this.validateInformationalMetadata(input); - this.validateStageEnds(input); - this.validatePlannedTimes(input); + checkTourCommand(input); const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints); if (!start) throw badRequest('A tour needs at least two points'); - return { input, start, fields: this.routeFields(input) }; + return { input, start, fields: tourRouteFields(input) }; } /** @@ -1021,7 +810,7 @@ export class ToursService { track_ended_at: recorded?.ended ?? null, }); tours.push( - this.toItem({ + tourItemOf({ place_id: place.id, name: place.name, description: place.description ?? null, @@ -1046,6 +835,7 @@ export class ToursService { has_recorded_track: 0, planned: 0, has_waypoints: 0, + stage_ends: 0, route_missing: 0, revision: 1, hiking_preferences: null, @@ -1104,38 +894,3 @@ export class ToursService { }; } } - -/** Where a tour without a line starts (CW-11): at its first point. */ -function firstPointOf(points: readonly { lat: number; lng: number }[]): [number, number] | undefined { - const point = points[0]; - return point ? [point.lat, point.lng] : undefined; -} - -/** The file name a file import keeps: the name as uploaded, without a path, cut to what the column shows. */ -function importFileName(name: string | undefined): string | null { - const base = name?.split('/').pop()?.trim(); - return base ? base.slice(0, 255) : null; -} - -/** What the source of a tour carried, read from the tour's own record and its import's snapshot row. */ -function provenanceSource(origin: TourOrigin, row: TourProvenanceRow): TourProvenanceSource { - if (origin === 'gpx') return { kind: 'gpx', fileName: row.origin_file_name }; - if (origin === 'wanderer') { - // The reference `instance#trail` is on the tour itself, so a snapshot whose row is missing still names its source. - const [refInstance, refTrail] = splitWandererRef(row.wanderer_ref); - return { - kind: 'wanderer', - instance: row.source_instance ?? refInstance, - externalId: row.source_external_id ?? refTrail, - externalUrl: row.source_external_url, - externalUpdated: row.source_external_updated, - importedAt: row.source_imported_at, - }; - } - return { kind: 'trek' }; -} - -function splitWandererRef(ref: string | null): [string, string] { - const at = ref?.lastIndexOf('#') ?? -1; - return ref && at > 0 ? [ref.slice(0, at), ref.slice(at + 1)] : ['', '']; -} diff --git a/server/src/nest/tours/wanderer-watch.service.ts b/server/src/nest/tours/wanderer-watch.service.ts index f108950a0..67a776456 100644 --- a/server/src/nest/tours/wanderer-watch.service.ts +++ b/server/src/nest/tours/wanderer-watch.service.ts @@ -84,7 +84,13 @@ export class WandererWatchService { @InjectRepository(TourSourceHints) private readonly hints: TourSourceHintsRepository, ) {} - /** The cron's entry: every connection that may be asked now, one after another. */ + /** + * The cron's entry: every connection that may be asked now, one after another. + * + * @txIndependent one transaction per user: each user's hints land whole with + * the note of the run, after a look at their own instance over the network, and + * one user that fails keeps no other from theirs. + */ async run(now: Date = new Date()): Promise { if (this.running || !(await this.addons.isWandererAvailable())) return; this.running = true; @@ -137,7 +143,10 @@ export class WandererWatchService { ); await this.wanderer.noteWatchRun(userId, { lastRunAt: dbNow(now), failures: 0, pausedUntil: null }); }); - await this.announce(userId, findings.filter(({ source, verdict }) => isNewHint(source, verdict))); + await this.announce( + userId, + findings.filter(({ source, verdict }) => isNewHint(source, verdict)), + ); } /** diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 73e091eea..c9910d116 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -733,9 +733,7 @@ export class TripsService { if (newPlaceId) await this.tagsRepo.insertIgnore(newPlaceId, [t.tag_id]); // TP47 } - // A Tour is its place plus the `tours` facet, its import record, its obstacle decisions, its packing link and - // the waypoints its route was planned through. The facet row goes first, since the others reference it; - // without them it is no Tour. + // The `tours` facet goes first: the import record, obstacle decisions, packing link and waypoints need it. const oldTours = await this.toursRepo.listRowsForTrip(Number(sourceTripId)); const copiedTours = new Map(); for (const { place_id, ...tour } of oldTours) { @@ -744,7 +742,9 @@ export class TripsService { await this.toursRepo.insertCopy(newPlaceId, tour, placeMap); copiedTours.set(place_id, newPlaceId); } - for (const [from, to] of copiedTours) await this.toursRepo.copyTourRelations(from, to); + await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copySource(from, to))); + await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copyObstacleReviews(from, to))); + await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copyPackingLink(from, to))); const waypointsByPlace = new Map(); for (const { place_id, ...waypoint } of await this.tourWaypointsRepo.listForTrip(Number(sourceTripId))) { @@ -767,8 +767,7 @@ export class TripsService { day_id: newDayId, place_id: newPlaceId, order_index: a.order_index, notes: a.notes, reservation_status: a.reservation_status, reservation_notes: a.reservation_notes, reservation_datetime: a.reservation_datetime, assignment_time: a.assignment_time, - assignment_end_time: a.assignment_end_time, end_day: a.end_day ?? 0, - tour_stage_key: a.tour_stage_key, + assignment_end_time: a.assignment_end_time, end_day: a.end_day ?? 0, tour_stage_key: a.tour_stage_key, }); assignmentMap.set(a.id, newAssignmentId); } diff --git a/server/src/nest/wanderer/wanderer.service.ts b/server/src/nest/wanderer/wanderer.service.ts index 4ad9bebf8..61cf5ccf2 100644 --- a/server/src/nest/wanderer/wanderer.service.ts +++ b/server/src/nest/wanderer/wanderer.service.ts @@ -49,16 +49,14 @@ export interface WandererTrailDownload { * refused or is no longer the user's own. */ export type WandererTrailLook = - | { kind: 'record'; name: string; description: string | null; updated: string | null } - | { kind: 'gone' }; + { kind: 'record'; name: string; description: string | null; updated: string | null } | { kind: 'gone' }; /** * WAN-02: what one look of the watch read: a look per trail, or why the * instance itself could not be asked, for the watch to back off from. */ export type WandererWatchLooks = - | { ok: true; looks: Map } - | { ok: false; code: WandererErrorCode }; + { ok: true; looks: Map } | { ok: false; code: WandererErrorCode }; /** WAN-02: the background watch of one user's connection. */ interface WandererWatchState { diff --git a/server/tests/e2e/tours-activity-stats.e2e.test.ts b/server/tests/e2e/tours-activity-stats.e2e.test.ts index 7a7ee5685..f7f24ebe0 100644 --- a/server/tests/e2e/tours-activity-stats.e2e.test.ts +++ b/server/tests/e2e/tours-activity-stats.e2e.test.ts @@ -8,7 +8,6 @@ * Fixtures: user 1 owns trip 5 (two dated days, one without a date), user 2 is * a member, user 3 has no access. */ -import { db } from '../../src/db/database'; import { AssignmentParticipants } from '../../src/db/entities/AssignmentParticipants.entity'; import { Days } from '../../src/db/entities/Days.entity'; import { Places } from '../../src/db/entities/Places.entity'; @@ -20,6 +19,7 @@ import { JourneyDomainService } from '../../src/nest/journey/journey-domain.serv 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 { makeDayAssignment } from '../helpers/factories/itinerary'; import { countRows, deleteRows, insertRow, insertRows } from '../helpers/factories/rows'; import { setAddonEnabled } from '../helpers/factories/settings'; @@ -36,10 +36,6 @@ import type { Server } from 'http'; import request from 'supertest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../src/db/database', async () => { - const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); - return buildDbMock(createSnapshotTestDb()); -}); vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), @@ -48,6 +44,7 @@ vi.mock('../../src/nest/audit/audit-log.logger', () => ({ logWarn: vi.fn(), })); +const db = createSnapshotTestDb(); const journeyHooks = { reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined), onPlaceCreated: vi.fn().mockResolvedValue(undefined), diff --git a/server/tests/e2e/tours-activity.e2e.test.ts b/server/tests/e2e/tours-activity.e2e.test.ts index 382624900..54f1e9c2e 100644 --- a/server/tests/e2e/tours-activity.e2e.test.ts +++ b/server/tests/e2e/tours-activity.e2e.test.ts @@ -8,7 +8,6 @@ * Fixtures: user 1 owns trip 5 (two days), user 2 is a member whom the * permission spy denies 'place_edit', user 3 has no access. */ -import { db } from '../../src/db/database'; import { Days } from '../../src/db/entities/Days.entity'; import { Places } from '../../src/db/entities/Places.entity'; import { TourActivities } from '../../src/db/entities/TourActivities.entity'; @@ -20,6 +19,7 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi 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 { makeDayAssignment } from '../helpers/factories/itinerary'; import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows'; import { setAddonEnabled } from '../helpers/factories/settings'; @@ -36,10 +36,6 @@ import type { Server } from 'http'; import request from 'supertest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; -vi.mock('../../src/db/database', async () => { - const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); - return buildDbMock(createSnapshotTestDb()); -}); vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), @@ -48,6 +44,7 @@ vi.mock('../../src/nest/audit/audit-log.logger', () => ({ logWarn: vi.fn(), })); +const db = createSnapshotTestDb(); const journeyHooks = { reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined), onPlaceCreated: vi.fn().mockResolvedValue(undefined), diff --git a/server/tests/e2e/tours-horse.e2e.test.ts b/server/tests/e2e/tours-horse.e2e.test.ts index bd9fee185..de5f94751 100644 --- a/server/tests/e2e/tours-horse.e2e.test.ts +++ b/server/tests/e2e/tours-horse.e2e.test.ts @@ -325,7 +325,12 @@ describe('Horse tours e2e (real guards + temp SQLite)', () => { .field('tour_type', 'horse') .attach('file', Buffer.from(GPX), { filename: 'ride.gpx', contentType: 'application/gpx+xml' }); expect(res.status).toBe(201); - expect(res.body.tours[0]).toMatchObject({ tour_type: 'horse', origin: 'gpx', duration: null, bike_profile: null }); + expect(res.body.tours[0]).toMatchObject({ + tour_type: 'horse', + origin: 'gpx', + duration: null, + bike_profile: null, + }); }); }); diff --git a/server/tests/e2e/tours-run.e2e.test.ts b/server/tests/e2e/tours-run.e2e.test.ts index ced30dc2a..646967241 100644 --- a/server/tests/e2e/tours-run.e2e.test.ts +++ b/server/tests/e2e/tours-run.e2e.test.ts @@ -172,7 +172,11 @@ describe('Runs e2e (real guards + temp SQLite)', () => { }); const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER)); expect(list.status).toBe(200); - expect(list.body.tours[0]).toMatchObject({ place_id: placeId, run_profile: 'running', run_pace_seconds_per_km: 360 }); + expect(list.body.tours[0]).toMatchObject({ + place_id: placeId, + run_profile: 'running', + run_pace_seconds_per_km: 360, + }); const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)); expect(detail.body.tour).toMatchObject({ tour_type: 'run', run_profile: 'running', duration: 48 }); expect(detail.body.waypoints).toHaveLength(2); diff --git a/server/tests/e2e/tours-ski.e2e.test.ts b/server/tests/e2e/tours-ski.e2e.test.ts index 570feb8e7..8572b9a0f 100644 --- a/server/tests/e2e/tours-ski.e2e.test.ts +++ b/server/tests/e2e/tours-ski.e2e.test.ts @@ -195,16 +195,17 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => { expect(opened.provenance).toMatchObject({ origin: 'gpx', source: { kind: 'gpx', fileName: 'hut.gpx' } }); }); - it('TOURS-SKI-E2E-002: a cross-country track keeps its technique, unknown included', async () => { - for (const technique of ['classic', 'skating', 'unknown']) { + it.each(['classic', 'skating', 'unknown'])( + 'TOURS-SKI-E2E-002: a cross-country track keeps its technique, unknown included: %s', + async (technique) => { const placeId = await importSki({ ski_discipline: 'cross_country', ski_technique: technique }); expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_discipline: 'cross_country', ski_technique: technique, }); await deleteRows(orm, Places); - } - }); + }, + ); it('TOURS-SKI-E2E-003: refuses a ski import without its discipline or with a technique it has not, and ski fields on a hike', async () => { const refused = await Promise.all( diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts index ab88e404c..18dcfeb40 100644 --- a/server/tests/helpers/factories.ts +++ b/server/tests/helpers/factories.ts @@ -388,7 +388,9 @@ export function createDayAssignment( }; const orderIndex = overrides.order_index ?? (maxOrder.max !== null ? maxOrder.max + 1 : 0); const result = db - .prepare('INSERT INTO day_assignments (day_id, place_id, order_index, notes, tour_stage_key) VALUES (?, ?, ?, ?, ?)') + .prepare( + 'INSERT INTO day_assignments (day_id, place_id, order_index, notes, tour_stage_key) VALUES (?, ?, ?, ?, ?)', + ) .run(dayId, placeId, orderIndex, overrides.notes ?? null, overrides.tour_stage_key ?? null); return db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(result.lastInsertRowid) as TestDayAssignment; } diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index 05e0f91c0..5dacd337a 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -448,14 +448,20 @@ describe('ToursRepository writes', () => { const place = createPlace(testDb, trip.id, { name: 'River loop' }); const run = { ...METRICS, tour_type: 'run', max_hiking_difficulty: null }; await repo.insertTour({ place_id: place.id, ...run, run_profile: 'running', run_pace_seconds_per_km: 360 }); - expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ run_profile: 'running', run_pace_seconds_per_km: 360 }); + expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ + run_profile: 'running', + run_pace_seconds_per_km: 360, + }); const faster = { ...HIKE_UPDATE, ...run, run_profile: 'running' as const, run_pace_seconds_per_km: 300 }; expect(await repo.updateInTrip(trip.id, place.id, faster)).toBe(true); expect((await repo.listForTrip(trip.id))[0]).toMatchObject({ tour_type: 'run', run_pace_seconds_per_km: 300 }); expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true); - expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null }); + expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ + run_profile: null, + run_pace_seconds_per_km: null, + }); }); it('TOURREPO-024: a ski track keeps its classification, is classified again only in its own trip, and a route edit takes it off (SKI-02)', async () => { @@ -682,8 +688,8 @@ describe('ToursRepository writes', () => { const plainCopy = createPlace(testDb, other.id); await repo.insertTour({ ...METRICS, place_id: plainCopy.id }); - await repo.copyTourRelations(linked.id, linkedCopy.id); - await repo.copyTourRelations(plain.id, plainCopy.id); + await repo.copyPackingLink(linked.id, linkedCopy.id); + await repo.copyPackingLink(plain.id, plainCopy.id); const source = await findRow(t, TourPackingLinks, { place: linked.id }); expect(await findRow(t, TourPackingLinks, { place: linkedCopy.id })).toMatchObject({ diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index 4bdce3279..40fd88ae7 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -13,8 +13,8 @@ import { db as testDb } from '../../../src/db/database'; 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 { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity'; +import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity'; import { Users } from '../../../src/db/entities/Users.entity'; @@ -416,7 +416,12 @@ describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => { expect(await svc.createAssignment(day.id, ordinary.id)).toMatchObject({ tour_stage_key: null }); }); - it('ASG-SVC-053: refuses a stage the tour does not have: its last stop, a stop ending none, a plain place, a day tour', async () => { + it.each([ + ['its last stop', 'tour', 'stop-end'], + ['a stop ending none', 'tour', 'stop-unknown'], + ['a plain place', 'plain', 'stop-start'], + ['a day tour', 'dayTour', 'day-start'], + ] as const)('ASG-SVC-053: refuses a stage the tour does not have: %s', async (_label, which, key) => { const { trip, day, place } = fixture(); await hutToHut(place.id); const plain = createPlace(testDb, trip.id, { name: 'Ordinary place' }); @@ -426,18 +431,12 @@ describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => { { place: dayTour.id, lat: 47, lng: 11, role: 'start', sequence: 0, station_key: 'day-start' }, { place: dayTour.id, lat: 47.1, lng: 11, role: 'end', sequence: 1, station_key: 'day-end' }, ]); - for (const [placeId, key] of [ - [place.id, 'stop-end'], - [place.id, 'stop-unknown'], - [plain.id, 'stop-start'], - [dayTour.id, 'day-start'], - ] as const) { - const refusal = await catchDomainError(() => svc.createAssignment(day.id, placeId, null, { tourStageKey: key })); - expect(refusal instanceof DomainError && [refusal.getStatus(), refusal.publicMessage]).toEqual([ - 400, - 'Tour has no such stage', - ]); - } + const placeId = { tour: place.id, plain: plain.id, dayTour: dayTour.id }[which]; + const refusal = await catchDomainError(() => svc.createAssignment(day.id, placeId, null, { tourStageKey: key })); + expect(refusal instanceof DomainError && [refusal.getStatus(), refusal.publicMessage]).toEqual([ + 400, + 'Tour has no such stage', + ]); expect(await countRows(await orm(), DayAssignments, {})).toBe(0); }); @@ -467,7 +466,7 @@ describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => { await hutToHut(place.id); const secondDay = createDay(testDb, trip.id); const stage = await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-two' }); - const { assignment } = await svc.moveAssignment(stage!.id, secondDay.id, 0); + const { assignment } = await svc.moveAssignment(stage.id, secondDay.id, 0); expect(assignment).toMatchObject({ day_id: secondDay.id, tour_stage_key: 'stop-hut-two' }); }); @@ -477,9 +476,7 @@ describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => { const secondDay = createDay(testDb, trip.id); await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-start' }); const second = await svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-hut-one' }); - expect(await svc.dropStaleTourStages(place.id, ['stop-start'])).toEqual([ - { id: second!.id, day_id: secondDay.id }, - ]); + expect(await svc.dropStaleTourStages(place.id, ['stop-start'])).toEqual([{ id: second.id, day_id: secondDay.id }]); expect(await countRows(await orm(), DayAssignments, { place: place.id })).toBe(1); }); }); diff --git a/server/tests/unit/nest/collection-tours.service.test.ts b/server/tests/unit/nest/collection-tours.service.test.ts index 490dbf072..ab4959f98 100644 --- a/server/tests/unit/nest/collection-tours.service.test.ts +++ b/server/tests/unit/nest/collection-tours.service.test.ts @@ -329,9 +329,7 @@ describe('CollectionToursService', () => { // Gone between the check and the write: the write leaves no half-made tour behind. const repo = t.moduleRef.get(getRepositoryToken(CollectionTours), { strict: false }); const find = repo.findInCollection.bind(repo); - vi.spyOn(repo, 'findInCollection') - .mockImplementationOnce(find) - .mockResolvedValueOnce(undefined); + vi.spyOn(repo, 'findInCollection').mockImplementationOnce(find).mockResolvedValueOnce(undefined); const before = await countRows(orm, Places); expect(await refusal(service.takeOver(String(f.next.id), f.owner.id, body))).toEqual({ status: 404, diff --git a/server/tests/unit/nest/tour-activities.mcp.test.ts b/server/tests/unit/nest/tour-activities.mcp.test.ts index 8863ea4f3..3fb2e77e7 100644 --- a/server/tests/unit/nest/tour-activities.mcp.test.ts +++ b/server/tests/unit/nest/tour-activities.mcp.test.ts @@ -9,8 +9,8 @@ import { TourActivities } from '../../../src/db/entities/TourActivities.entity'; import type { McpContext } from '../../../src/nest-mcp'; import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service'; import { TourActivitiesMcp } from '../../../src/nest/tours/tour-activities.mcp'; -import { ToursModule } from '../../../src/nest/tours/tours.module'; import { ToursMcp } from '../../../src/nest/tours/tours.mcp'; +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'; diff --git a/server/tests/unit/nest/tour-activity-stats.mcp.test.ts b/server/tests/unit/nest/tour-activity-stats.mcp.test.ts index fac7d72a7..471d569d0 100644 --- a/server/tests/unit/nest/tour-activity-stats.mcp.test.ts +++ b/server/tests/unit/nest/tour-activity-stats.mcp.test.ts @@ -48,23 +48,24 @@ function read(result: { content: Array<{ type: string; text: string }>; isError? return result.isError ? { error: text } : JSON.parse(text); } +/** A tour of the trip on a day of its own, with 500 m up and down; answers its place. */ +async function tourOnDay(tripId: number, name: string, type: 'hike' | 'kayak', distance: number, date: string) { + const day = await makeDay(orm(), tripId, { date }); + const place = await makePlace(orm(), tripId, { name }); + await makeTour(orm(), place.id, { tourTypeRef: type, distance, elevation_gain: 500, elevation_loss: 500 }); + await makeDayAssignment(orm(), day.id, place.id); + return place.id; +} + /** A hike and a paddle tour done in July, a hike still ahead, and a trip somebody else owns. */ async function fixture() { const { user } = await makeUser(orm()); const { user: stranger } = await makeUser(orm()); const trip = await makeTrip(orm(), user.id, { title: 'Alps' }); const tours: Record = {}; - for (const [name, type, distance, date] of [ - ['Ridge', 'hike', 12, '2026-07-02'], - ['Lake', 'kayak', 9, '2026-07-04'], - ['Summit', 'hike', 15, '2026-07-20'], - ] as const) { - const day = await makeDay(orm(), trip.id, { date }); - const place = await makePlace(orm(), trip.id, { name }); - await makeTour(orm(), place.id, { tourTypeRef: type, distance, elevation_gain: 500, elevation_loss: 500 }); - await makeDayAssignment(orm(), day.id, place.id); - tours[name] = place.id; - } + tours.Ridge = await tourOnDay(trip.id, 'Ridge', 'hike', 12, '2026-07-02'); + tours.Lake = await tourOnDay(trip.id, 'Lake', 'kayak', 9, '2026-07-04'); + tours.Summit = await tourOnDay(trip.id, 'Summit', 'hike', 15, '2026-07-20'); const closed = await makeTrip(orm(), stranger.id); const hidden = await makePlace(orm(), closed.id, { name: 'Hidden' }); await makeTour(orm(), hidden.id); diff --git a/server/tests/unit/nest/tour-change-save.mcp.test.ts b/server/tests/unit/nest/tour-change-save.mcp.test.ts index 78fdf72fa..144cb34bc 100644 --- a/server/tests/unit/nest/tour-change-save.mcp.test.ts +++ b/server/tests/unit/nest/tour-change-save.mcp.test.ts @@ -288,8 +288,8 @@ describe('TourChangeMcp.saveTourChange (MCP-T03)', () => { { ...request, expected_revision: 2 }, { ...request, proposal_id: 'f'.repeat(32) }, ]; - for (const body of altered) { - const err = await refusal(saveAs(f.trip.id, f.walk.id, body, f.user.id)); + const refusals = await Promise.all(altered.map((body) => refusal(saveAs(f.trip.id, f.walk.id, body, f.user.id)))); + for (const err of refusals) { expect([err.getStatus(), err.code]).toEqual([422, TOUR_CHANGE_NOT_PREPARED]); expect(err.publicMessage).toMatch(/^These changes are not the proposal that was prepared\. Nothing was saved/); } diff --git a/server/tests/unit/nest/tour-change-undo.mcp.test.ts b/server/tests/unit/nest/tour-change-undo.mcp.test.ts index f16732fcc..3bbed1ffb 100644 --- a/server/tests/unit/nest/tour-change-undo.mcp.test.ts +++ b/server/tests/unit/nest/tour-change-undo.mcp.test.ts @@ -303,8 +303,9 @@ describe('TourChangeUndoMcp (MCP-T04)', () => { [first.step.id, 'later_change'], ]); const before = await untouched(f.walk.id); - for (const attempt of [() => prepared(f, first.step.id), () => undone(f, confirmed(first.step.id, 5))]) { - const err = await refusal(attempt()); + const asPrepared = await refusal(prepared(f, first.step.id)); + const asUndone = await refusal(undone(f, confirmed(first.step.id, 5))); + for (const err of [asPrepared, asUndone]) { expect([err.getStatus(), err.code, err.details?.revision]).toEqual([409, TOUR_REVISION_CONFLICT, 5]); expect(err.publicMessage).toContain('(revision 4 then, 5 now). Nothing changed: only the latest change'); } @@ -349,8 +350,9 @@ describe('TourChangeUndoMcp (MCP-T04)', () => { await markStationVisit(orm(), f.onDay1.id, bakeryKey, 'visited'); const before = await untouched(f.walk.id); - for (const attempt of [() => prepared(f, save.step.id), () => undone(f, confirmed(save.step.id, 4))]) { - const err = await refusal(attempt()); + const asPrepared = await refusal(prepared(f, save.step.id)); + const asUndone = await refusal(undone(f, confirmed(save.step.id, 4))); + for (const err of [asPrepared, asUndone]) { expect([err.getStatus(), err.code]).toEqual([409, TOUR_CHANGE_UNDO_REFUSED]); expect(err.details?.refused).toEqual([ { @@ -486,7 +488,7 @@ describe('TourChangeUndoMcp (MCP-T04)', () => { ['tripId', 'placeId', 'step_id', 'expected_revision', 'confirmed'], ], ] as const; - for (const [method, name, readOnly, mode, fields] of tools) { + const gates = tools.map(([method, name, readOnly, mode, fields]) => { const entry = getEntry(TourChangeUndoMcp as unknown as ClassRef, method); if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`); const { options } = entry; @@ -498,9 +500,10 @@ describe('TourChangeUndoMcp (MCP-T04)', () => { expect(Object.keys(options.inputSchema as object)).toEqual([...fields]); expect(options.description).toContain('planner'); const isAddonEnabled = vi.fn(async (id: string) => id === 'tours'); - expect(await options.when?.(ctx(1), { addons: { isAddonEnabled } })).toBe(true); - expect(isAddonEnabled).toHaveBeenCalledWith('tours'); - } + return { isAddonEnabled, open: options.when?.(ctx(1), { addons: { isAddonEnabled } }) }; + }); + expect(await Promise.all(gates.map((gate) => gate.open))).toEqual([true, true, true]); + for (const { isAddonEnabled } of gates) expect(isAddonEnabled).toHaveBeenCalledWith('tours'); const entry = getEntry(TourChangeUndoMcp as unknown as ClassRef, 'undoTourChange'); if (!entry || entry.kind !== 'tool') throw new Error('undoTourChange is no MCP tool'); // The fields are zod schemas from @trek/shared, read here by what they answer. diff --git a/server/tests/unit/nest/tour-change.mcp.test.ts b/server/tests/unit/nest/tour-change.mcp.test.ts index 19bdb0566..2d3735e1e 100644 --- a/server/tests/unit/nest/tour-change.mcp.test.ts +++ b/server/tests/unit/nest/tour-change.mcp.test.ts @@ -51,14 +51,16 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(db); // Every leg the router plans is 500 m and 400 s on foot. - route.mockReset().mockImplementation( - async (_userId: number, _tripId: number, _dayId: number, points: Array<{ lat: number; lng: number }>) => ({ - leg: { line: points.map((point): [number, number] => [point.lat, point.lng]) }, - parts: points.slice(1).map(() => ({ distance: 500, duration: 400 })), - snapped: points.map(() => ({ meters: 4 })), - avoidMissed: [], - }), - ); + route + .mockReset() + .mockImplementation( + async (_userId: number, _tripId: number, _dayId: number, points: Array<{ lat: number; lng: number }>) => ({ + leg: { line: points.map((point): [number, number] => [point.lat, point.lng]) }, + parts: points.slice(1).map(() => ({ distance: 500, duration: 400 })), + snapped: points.map(() => ({ meters: 4 })), + avoidMissed: [], + }), + ); }); afterAll(async () => { await t.close(); diff --git a/server/tests/unit/nest/tour-horse-routes.controller.test.ts b/server/tests/unit/nest/tour-horse-routes.controller.test.ts index a1d9979ef..211e9f5a9 100644 --- a/server/tests/unit/nest/tour-horse-routes.controller.test.ts +++ b/server/tests/unit/nest/tour-horse-routes.controller.test.ts @@ -4,10 +4,7 @@ import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator' import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard'; import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract'; import { TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard'; -import { - parseHorseRoutesQuery, - TourHorseRoutesController, -} from '../../../src/nest/tours/tour-horse-routes.controller'; +import { parseHorseRoutesQuery, TourHorseRoutesController } from '../../../src/nest/tours/tour-horse-routes.controller'; import type { TourHorseRoutesService } from '../../../src/nest/tours/tour-horse-routes.service'; import { HttpException } from '@nestjs/common'; import { GUARDS_METADATA } from '@nestjs/common/constants'; diff --git a/server/tests/unit/nest/tour-horse-routes.service.test.ts b/server/tests/unit/nest/tour-horse-routes.service.test.ts index 1c34969f6..6868c95f4 100644 --- a/server/tests/unit/nest/tour-horse-routes.service.test.ts +++ b/server/tests/unit/nest/tour-horse-routes.service.test.ts @@ -92,7 +92,8 @@ describe('TourHorseRoutesService (HOR-01)', () => { it('HORSE-SVC-005: keeps a bounded number of answers, letting the one kept longest go', async () => { query.mockResolvedValue([]); const nth = (n: number) => ({ south: 40 + n * 0.1, west: 5, north: 40.05 + n * 0.1, east: 5.05 }); - for (let n = 0; n <= HORSE_ROUTES_KEPT_MAX; n += 1) await service.list(nth(n), NOW); + // Asked all at once, the answers are kept in the order they were asked for. + await Promise.all(Array.from({ length: HORSE_ROUTES_KEPT_MAX + 1 }, (_, n) => service.list(nth(n), NOW))); expect(query).toHaveBeenCalledTimes(HORSE_ROUTES_KEPT_MAX + 1); await service.list(nth(HORSE_ROUTES_KEPT_MAX), NOW); await service.list(nth(1), NOW); @@ -107,7 +108,12 @@ describe('TourHorseRoutesService (HOR-01)', () => { const wayCount = Math.ceil((HORSE_ROUTES_KEPT_POINTS_MAX / 2 + 1) / perWay); const ids = Array.from({ length: wayCount }, (_, w) => w + 1); const dense = [ - { type: 'relation', id: 7, tags: { route: 'horse' }, members: ids.map((ref) => ({ type: 'way', ref, role: '' })) }, + { + type: 'relation', + id: 7, + tags: { route: 'horse' }, + members: ids.map((ref) => ({ type: 'way', ref, role: '' })), + }, ...ids.map((id) => ({ type: 'way', id, diff --git a/server/tests/unit/nest/tour-packing-link.service.test.ts b/server/tests/unit/nest/tour-packing-link.service.test.ts index b88cf3480..04f27e2c7 100644 --- a/server/tests/unit/nest/tour-packing-link.service.test.ts +++ b/server/tests/unit/nest/tour-packing-link.service.test.ts @@ -117,11 +117,14 @@ describe('TourPackingLinkService', () => { it('TPL-SVC-003: a category the user cannot see an item of is refused, a private one of another member included', async () => { const f = await fixture(); - for (const category of ['Climbing', 'hiking gear', 'Secret']) { - await expect( - links.link(String(f.trip.id), String(f.hike.id), { category }, f.owner.id), - ).rejects.toMatchObject({ status: 400, publicMessage: 'Packing category not found' }); - } + const refused = (category: string) => + expect(links.link(String(f.trip.id), String(f.hike.id), { category }, f.owner.id)).rejects.toMatchObject({ + status: 400, + publicMessage: 'Packing category not found', + }); + await refused('Climbing'); + await refused('hiking gear'); + await refused('Secret'); // The member who keeps it may link it. const own = await links.link(String(f.trip.id), String(f.hike.id), { category: 'Secret' }, f.member.id); expect(own.packing_link?.category).toBe('Secret'); @@ -163,18 +166,15 @@ describe('TourPackingLinkService', () => { it('TPL-SVC-006: a tour of another trip, a plain place or a malformed id is not found', async () => { const f = await fixture(); - const attempts = [ - () => links.read(f.trip.id, f.otherTour.id), - () => links.read(f.trip.id, f.cafe.id), - () => links.read('abc', f.hike.id), - () => links.read(f.trip.id, '1.5'), - () => links.link(String(f.trip.id), String(f.otherTour.id), { category: null }, f.owner.id), - () => links.unlink(String(f.trip.id), String(f.cafe.id)), - () => links.view(f.other.id, f.hike.id, f.owner.id), - ]; - for (const attempt of attempts) { - await expect(attempt()).rejects.toMatchObject({ status: 404, publicMessage: 'Tour not found' }); - } + const notFound = (attempt: Promise) => + expect(attempt).rejects.toMatchObject({ status: 404, publicMessage: 'Tour not found' }); + await notFound(links.read(f.trip.id, f.otherTour.id)); + await notFound(links.read(f.trip.id, f.cafe.id)); + await notFound(links.read('abc', f.hike.id)); + await notFound(links.read(f.trip.id, '1.5')); + await notFound(links.link(String(f.trip.id), String(f.otherTour.id), { category: null }, f.owner.id)); + await notFound(links.unlink(String(f.trip.id), String(f.cafe.id))); + await notFound(links.view(f.other.id, f.hike.id, f.owner.id)); expect(await countRows(orm(), TourPackingLinks)).toBe(0); }); diff --git a/server/tests/unit/nest/tour-packing.mcp.test.ts b/server/tests/unit/nest/tour-packing.mcp.test.ts index 3dbbc9e18..2ffd7036a 100644 --- a/server/tests/unit/nest/tour-packing.mcp.test.ts +++ b/server/tests/unit/nest/tour-packing.mcp.test.ts @@ -115,9 +115,9 @@ describe('TourPackingMcp', () => { await expect( mcp.linkTourPacking({ tripId: f.trip.id, placeId: f.hike.id, category: 'Climbing' }, ctx(f.user.id)), ).rejects.toThrow('Packing category not found'); - await expect( - mcp.getTourPacking({ tripId: f.trip.id, placeId: 999_999 }, ctx(f.user.id)), - ).rejects.toThrow('Tour not found'); + await expect(mcp.getTourPacking({ tripId: f.trip.id, placeId: 999_999 }, ctx(f.user.id))).rejects.toThrow( + 'Tour not found', + ); }); it('TPL-MCP-004: the tools are there only while Tours and Packing are on, and only for the scopes they need', async () => { @@ -127,15 +127,14 @@ describe('TourPackingMcp', () => { return entry.options; }; const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(true); - for (const name of ['getTourPacking', 'linkTourPacking', 'unlinkTourPacking']) { - const gate = options(name).when; - enabled.mockResolvedValue(true); - expect(await gate?.(ctx(1), mcp)).toBe(true); - enabled.mockImplementation(async (id: string) => id !== 'packing'); - expect(await gate?.(ctx(1), mcp)).toBe(false); - enabled.mockImplementation(async (id: string) => id !== 'tours'); - expect(await gate?.(ctx(1), mcp)).toBe(false); - } + const gates = ['getTourPacking', 'linkTourPacking', 'unlinkTourPacking'].map((name) => options(name).when); + const open = () => Promise.all(gates.map((gate) => gate?.(ctx(1), mcp))); + enabled.mockResolvedValue(true); + expect(await open()).toEqual([true, true, true]); + enabled.mockImplementation(async (id: string) => id !== 'packing'); + expect(await open()).toEqual([false, false, false]); + enabled.mockImplementation(async (id: string) => id !== 'tours'); + expect(await open()).toEqual([false, false, false]); const allowed = (name: string, scopes: string[]) => { const access = options(name).access; diff --git a/server/tests/unit/nest/tours.gpx.atomic.test.ts b/server/tests/unit/nest/tours.gpx.atomic.test.ts index 6bb362f72..1d084dd85 100644 --- a/server/tests/unit/nest/tours.gpx.atomic.test.ts +++ b/server/tests/unit/nest/tours.gpx.atomic.test.ts @@ -200,7 +200,9 @@ afterAll(() => { describe('Tours GPX atomic persistence and postcommit publication', () => { async function expectEmpty() { - for (const table of ['places', 'tours', 'tour_waypoints'] as const) expect(await count(table)).toBe(0); + expect(await Promise.all((['places', 'tours', 'tour_waypoints'] as const).map((table) => count(table)))).toEqual([ + 0, 0, 0, + ]); expect(broadcast).not.toHaveBeenCalled(); } @@ -451,7 +453,7 @@ describe('Tours KML import through the same persistence (IO-02)', () => { `); it('stores each line as a tour of its own, heights only where the file has them', async () => { - const result = (await tours.importGpxAsTour(tripId, twoLines, 'alps.kml', 'socket'))!; + const result = await tours.importGpxAsTour(tripId, twoLines, 'alps.kml', 'socket'); expect(result.tours.map((tour) => [tour.name, tour.origin, tour.elevation_gain])).toEqual([ ['Coast', 'gpx', null], @@ -466,7 +468,9 @@ describe('Tours KML import through the same persistence (IO-02)', () => { testDb.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge' BEGIN SELECT RAISE(ABORT, 'place failure'); END;`); await expect(tours.importGpxAsTour(tripId, twoLines, 'alps.kml')).rejects.toThrow('place failure'); - for (const table of ['places', 'tours', 'tour_waypoints'] as const) expect(await count(table)).toBe(0); + expect(await Promise.all((['places', 'tours', 'tour_waypoints'] as const).map((table) => count(table)))).toEqual([ + 0, 0, 0, + ]); expect(broadcast).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts index de3399cc2..cede50bb3 100644 --- a/server/tests/unit/nest/tours.mcp.test.ts +++ b/server/tests/unit/nest/tours.mcp.test.ts @@ -378,7 +378,9 @@ describe('ToursMcp', () => { const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id))); const listed = tours.find((tour: { place_id: number }) => tour.place_id === ridge.id); expect(listed.stage_stops.map((stop: { key: string }) => stop.key)).toEqual(['stop-start', 'stop-hut', 'stop-end']); - expect(listed.days.map((day: { day_id: number; tour_stage_key: string | null }) => [day.day_id, day.tour_stage_key])).toEqual([ + expect( + listed.days.map((day: { day_id: number; tour_stage_key: string | null }) => [day.day_id, day.tour_stage_key]), + ).toEqual([ [day1.id, null], [day2.id, null], [day3.id, 'stop-hut'], @@ -586,7 +588,11 @@ describe('ToursMcp', () => { await orm.close(); const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id))); const byId = new Map(tours.map((tour: { place_id: number }) => [tour.place_id, tour])); - expect(byId.get(ridge.id)).toMatchObject({ tour_type: 'run', run_profile: 'running', run_pace_seconds_per_km: 360 }); + expect(byId.get(ridge.id)).toMatchObject({ + tour_type: 'run', + run_profile: 'running', + run_pace_seconds_per_km: 360, + }); expect(byId.get(gorge.id)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null }); const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours'); const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : ''; diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts index 6b7642185..cb7662697 100644 --- a/server/tests/unit/nest/tours.service.test.ts +++ b/server/tests/unit/nest/tours.service.test.ts @@ -685,7 +685,6 @@ describe('ToursService runs (RUN-01)', () => { }); }); - describe('ToursService multi-day tours (MDT-01)', () => { /** Hut to hut over three days: two stops between start and end each end a stage. */ const hutToHut: TourCreateRequest = { diff --git a/server/tests/unit/nest/wanderer-watch.service.test.ts b/server/tests/unit/nest/wanderer-watch.service.test.ts index ff3491011..a97276ba9 100644 --- a/server/tests/unit/nest/wanderer-watch.service.test.ts +++ b/server/tests/unit/nest/wanderer-watch.service.test.ts @@ -11,9 +11,9 @@ import { maybe_encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCry import { DomainError } from '../../../src/nest/common/domain-error'; import { NotificationsService } from '../../../src/nest/notifications/notifications.service'; import { contentHashOf } from '../../../src/nest/tours/tours.helpers'; +import { ToursModule } from '../../../src/nest/tours/tours.module'; import { WATCH_BACKOFF_BASE_MS } from '../../../src/nest/tours/wanderer-watch.helpers'; import { WandererWatchService } from '../../../src/nest/tours/wanderer-watch.service'; -import { ToursModule } from '../../../src/nest/tours/tours.module'; import { WandererClient, WandererError } from '../../../src/nest/wanderer/wanderer.client'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { makePlace } from '../../helpers/factories/places'; diff --git a/shared/src/tours/tour-stages.spec.ts b/shared/src/tours/tour-stages.spec.ts index 643a92771..c96e77342 100644 --- a/shared/src/tours/tour-stages.spec.ts +++ b/shared/src/tours/tour-stages.spec.ts @@ -19,8 +19,15 @@ import { describe, expect, it } from 'vitest'; type Role = 'start' | 'via' | 'end'; +/** What a test sets on a point beyond its place and role. */ +interface PointExtra { + stage_end?: boolean; + shaping?: boolean; + key?: string; +} + /** A straight run of points along a meridian, each a stop unless told otherwise. */ -function points(count: number, extra: (index: number) => Record = () => ({})) { +function points(count: number, extra: (index: number) => PointExtra = () => ({})) { return Array.from({ length: count }, (_, sequence) => ({ lat: 47 + sequence * 0.01, lng: 11,