diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 6ef5bf4f0..9e65b5c40 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -220,12 +220,23 @@ export class ToursRepository extends TrekRepository { /** * TO8 — a copied tour keeps every column, `created_at` included, so the - * copy lists its tours in the source's order. + * copy lists its tours in the source's order. A tour planned from an import + * points at the copied import (`copiedPlaceIds` maps source to copy), never + * back into the source trip. */ - async insertCopy(place_id: number, row: Omit): Promise { + async insertCopy( + place_id: number, + row: Omit, + copiedPlaceIds: ReadonlyMap, + ): Promise { + const derivedFrom = row.derived_from_place_id; await this.db() .insertInto('tours') - .values({ ...row, place_id }) + .values({ + ...row, + place_id, + derived_from_place_id: derivedFrom === null ? null : (copiedPlaceIds.get(derivedFrom) ?? null), + }) .execute(); } diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 575419bf6..47674ef93 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -743,10 +743,7 @@ export class TripsService { for (const { place_id, ...tour } of oldTours) { const newPlaceId = placeMap.get(place_id); if (!newPlaceId) continue; - // A tour planned from an import points at the copied import, never back into the source trip. - const derivedFrom = - tour.derived_from_place_id === null ? null : (placeMap.get(tour.derived_from_place_id) ?? null); - await this.toursRepo.insertCopy(newPlaceId, { ...tour, derived_from_place_id: derivedFrom }); + await this.toursRepo.insertCopy(newPlaceId, tour, placeMap); copiedTours.add(place_id); } diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts index 49b4b9d2a..05561da55 100644 --- a/server/tests/e2e/addons.e2e.test.ts +++ b/server/tests/e2e/addons.e2e.test.ts @@ -134,6 +134,8 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { expect(res.body).toEqual({ collabFeatures: { chat: true, notes: true, links: true, polls: true, whatsnext: true }, bagTracking: true, + // Tours is off in this database, so wanderer is not offered either. + toursWanderer: false, addons: [ { id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true }, { id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }, diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index f621c54b6..5577bde4a 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -89,6 +89,8 @@ describe('ToursRepository reads', () => { max_hiking_difficulty: 5, planned: 0, has_waypoints: 1, + derived_from_place_id: null, + has_recorded_track: 0, }, { place_id: older.id, @@ -108,6 +110,8 @@ describe('ToursRepository reads', () => { max_hiking_difficulty: 2, planned: 1, has_waypoints: 0, + derived_from_place_id: null, + has_recorded_track: 0, }, ]); expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id); @@ -183,6 +187,8 @@ describe('ToursRepository writes', () => { wanderer_ref: null, planned: 0, has_waypoints: 0, + derived_from_place_id: null, + has_recorded_track: 0, }); }); @@ -241,13 +247,34 @@ describe('ToursRepository writes', () => { match_confidence: null, created_at: '2026-03-04 05:06:07', max_hiking_difficulty: 4, + derived_from_place_id: null, + recorded_route_geometry: null, }, ]); expect(await repo.listRowsForTrip(other.id)).toEqual([]); const copy = createPlace(testDb, other.id); const { place_id: _sourceId, ...columns } = rows[0]; - await repo.insertCopy(copy.id, columns); + await repo.insertCopy(copy.id, columns, new Map()); expect(await repo.listRowsForTrip(other.id)).toEqual([{ ...rows[0], place_id: copy.id }]); }); + + it('TOURREPO-009: insertCopy points a planned tour at the copied import, and drops a link the copy has no import for', async () => { + const { trip, other } = fixture(); + const sourceImport = createPlace(testDb, trip.id); + const planned = createPlace(testDb, trip.id); + createTour(testDb, planned.id); + const [row] = await repo.listRowsForTrip(trip.id); + const { place_id: _sourceId, ...columns } = { ...row, derived_from_place_id: sourceImport.id }; + const copiedImport = createPlace(testDb, other.id); + const copy = createPlace(testDb, other.id); + const orphan = createPlace(testDb, other.id); + + await repo.insertCopy(copy.id, columns, new Map([[sourceImport.id, copiedImport.id]])); + await repo.insertCopy(orphan.id, columns, new Map()); + + const copied = await repo.listRowsForTrip(other.id); + expect(copied.find((r) => r.place_id === copy.id)?.derived_from_place_id).toBe(copiedImport.id); + expect(copied.find((r) => r.place_id === orphan.id)?.derived_from_place_id).toBeNull(); + }); }); diff --git a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts index 5a4ece0a1..4abafedd5 100644 --- a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts +++ b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts @@ -261,6 +261,7 @@ function tourRepositoryFixture(ownerTripId: number) { tripId === ownerTripId && placeId === 42 ? row : undefined, ), updateInTrip: vi.fn(async () => true), + keepRecordedTrack: vi.fn(async () => undefined), }; const waypointsRepo = { listForPlace: vi.fn(async () => input.waypoints), diff --git a/server/tests/unit/nest/tours.service.permissions.test.ts b/server/tests/unit/nest/tours.service.permissions.test.ts index bfac40045..b95d5fd85 100644 --- a/server/tests/unit/nest/tours.service.permissions.test.ts +++ b/server/tests/unit/nest/tours.service.permissions.test.ts @@ -89,6 +89,7 @@ function makeService() { findInTrip: vi.fn(async (): Promise => row), insertTour: inTx(undefined), updateInTrip: inTx(true), + keepRecordedTrack: inTx(undefined), }; const waypointsRepo = { listForPlace: vi.fn(async () => request.waypoints), @@ -147,6 +148,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a expect(setup.toursRepo.insertTour).toHaveBeenCalledExactlyOnceWith({ place_id: 42, tour_type: 'hike', + derived_from_place_id: null, distance: expect.any(Number), elevation_gain: 50, elevation_loss: 20,