From 1a941a6f1d06a539264abc62a8ce9f78fbfb612e Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 24 Oct 2026 01:53:51 +0000 Subject: [PATCH] fix(tours): a recorded track is neither turned round nor closed by update_tour A GPX import keeps no points of its own, and the tour read makes up a start and an end from its line. update_tour with reshape turned or closed those two made up points, so the recorded line was replaced by a router line between its ends. A reshape of a tour without points of its own is now refused with the recorded track wording, before anything is planned or saved. --- server/src/nest/tours/hike-plan.service.ts | 13 +++++---- .../tests/unit/nest/hike-plan.reshape.test.ts | 27 ++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/server/src/nest/tours/hike-plan.service.ts b/server/src/nest/tours/hike-plan.service.ts index d828f32cc..b8b3237ab 100644 --- a/server/src/nest/tours/hike-plan.service.ts +++ b/server/src/nest/tours/hike-plan.service.ts @@ -137,11 +137,14 @@ function repointingRefusal(saved: readonly TourWaypoint[], points: readonly Hike return null; } -/** The change with the saved points reshaped as its new points, or refused with why (a hike only). */ -function withReshape(request: TourUpdateRequest, saved: readonly TourWaypoint[], type: string): TourUpdateRequest { +/** + * The change with the saved points reshaped as its new points, or refused with why (a hike only). A + * recorded track has no points of its own to turn: the two its read gives are made up from its line's ends. + */ +function withReshape(request: TourUpdateRequest, { tour, waypoints }: TourDetailResponse): TourUpdateRequest { const { reshape, ...rest } = request; - if (!reshape || type !== 'hike') return rest; - const reshaped = reshapedHikePoints(saved, reshape); + if (!reshape || tour.tour_type !== 'hike') return rest; + const reshaped = reshapedHikePoints(tour.has_waypoints === true ? waypoints : [], reshape); if ('refused' in reshaped) throw badRequest(reshaped.refused); return { ...rest, waypoints: reshaped.points }; } @@ -395,7 +398,7 @@ export class HikePlanService { const { placeId } = asked; const saved = await this.tours.getTour(String(tripId), String(placeId)); // PR1-01, PR1-02: a reshape is the saved points turned or closed, then planned as new points are. - const request = asked.reshape ? withReshape(asked, saved.waypoints, saved.tour.tour_type) : asked; + const request = asked.reshape ? withReshape(asked, saved) : asked; const { tour } = saved; if (tour.tour_type !== 'hike') { throw badRequest( diff --git a/server/tests/unit/nest/hike-plan.reshape.test.ts b/server/tests/unit/nest/hike-plan.reshape.test.ts index f3e964f66..a44faea62 100644 --- a/server/tests/unit/nest/hike-plan.reshape.test.ts +++ b/server/tests/unit/nest/hike-plan.reshape.test.ts @@ -4,12 +4,15 @@ * points are. The way is planned by a stub of HikeRoutingService; the write * is the real one over a snapshot test db. */ +import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; +import { Tours } from '../../../src/db/entities/Tours.entity'; import type { McpContext } from '../../../src/nest-mcp'; import { HikePlanMcp } from '../../../src/nest/tours/hike-plan.mcp'; import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service'; import { ToursModule } from '../../../src/nest/tours/tours.module'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { makePlace } from '../../helpers/factories/places'; +import { countRows, findRow } from '../../helpers/factories/rows'; import { addTourWaypoints, makeTour } from '../../helpers/factories/tours'; import { makeTrip } from '../../helpers/factories/trips'; import { makeUser } from '../../helpers/factories/users'; @@ -20,7 +23,7 @@ import type { GeometryPoint, HikePlanRequest } from '@trek/shared'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -// HIKE-RESHAPE-001 to HIKE-RESHAPE-004 +// HIKE-RESHAPE-001 to HIKE-RESHAPE-005 const db = createSnapshotTestDb(); const plan = vi.fn(); @@ -142,4 +145,26 @@ describe('update_tour with reshape', () => { mcp.updateTour({ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape: 'reverse' }, ctx(user.id)), ).rejects.toThrow('Only a hike is changed here'); }); + + it('HIKE-RESHAPE-005: a recorded track is neither turned round nor closed, and nothing is planned or saved', async () => { + const { user } = await makeUser(orm()); + const trip = await makeTrip(orm(), user.id); + // A GPX import from A to B: its line only, and no points of its own (its read makes up the two ends). + const place = await makePlace(orm(), trip.id, { + name: 'Recorded', + route_geometry: JSON.stringify([ + [47.5, 11.1, 800], + [47.51, 11.12, 900], + [47.52, 11.1, 850], + ]), + }); + await makeTour(orm(), place.id, { origin: 'gpx' }); + const reshaped = (reshape: 'reverse' | 'close_loop') => + mcp.updateTour({ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape }, ctx(user.id)); + await expect(reshaped('reverse')).rejects.toThrow('recorded track without points of its own'); + await expect(reshaped('close_loop')).rejects.toThrow('recorded track without points of its own'); + expect(plan).not.toHaveBeenCalled(); + expect(await countRows(orm(), TourWaypoints, { place: place.id })).toBe(0); + expect((await findRow(orm(), Tours, { place: place.id }))?.revision).toBe(1); + }); });