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,