diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx
index 490e1f13d..09e65150d 100644
--- a/client/src/components/Tours/TourDetailDialog.test.tsx
+++ b/client/src/components/Tours/TourDetailDialog.test.tsx
@@ -155,7 +155,7 @@ describe('TourDetailDialog', () => {
it('uses existing assignment callbacks for add and remove actions', () => {
const { rerender } = render()
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
- expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42)
+ expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42, 7)
rerender()
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
@@ -198,14 +198,14 @@ describe('TourDetailDialog', () => {
seedStore(useTripStore, { places: [buildPlace({ id: 7, name: 'Recorded ridge' }), place] })
render()
- expect(screen.getByText('Planned from Recorded ridge')).toBeInTheDocument()
+ expect(screen.getByText('Based on Recorded ridge')).toBeInTheDocument()
})
it('PR1-05B: says nothing of an origin that is gone from the trip', () => {
seedStore(useTripStore, { places: [place] })
render()
- expect(screen.queryByText(/Planned from/)).not.toBeInTheDocument()
+ expect(screen.queryByText(/Based on/)).not.toBeInTheDocument()
})
it('shows the grade on the tile and no second difficulty scale', () => {
diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx
index 8d795bad9..5ebcc96b4 100644
--- a/client/src/components/Tours/ToursSidebar.test.tsx
+++ b/client/src/components/Tours/ToursSidebar.test.tsx
@@ -111,7 +111,7 @@ describe('ToursSidebar', () => {
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
- expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
+ expect(within(actionRow as HTMLElement).getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ })).toHaveAttribute('aria-haspopup', 'menu')
expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
@@ -314,13 +314,20 @@ describe('ToursSidebar', () => {
{ ...tour, place_id: 3, name: 'Snapshot', origin: 'wanderer', max_hiking_difficulty: 3 },
]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
- const source = screen.getByRole('group', { name: 'Source' })
- fireEvent.click(within(source).getByRole('button', { name: /GPX import\s*1/ }))
+ const limit = () => within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ })
+ fireEvent.click(limit())
+ expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Snapshot')])
+ // Under that limit only the wanderer snapshot is left, so the source offers nothing else.
+ expect(within(screen.getByRole('group', { name: 'Source' })).queryByRole('button', { name: /GPX import/ })).toBeNull()
+ fireEvent.click(limit())
+ fireEvent.click(within(screen.getByRole('group', { name: 'Source' })).getByRole('button', { name: /GPX import\s*1/ }))
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Imported')])
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1')
// Only one tour type in the list: no type filter.
expect(screen.queryByRole('group', { name: 'Type' })).toBeNull()
- fireEvent.click(within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ }))
+ // The import has no routing limit, so none is offered for it; the day status still narrows it away.
+ expect(screen.queryByRole('group', { name: 'Routing limit' })).toBeNull()
+ fireEvent.click(within(screen.getByRole('group', { name: 'Status' })).getByRole('button', { name: /^Planned/ }))
expect(screen.getByRole('status')).toHaveTextContent('No tour matches these filters.')
fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }))
expect(screen.getAllByRole('option')).toHaveLength(3)
diff --git a/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx b/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx
index 5109b49f6..34b48dbc8 100644
--- a/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx
+++ b/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx
@@ -1,6 +1,6 @@
+import { fireEvent, render, screen } from '@testing-library/react';
import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import TourProvenanceSection from './TourProvenanceSection';
import type { SourceCheck } from './useSourceCheck';
@@ -28,6 +28,7 @@ function check(over: Partial = {}): SourceCheck {
} as SourceCheck;
}
+// Rendered without the translation provider: every text reads as its key, so the test pins which string each part shows.
describe('TourProvenanceSection (P2C-02, MF-03)', () => {
it('names a planned tour as planned here, with no local state and no check', () => {
render();
diff --git a/client/src/components/Tours/useTourDayAssignment.test.ts b/client/src/components/Tours/useTourDayAssignment.test.ts
index 0b0860404..95a4770c2 100644
--- a/client/src/components/Tours/useTourDayAssignment.test.ts
+++ b/client/src/components/Tours/useTourDayAssignment.test.ts
@@ -43,7 +43,7 @@ describe('useTourDayAssignment (PR1-18)', () => {
second = result.current.assign(42, 4);
});
expect(onAssignToDay).toHaveBeenCalledTimes(1);
- expect(onAssignToDay).toHaveBeenCalledWith(42, 4, undefined);
+ expect(onAssignToDay).toHaveBeenCalledWith(42, 4);
expect(await second).toBe(false);
expect(result.current.isPending(42, 4)).toBe(true);
expect(result.current.isAvailable(42, 4)).toBe(false);
@@ -59,10 +59,10 @@ describe('useTourDayAssignment (PR1-18)', () => {
const onAssignToDay = vi.fn(async () => false);
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
await act(async () => {
- expect(await result.current.assign(42, 4, 2)).toBe(false);
+ expect(await result.current.assign(42, 4)).toBe(false);
expect(await result.current.assign(42, 3)).toBe(false);
});
expect(onAssignToDay).toHaveBeenCalledTimes(1);
- expect(onAssignToDay).toHaveBeenCalledWith(42, 4, 2);
+ expect(onAssignToDay).toHaveBeenCalledWith(42, 4);
});
});
diff --git a/client/src/components/Tours/useTourDayAssignment.ts b/client/src/components/Tours/useTourDayAssignment.ts
index 168a0ec1a..87f2c9b5e 100644
--- a/client/src/components/Tours/useTourDayAssignment.ts
+++ b/client/src/components/Tours/useTourDayAssignment.ts
@@ -45,13 +45,13 @@ export function useTourDayAssignment({ canAssign, onAssignToDay }: TourDayAssign
);
const assign = useCallback(
- async (placeId: number, dayId: number, position?: number): Promise => {
+ async (placeId: number, dayId: number): Promise => {
const key = `${placeId}:${dayId}`;
if (!isAvailable(placeId, dayId) || inFlight.current.has(key)) return false;
inFlight.current.add(key);
setPending((current) => new Set(current).add(key));
try {
- return (await onAssignToDay(placeId, dayId, position)) !== false;
+ return (await onAssignToDay(placeId, dayId)) !== false;
} finally {
inFlight.current.delete(key);
setPending((current) => {
diff --git a/client/src/components/shared/CustomSelect.consumers.test.tsx b/client/src/components/shared/CustomSelect.consumers.test.tsx
index a1d842d77..038b211fe 100644
--- a/client/src/components/shared/CustomSelect.consumers.test.tsx
+++ b/client/src/components/shared/CustomSelect.consumers.test.tsx
@@ -15,8 +15,8 @@ vi.mock('../../store/settingsStore', () => ({
selector({ settings: { language: 'en', distance_unit: 'metric' } }),
}))
vi.mock('../../store/tripStore', () => ({
- useTripStore: (selector: (state: { loadTrip: () => Promise }) => unknown) =>
- selector({ loadTrip: vi.fn() }),
+ useTripStore: (selector: (state: { loadTrip: () => Promise; places: never[]; assignments: Record }) => unknown) =>
+ selector({ loadTrip: vi.fn(), places: [], assignments: {} }),
}))
const tour: TourListItem = {
@@ -95,16 +95,14 @@ describe('CustomSelect consumer contracts', () => {
onAssignToDay={vi.fn()} onSelectTour={vi.fn()}
/>)
expect(screen.getAllByRole('option')).toHaveLength(2)
- const filter = screen.getByRole('button', { name: 'Show' })
- await user.click(filter)
+ // The menu stays open while the filters are set, so each pick is one click.
+ await user.click(screen.getByRole('button', { name: 'Show' }))
await user.click(screen.getByRole('button', { name: /Unplanned\s*1/ }))
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Planned walk/ })).toBeNull()
- await user.click(filter)
- await user.click(screen.getByRole('button', { name: /Planned\s*1/ }))
+ await user.click(screen.getByRole('button', { name: /^Planned\s*1/ }))
expect(screen.getByRole('option', { name: /Planned walk/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Ridge walk/ })).toBeNull()
- await user.click(filter)
await user.click(screen.getByRole('button', { name: /All\s*2/ }))
expect(screen.getAllByRole('option')).toHaveLength(2)
})
diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts
index 719d6a0c4..a16d4e82b 100644
--- a/client/tests/helpers/mobileTrip.ts
+++ b/client/tests/helpers/mobileTrip.ts
@@ -69,6 +69,7 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner
reservations: [],
budgetItems: [],
files: [],
+ tours: [],
selectedDayId: null,
isLoading: false,
diff --git a/client/tests/helpers/storeRegistry.ts b/client/tests/helpers/storeRegistry.ts
index 876269d98..fc678a77a 100644
--- a/client/tests/helpers/storeRegistry.ts
+++ b/client/tests/helpers/storeRegistry.ts
@@ -4,6 +4,7 @@ import { useAuthStore } from '../../src/store/authStore';
import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
import { usePermissionsStore } from '../../src/store/permissionsStore';
import { useSettingsStore } from '../../src/store/settingsStore';
+import { useTourMapVisibilityStore } from '../../src/store/tourMapVisibilityStore';
import { useTripStore } from '../../src/store/tripStore';
import { useVacayStore } from '../../src/store/vacayStore';
@@ -34,6 +35,7 @@ export const RESET_STORES: Record = {
useAddonStore,
useInAppNotificationStore,
usePermissionsStore,
+ useTourMapVisibilityStore,
};
/** Not reset so far; moving one into RESET_STORES changes what every file that resets starts from. */
diff --git a/client/tests/unit/mobile/trip/MBrowseActionsSheet.tours.test.tsx b/client/tests/unit/mobile/trip/MBrowseActionsSheet.tours.test.tsx
index 62e2fe57e..1bb6dff71 100644
--- a/client/tests/unit/mobile/trip/MBrowseActionsSheet.tours.test.tsx
+++ b/client/tests/unit/mobile/trip/MBrowseActionsSheet.tours.test.tsx
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from 'vitest';
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell';
import MBrowseActionsSheet from '../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet';
+import { useTripStore } from '../../../../src/store/tripStore';
import type { Place } from '../../../../src/types';
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render';
-import { resetAllStores } from '../../../helpers/store';
+import { resetAllStores, seedStore } from '../../../helpers/store';
function tourPlace(): Place {
return {
@@ -51,6 +52,8 @@ function renderDayPicker(planner = mobilePlanner()) {
describe('MBrowseActionsSheet Tour assignment picker', () => {
it('disables assigned Tour days, blocks repeat clicks, and refreshes on reopen', async () => {
resetAllStores();
+ // The days a tour is on are read from the trip store, the one place every tour view reads them from.
+ seedStore(useTripStore, { assignments: { '7': [{ id: 70, day_id: 7, place_id: 42 }] } as never });
let finishAssignment!: (assigned: boolean) => void;
const handleAssignToDay = vi.fn(
() =>
@@ -80,12 +83,12 @@ describe('MBrowseActionsSheet Tour assignment picker', () => {
});
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledOnce());
- const reopened = mobilePlanner({
- assignments: {
- '7': [{ id: 70, day_id: 7, place_id: 42 }],
- '8': [{ id: 80, day_id: 8, place_id: 42 }],
- } as never,
- });
+ const assignedAfter = {
+ '7': [{ id: 70, day_id: 7, place_id: 42 }],
+ '8': [{ id: 80, day_id: 8, place_id: 42 }],
+ } as never;
+ act(() => seedStore(useTripStore, { assignments: assignedAfter }));
+ const reopened = mobilePlanner({ assignments: assignedAfter });
rerender();
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled();
diff --git a/server/src/addons.ts b/server/src/addons.ts
index dd9e19fe8..1d874167f 100644
--- a/server/src/addons.ts
+++ b/server/src/addons.ts
@@ -47,5 +47,6 @@ export const MCP_GATED_ADDON_IDS: readonly AddonId[] = [
ADDON_IDS.JOURNEY,
ADDON_IDS.PACKING,
ADDON_IDS.ROADTRIP,
+ ADDON_IDS.TOURS,
ADDON_IDS.VACAY,
];
diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts
index b7d5186fb..a232453c3 100644
--- a/server/src/nest/tours/tours.service.ts
+++ b/server/src/nest/tours/tours.service.ts
@@ -354,14 +354,14 @@ export class ToursService {
this.toursRepo.findProvenance(tid, pid),
this.placesRepo.findInTrip(pid, tid),
]);
+ const start = parseRouteGeometry(place?.route_geometry)[0];
// A tour without a line has nothing to copy.
- if (!source || !provenance || !place?.route_geometry) throw new NotFoundException('Tour not found');
- const start = parseRouteGeometry(place.route_geometry)[0];
+ if (!source || !provenance || !place?.route_geometry || !start) throw new NotFoundException('Tour not found');
const id = await this.placesRepo.insertTourPlace({
trip_id: tid,
name,
- lat: start?.[0] ?? place.lat ?? 0,
- lng: start?.[1] ?? place.lng ?? 0,
+ lat: start[0],
+ lng: start[1],
route_geometry: place.route_geometry,
description: place.description ?? null,
website: place.website ?? null,
diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts
index 86e624e9f..84fb9553b 100644
--- a/server/tests/e2e/tours.e2e.test.ts
+++ b/server/tests/e2e/tours.e2e.test.ts
@@ -15,6 +15,7 @@ import { db } 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 { TourSources } from '../../src/db/entities/TourSources.entity';
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../src/db/entities/Tours.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
@@ -30,7 +31,7 @@ import { ToursModule } from '../../src/nest/tours/tours.module';
import { TripsModule } from '../../src/nest/trips/trips.module';
import { makeDayAssignment } from '../helpers/factories/itinerary';
import { makePlace, type PlaceRow } from '../helpers/factories/places';
-import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
+import { countRows, deleteRows, findRow, insertRows, updateRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
@@ -546,6 +547,60 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
derivedFrom: { placeId: source, name: sourceName },
});
});
+
+ it('a wanderer snapshot without its import record names its source from its reference, and invents none', async () => {
+ const placeId = await createViaApi();
+ await updateRows(orm, Tours, { place: placeId }, { origin: 'wanderer', wanderer_ref: 'https://w.example#t9' });
+ expect((await detail(placeId)).provenance.source).toEqual({
+ kind: 'wanderer',
+ instance: 'https://w.example',
+ externalId: 't9',
+ externalUrl: null,
+ externalUpdated: null,
+ importedAt: null,
+ });
+
+ await updateRows(orm, Tours, { place: placeId }, { wanderer_ref: null });
+ expect((await detail(placeId)).provenance.source).toMatchObject({ instance: '', externalId: '' });
+ });
+
+ it('a trip copy keeps the import record of a wanderer snapshot', async () => {
+ const placeId = await createViaApi(5, tourBody({ name: 'Snapshot' }));
+ await updateRows(orm, Tours, { place: placeId }, { origin: 'wanderer', wanderer_ref: 'https://w.example#t1' });
+ await insertRows(orm, TourSources, [
+ {
+ place: placeId,
+ provider: 'wanderer',
+ instance: 'https://w.example',
+ external_id: 't1',
+ external_url: 'https://w.example/trail/view/@me/t1',
+ external_updated: '2026-10-01',
+ content_hash: 'c.c.c',
+ local_hash: 'l.l',
+ },
+ ]);
+ const res = await request(server)
+ .post('/api/trips/5/copy')
+ .set('Cookie', sessionCookie(OWNER))
+ .send({ title: 'Copy' });
+ const copyId = res.body.trip.id as number;
+ const tours = (await request(server).get(`/api/trips/${copyId}/tours`).set('Cookie', sessionCookie(OWNER))).body
+ .tours;
+ const copied = tours.find((tour: { name: string }) => tour.name === 'Snapshot');
+ const copiedDetail = (
+ await request(server).get(`/api/trips/${copyId}/tours/${copied.place_id}`).set('Cookie', sessionCookie(OWNER))
+ ).body;
+
+ expect(copiedDetail.provenance).toMatchObject({
+ origin: 'wanderer',
+ source: (await detail(placeId)).provenance.source,
+ });
+ expect(copiedDetail.provenance.source).toMatchObject({ instance: 'https://w.example', externalId: 't1' });
+ expect(await findRow(orm, TourSources, { place: copied.place_id })).toMatchObject({
+ content_hash: 'c.c.c',
+ local_hash: 'l.l',
+ });
+ });
});
describe('save as new tour (P2C-07)', () => {
@@ -605,6 +660,19 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect((await copy(await plainPlace(5, 'Cafe'))).status).toBe(404);
const foreign = await createViaApi(6);
expect((await copy(foreign)).status).toBe(404);
+ const notAnId = await request(server)
+ .post('/api/trips/5/tours/abc/copy')
+ .set('Cookie', sessionCookie(OWNER))
+ .send({ name: 'x' });
+ expect(notAnId.status).toBe(404);
+ });
+
+ it('a tour whose line holds no point has nothing to copy', async () => {
+ const source = await createViaApi();
+ await updateRows(orm, Places, { id: source }, { route_geometry: '[]' });
+ const before = await countRows(orm, Tours, {});
+ expect((await copy(source)).status).toBe(404);
+ expect(await countRows(orm, Tours, {})).toBe(before);
});
});
diff --git a/server/tests/e2e/wanderer.e2e.test.ts b/server/tests/e2e/wanderer.e2e.test.ts
index 05fb70aa4..652893bd2 100644
--- a/server/tests/e2e/wanderer.e2e.test.ts
+++ b/server/tests/e2e/wanderer.e2e.test.ts
@@ -448,9 +448,27 @@ describe('wanderer e2e', () => {
const placeId = await imported();
client.searchById.mockResolvedValue(null);
expect((await check(placeId)).body).toMatchObject({ state: 'unavailable', reimport: null });
+ client.searchById.mockResolvedValue(HIT);
+ client.trail.mockResolvedValue({ ...RECORD, author: 'someone-else' });
+ expect((await check(placeId)).body).toMatchObject({ state: 'unavailable', reimport: null });
expect(await countRows(orm, Places)).toBe(1);
});
+ it('a trail that lost its track cannot be compared, and the check says why', async () => {
+ const placeId = await imported();
+ client.gpx.mockResolvedValue('');
+ const res = await check(placeId);
+ expect(res.status).toBe(422);
+ expect(res.body).toMatchObject({ code: 'no_track' });
+ expect(await countRows(orm, Places)).toBe(1);
+ });
+
+ it('a trail without a change date still compares, and names no date', async () => {
+ const placeId = await imported();
+ client.trail.mockResolvedValue({ ...RECORD, updated: undefined });
+ expect((await check(placeId)).body).toMatchObject({ state: 'unchanged', externalUpdated: null });
+ });
+
it('without a connection, or one to another instance, there is nothing to ask', async () => {
const placeId = await imported();
await request(server)
@@ -476,6 +494,9 @@ describe('wanderer e2e', () => {
const placeId = await imported();
expect((await check(placeId, VIEWER)).status).toBe(403);
expect((await check(999_999)).status).toBe(404);
+ expect((await request(server).post('/api/trips/5/tours/wanderer/sources/abc/check').set(as(OWNER))).status).toBe(
+ 404,
+ );
const planned = await request(server)
.post('/api/trips/5/tours')
.set(as(OWNER))
@@ -496,7 +517,7 @@ describe('wanderer e2e', () => {
expect(res.body).toMatchObject({ code: 'no_source' });
});
- it('a wanderer import says where it came from in its detail, and a trip copy keeps it (P2C-02)', async () => {
+ it('a wanderer import says where it came from in its detail (P2C-02)', async () => {
const placeId = await imported();
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set(as(OWNER));
expect(detail.body.provenance).toMatchObject({
diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts
index d0e1a19eb..6fbbaac9f 100644
--- a/server/tests/unit/nest/tours.mcp.test.ts
+++ b/server/tests/unit/nest/tours.mcp.test.ts
@@ -7,6 +7,7 @@
import type { McpContext } from '../../../src/nest-mcp';
import { ToursMcp } from '../../../src/nest/tours/tours.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
+import { ToursService } from '../../../src/nest/tours/tours.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import {
addTripMember,
@@ -20,7 +21,7 @@ import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { createTour } from '../../helpers/tours-repos';
-import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
@@ -111,4 +112,15 @@ describe('ToursMcp', () => {
error: 'Tour not found.',
});
});
+
+ it('TOURS-MCP-005: a failure other than a missing tour is not passed off as not found', async () => {
+ const { user, trip, ridge } = fixture();
+ const failure = new Error('database gone');
+ const getTour = vi.spyOn(t.get(ToursService), 'getTour').mockRejectedValueOnce(failure);
+ try {
+ await expect(mcp.getTourPlan({ tripId: trip.id, placeId: ridge.id }, ctx(user.id))).rejects.toBe(failure);
+ } finally {
+ getTour.mockRestore();
+ }
+ });
});
diff --git a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts
index 4abafedd5..71b195ada 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
@@ -11,7 +11,7 @@ import 'reflect-metadata';
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { TourTypesRepository } from '../../../src/db/repositories/TourTypes.repository';
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
-import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
+import type { TourProvenanceRow, ToursRepository } from '../../../src/db/repositories/Tours.repository';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { DayAssignmentsController } from '../../../src/nest/assignments/assignments.controller';
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
@@ -252,14 +252,36 @@ function tourRepositoryFixture(ownerTripId: number) {
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
+ origin: 'trek',
planned: 0,
has_waypoints: 1,
};
+ const provenance: TourProvenanceRow = {
+ origin: 'trek',
+ origin_file_name: null,
+ created_at: null,
+ wanderer_ref: null,
+ has_recorded_track: 0,
+ derived_from_place_id: null,
+ derived_from_name: null,
+ source_instance: null,
+ source_external_id: null,
+ source_external_url: null,
+ source_external_updated: null,
+ source_imported_at: null,
+ source_content_hash: null,
+ source_local_hash: null,
+ route_geometry: null,
+ name: 'Ridge walk',
+ };
const uow = { transactional: vi.fn(async (fn: () => Promise): Promise => await fn()) };
const toursRepo = {
findInTrip: vi.fn(async (tripId: number, placeId: number) =>
tripId === ownerTripId && placeId === 42 ? row : undefined,
),
+ findProvenance: vi.fn(async (tripId: number, placeId: number) =>
+ tripId === ownerTripId && placeId === 42 ? provenance : undefined,
+ ),
updateInTrip: vi.fn(async () => true),
keepRecordedTrack: vi.fn(async () => undefined),
};
@@ -291,6 +313,7 @@ describe('Real ToursService update trip boundary (stubbed repositories)', () =>
const f = tourRepositoryFixture(8);
expect((await f.service.getTour('8', '42')).tour.place_id).toBe(42);
f.toursRepo.findInTrip.mockClear();
+ f.toursRepo.findProvenance.mockClear();
f.waypointsRepo.listForPlace.mockClear();
const error: unknown = await f.service.updateTour('7', '42', input, 'socket').catch((e: unknown) => e);
@@ -305,6 +328,7 @@ describe('Real ToursService update trip boundary (stubbed repositories)', () =>
f.waypointsRepo.deleteForPlace,
f.waypointsRepo.insertForPlace,
f.waypointsRepo.listForPlace,
+ f.toursRepo.findProvenance,
f.placesRepo.findWithTagsAndRatings,
f.places.broadcast,
]) {
@@ -337,5 +361,6 @@ describe('Real ToursService update trip boundary (stubbed repositories)', () =>
expect(f.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, input.waypoints);
expect(f.placesRepo.findWithTagsAndRatings).toHaveBeenCalledWith(42);
expect(f.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket');
+ expect(f.toursRepo.findProvenance).toHaveBeenCalledExactlyOnceWith(7, 42);
});
});
diff --git a/server/tests/unit/nest/tours.service.permissions.test.ts b/server/tests/unit/nest/tours.service.permissions.test.ts
index b95d5fd85..10c75fe2e 100644
--- a/server/tests/unit/nest/tours.service.permissions.test.ts
+++ b/server/tests/unit/nest/tours.service.permissions.test.ts
@@ -8,7 +8,7 @@
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { TourTypesRepository } from '../../../src/db/repositories/TourTypes.repository';
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
-import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
+import type { TourProvenanceRow, ToursRepository } from '../../../src/db/repositories/Tours.repository';
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
@@ -63,9 +63,28 @@ function makeService() {
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
+ origin: 'trek',
planned: 0,
has_waypoints: 1,
};
+ const provenance: TourProvenanceRow = {
+ origin: 'trek',
+ origin_file_name: null,
+ created_at: '2026-10-01 08:00:00',
+ wanderer_ref: null,
+ has_recorded_track: 0,
+ derived_from_place_id: null,
+ derived_from_name: null,
+ source_instance: null,
+ source_external_id: null,
+ source_external_url: null,
+ source_external_updated: null,
+ source_imported_at: null,
+ source_content_hash: null,
+ source_local_hash: null,
+ route_geometry: JSON.stringify(request.route_geometry),
+ name: request.name,
+ };
const place = { id: 42, trip_id: 7 };
let inTransaction = false;
/** Records whether each write ran inside the transaction. */
@@ -87,6 +106,7 @@ function makeService() {
};
const toursRepo = {
findInTrip: vi.fn(async (): Promise => row),
+ findProvenance: vi.fn(async (): Promise => provenance),
insertTour: inTx(undefined),
updateInTrip: inTx(true),
keepRecordedTrack: inTx(undefined),
@@ -163,6 +183,15 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42);
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true });
expect(result.waypoints).toEqual(request.waypoints);
+ expect(setup.toursRepo.findProvenance).toHaveBeenCalledExactlyOnceWith(7, 42);
+ expect(result.provenance).toEqual({
+ origin: 'trek',
+ createdAt: '2026-10-01 08:00:00',
+ source: { kind: 'trek' },
+ derivedFrom: null,
+ plannedOver: false,
+ editedLocally: null,
+ });
expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [42] }, 'socket-1');
expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, '7', 'place:created', { place: setup.place }, 'socket-1');
});
@@ -204,6 +233,11 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
]);
+ expect(legacyGpx.provenance).toMatchObject({ origin: 'trek', source: { kind: 'trek' } });
+
+ // The provenance read is scoped to the trip as well: no row there is the same 404.
+ setup.toursRepo.findProvenance.mockResolvedValueOnce(undefined);
+ await expect(setup.service.getTour('7', '42')).rejects.toBeInstanceOf(NotFoundException);
for (const method of [
setup.uow.transactional,
setup.placesRepo.insertTourPlace,