mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Tours: tests follow the provenance read and the shared day assignment, the tours addon ends MCP sessions, a line without a point is not copied
The repository stubs of the tour service tests answer findProvenance, which getTour and createTour read since P2C-02. Switching the Tours addon off now ends live MCP sessions like every other addon that gates a tool (MCP-GATE-PARITY-001). copyTour refuses a line without a point instead of placing the copy at 0/0, and new tests cover the reachable paths of the copy, the source check and the provenance of a snapshot without its import record, including a trip copy keeping that record. On the client the shared day assignment hands the plan exactly the tour and the day, the new visibility store is reset between tests, and the tests of the sidebar, the detail, the phone sheet and the origin section match the views this PR built.
This commit is contained in:
@@ -155,7 +155,7 @@ describe('TourDetailDialog', () => {
|
|||||||
it('uses existing assignment callbacks for add and remove actions', () => {
|
it('uses existing assignment callbacks for add and remove actions', () => {
|
||||||
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
|
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
|
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
|
||||||
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42)
|
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42, 7)
|
||||||
|
|
||||||
rerender(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} assignments={{ '7': [{ id: 9, place }] as never }} />)
|
rerender(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} assignments={{ '7': [{ id: 9, place }] as never }} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
|
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] })
|
seedStore(useTripStore, { places: [buildPlace({ id: 7, name: 'Recorded ridge' }), place] })
|
||||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
|
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
|
||||||
|
|
||||||
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', () => {
|
it('PR1-05B: says nothing of an origin that is gone from the trip', () => {
|
||||||
seedStore(useTripStore, { places: [place] })
|
seedStore(useTripStore, { places: [place] })
|
||||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
|
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
|
||||||
|
|
||||||
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', () => {
|
it('shows the grade on the tile and no second difficulty scale', () => {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ describe('ToursSidebar', () => {
|
|||||||
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
|
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
|
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
|
||||||
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
|
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()
|
expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
|
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 },
|
{ ...tour, place_id: 3, name: 'Snapshot', origin: 'wanderer', max_hiking_difficulty: 3 },
|
||||||
]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
|
||||||
const source = screen.getByRole('group', { name: 'Source' })
|
const limit = () => within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ })
|
||||||
fireEvent.click(within(source).getByRole('button', { name: /GPX import\s*1/ }))
|
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.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Imported')])
|
||||||
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1')
|
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1')
|
||||||
// Only one tour type in the list: no type filter.
|
// Only one tour type in the list: no type filter.
|
||||||
expect(screen.queryByRole('group', { name: 'Type' })).toBeNull()
|
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.')
|
expect(screen.getByRole('status')).toHaveTextContent('No tour matches these filters.')
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }))
|
||||||
expect(screen.getAllByRole('option')).toHaveLength(3)
|
expect(screen.getAllByRole('option')).toHaveLength(3)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared';
|
import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
|
||||||
import TourProvenanceSection from './TourProvenanceSection';
|
import TourProvenanceSection from './TourProvenanceSection';
|
||||||
import type { SourceCheck } from './useSourceCheck';
|
import type { SourceCheck } from './useSourceCheck';
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ function check(over: Partial<SourceCheck> = {}): SourceCheck {
|
|||||||
} as 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)', () => {
|
describe('TourProvenanceSection (P2C-02, MF-03)', () => {
|
||||||
it('names a planned tour as planned here, with no local state and no check', () => {
|
it('names a planned tour as planned here, with no local state and no check', () => {
|
||||||
render(<TourProvenanceSection provenance={base} />);
|
render(<TourProvenanceSection provenance={base} />);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe('useTourDayAssignment (PR1-18)', () => {
|
|||||||
second = result.current.assign(42, 4);
|
second = result.current.assign(42, 4);
|
||||||
});
|
});
|
||||||
expect(onAssignToDay).toHaveBeenCalledTimes(1);
|
expect(onAssignToDay).toHaveBeenCalledTimes(1);
|
||||||
expect(onAssignToDay).toHaveBeenCalledWith(42, 4, undefined);
|
expect(onAssignToDay).toHaveBeenCalledWith(42, 4);
|
||||||
expect(await second).toBe(false);
|
expect(await second).toBe(false);
|
||||||
expect(result.current.isPending(42, 4)).toBe(true);
|
expect(result.current.isPending(42, 4)).toBe(true);
|
||||||
expect(result.current.isAvailable(42, 4)).toBe(false);
|
expect(result.current.isAvailable(42, 4)).toBe(false);
|
||||||
@@ -59,10 +59,10 @@ describe('useTourDayAssignment (PR1-18)', () => {
|
|||||||
const onAssignToDay = vi.fn(async () => false);
|
const onAssignToDay = vi.fn(async () => false);
|
||||||
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
|
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
|
||||||
await act(async () => {
|
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(await result.current.assign(42, 3)).toBe(false);
|
||||||
});
|
});
|
||||||
expect(onAssignToDay).toHaveBeenCalledTimes(1);
|
expect(onAssignToDay).toHaveBeenCalledTimes(1);
|
||||||
expect(onAssignToDay).toHaveBeenCalledWith(42, 4, 2);
|
expect(onAssignToDay).toHaveBeenCalledWith(42, 4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ export function useTourDayAssignment({ canAssign, onAssignToDay }: TourDayAssign
|
|||||||
);
|
);
|
||||||
|
|
||||||
const assign = useCallback(
|
const assign = useCallback(
|
||||||
async (placeId: number, dayId: number, position?: number): Promise<boolean> => {
|
async (placeId: number, dayId: number): Promise<boolean> => {
|
||||||
const key = `${placeId}:${dayId}`;
|
const key = `${placeId}:${dayId}`;
|
||||||
if (!isAvailable(placeId, dayId) || inFlight.current.has(key)) return false;
|
if (!isAvailable(placeId, dayId) || inFlight.current.has(key)) return false;
|
||||||
inFlight.current.add(key);
|
inFlight.current.add(key);
|
||||||
setPending((current) => new Set(current).add(key));
|
setPending((current) => new Set(current).add(key));
|
||||||
try {
|
try {
|
||||||
return (await onAssignToDay(placeId, dayId, position)) !== false;
|
return (await onAssignToDay(placeId, dayId)) !== false;
|
||||||
} finally {
|
} finally {
|
||||||
inFlight.current.delete(key);
|
inFlight.current.delete(key);
|
||||||
setPending((current) => {
|
setPending((current) => {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ vi.mock('../../store/settingsStore', () => ({
|
|||||||
selector({ settings: { language: 'en', distance_unit: 'metric' } }),
|
selector({ settings: { language: 'en', distance_unit: 'metric' } }),
|
||||||
}))
|
}))
|
||||||
vi.mock('../../store/tripStore', () => ({
|
vi.mock('../../store/tripStore', () => ({
|
||||||
useTripStore: (selector: (state: { loadTrip: () => Promise<void> }) => unknown) =>
|
useTripStore: (selector: (state: { loadTrip: () => Promise<void>; places: never[]; assignments: Record<string, never> }) => unknown) =>
|
||||||
selector({ loadTrip: vi.fn() }),
|
selector({ loadTrip: vi.fn(), places: [], assignments: {} }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const tour: TourListItem = {
|
const tour: TourListItem = {
|
||||||
@@ -95,16 +95,14 @@ describe('CustomSelect consumer contracts', () => {
|
|||||||
onAssignToDay={vi.fn()} onSelectTour={vi.fn()}
|
onAssignToDay={vi.fn()} onSelectTour={vi.fn()}
|
||||||
/></TranslationProvider>)
|
/></TranslationProvider>)
|
||||||
expect(screen.getAllByRole('option')).toHaveLength(2)
|
expect(screen.getAllByRole('option')).toHaveLength(2)
|
||||||
const filter = screen.getByRole('button', { name: 'Show' })
|
// The menu stays open while the filters are set, so each pick is one click.
|
||||||
await user.click(filter)
|
await user.click(screen.getByRole('button', { name: 'Show' }))
|
||||||
await user.click(screen.getByRole('button', { name: /Unplanned\s*1/ }))
|
await user.click(screen.getByRole('button', { name: /Unplanned\s*1/ }))
|
||||||
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
|
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('option', { name: /Planned walk/ })).toBeNull()
|
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.getByRole('option', { name: /Planned walk/ })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('option', { name: /Ridge walk/ })).toBeNull()
|
expect(screen.queryByRole('option', { name: /Ridge walk/ })).toBeNull()
|
||||||
await user.click(filter)
|
|
||||||
await user.click(screen.getByRole('button', { name: /All\s*2/ }))
|
await user.click(screen.getByRole('button', { name: /All\s*2/ }))
|
||||||
expect(screen.getAllByRole('option')).toHaveLength(2)
|
expect(screen.getAllByRole('option')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
|
|||||||
reservations: [],
|
reservations: [],
|
||||||
budgetItems: [],
|
budgetItems: [],
|
||||||
files: [],
|
files: [],
|
||||||
|
tours: [],
|
||||||
|
|
||||||
selectedDayId: null,
|
selectedDayId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuthStore } from '../../src/store/authStore';
|
|||||||
import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
|
import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
|
||||||
import { usePermissionsStore } from '../../src/store/permissionsStore';
|
import { usePermissionsStore } from '../../src/store/permissionsStore';
|
||||||
import { useSettingsStore } from '../../src/store/settingsStore';
|
import { useSettingsStore } from '../../src/store/settingsStore';
|
||||||
|
import { useTourMapVisibilityStore } from '../../src/store/tourMapVisibilityStore';
|
||||||
import { useTripStore } from '../../src/store/tripStore';
|
import { useTripStore } from '../../src/store/tripStore';
|
||||||
import { useVacayStore } from '../../src/store/vacayStore';
|
import { useVacayStore } from '../../src/store/vacayStore';
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export const RESET_STORES: Record<string, ResettableStore> = {
|
|||||||
useAddonStore,
|
useAddonStore,
|
||||||
useInAppNotificationStore,
|
useInAppNotificationStore,
|
||||||
usePermissionsStore,
|
usePermissionsStore,
|
||||||
|
useTourMapVisibilityStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Not reset so far; moving one into RESET_STORES changes what every file that resets starts from. */
|
/** Not reset so far; moving one into RESET_STORES changes what every file that resets starts from. */
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell';
|
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell';
|
||||||
import MBrowseActionsSheet from '../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet';
|
import MBrowseActionsSheet from '../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet';
|
||||||
|
import { useTripStore } from '../../../../src/store/tripStore';
|
||||||
import type { Place } from '../../../../src/types';
|
import type { Place } from '../../../../src/types';
|
||||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
|
||||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render';
|
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render';
|
||||||
import { resetAllStores } from '../../../helpers/store';
|
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||||
|
|
||||||
function tourPlace(): Place {
|
function tourPlace(): Place {
|
||||||
return {
|
return {
|
||||||
@@ -51,6 +52,8 @@ function renderDayPicker(planner = mobilePlanner()) {
|
|||||||
describe('MBrowseActionsSheet Tour assignment picker', () => {
|
describe('MBrowseActionsSheet Tour assignment picker', () => {
|
||||||
it('disables assigned Tour days, blocks repeat clicks, and refreshes on reopen', async () => {
|
it('disables assigned Tour days, blocks repeat clicks, and refreshes on reopen', async () => {
|
||||||
resetAllStores();
|
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;
|
let finishAssignment!: (assigned: boolean) => void;
|
||||||
const handleAssignToDay = vi.fn(
|
const handleAssignToDay = vi.fn(
|
||||||
() =>
|
() =>
|
||||||
@@ -80,12 +83,12 @@ describe('MBrowseActionsSheet Tour assignment picker', () => {
|
|||||||
});
|
});
|
||||||
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledOnce());
|
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
const reopened = mobilePlanner({
|
const assignedAfter = {
|
||||||
assignments: {
|
'7': [{ id: 70, day_id: 7, place_id: 42 }],
|
||||||
'7': [{ id: 70, day_id: 7, place_id: 42 }],
|
'8': [{ id: 80, day_id: 8, place_id: 42 }],
|
||||||
'8': [{ id: 80, day_id: 8, place_id: 42 }],
|
} as never;
|
||||||
} as never,
|
act(() => seedStore(useTripStore, { assignments: assignedAfter }));
|
||||||
});
|
const reopened = mobilePlanner({ assignments: assignedAfter });
|
||||||
rerender(<MBrowseActionsSheet planner={reopened} shell={shell} />);
|
rerender(<MBrowseActionsSheet planner={reopened} shell={shell} />);
|
||||||
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled();
|
||||||
|
|||||||
@@ -47,5 +47,6 @@ export const MCP_GATED_ADDON_IDS: readonly AddonId[] = [
|
|||||||
ADDON_IDS.JOURNEY,
|
ADDON_IDS.JOURNEY,
|
||||||
ADDON_IDS.PACKING,
|
ADDON_IDS.PACKING,
|
||||||
ADDON_IDS.ROADTRIP,
|
ADDON_IDS.ROADTRIP,
|
||||||
|
ADDON_IDS.TOURS,
|
||||||
ADDON_IDS.VACAY,
|
ADDON_IDS.VACAY,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -354,14 +354,14 @@ export class ToursService {
|
|||||||
this.toursRepo.findProvenance(tid, pid),
|
this.toursRepo.findProvenance(tid, pid),
|
||||||
this.placesRepo.findInTrip(pid, tid),
|
this.placesRepo.findInTrip(pid, tid),
|
||||||
]);
|
]);
|
||||||
|
const start = parseRouteGeometry(place?.route_geometry)[0];
|
||||||
// A tour without a line has nothing to copy.
|
// A tour without a line has nothing to copy.
|
||||||
if (!source || !provenance || !place?.route_geometry) throw new NotFoundException('Tour not found');
|
if (!source || !provenance || !place?.route_geometry || !start) throw new NotFoundException('Tour not found');
|
||||||
const start = parseRouteGeometry(place.route_geometry)[0];
|
|
||||||
const id = await this.placesRepo.insertTourPlace({
|
const id = await this.placesRepo.insertTourPlace({
|
||||||
trip_id: tid,
|
trip_id: tid,
|
||||||
name,
|
name,
|
||||||
lat: start?.[0] ?? place.lat ?? 0,
|
lat: start[0],
|
||||||
lng: start?.[1] ?? place.lng ?? 0,
|
lng: start[1],
|
||||||
route_geometry: place.route_geometry,
|
route_geometry: place.route_geometry,
|
||||||
description: place.description ?? null,
|
description: place.description ?? null,
|
||||||
website: place.website ?? null,
|
website: place.website ?? null,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { db } from '../../src/db/database';
|
|||||||
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||||
import { Days } from '../../src/db/entities/Days.entity';
|
import { Days } from '../../src/db/entities/Days.entity';
|
||||||
import { Places } from '../../src/db/entities/Places.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 { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
|
||||||
import { Tours } from '../../src/db/entities/Tours.entity';
|
import { Tours } from '../../src/db/entities/Tours.entity';
|
||||||
import { Trips } from '../../src/db/entities/Trips.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 { TripsModule } from '../../src/nest/trips/trips.module';
|
||||||
import { makeDayAssignment } from '../helpers/factories/itinerary';
|
import { makeDayAssignment } from '../helpers/factories/itinerary';
|
||||||
import { makePlace, type PlaceRow } from '../helpers/factories/places';
|
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 { setAddonEnabled } from '../helpers/factories/settings';
|
||||||
import { addTripMember } from '../helpers/factories/trips';
|
import { addTripMember } from '../helpers/factories/trips';
|
||||||
import { makeUser } from '../helpers/factories/users';
|
import { makeUser } from '../helpers/factories/users';
|
||||||
@@ -546,6 +547,60 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
|||||||
derivedFrom: { placeId: source, name: sourceName },
|
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)', () => {
|
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);
|
expect((await copy(await plainPlace(5, 'Cafe'))).status).toBe(404);
|
||||||
const foreign = await createViaApi(6);
|
const foreign = await createViaApi(6);
|
||||||
expect((await copy(foreign)).status).toBe(404);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -448,9 +448,27 @@ describe('wanderer e2e', () => {
|
|||||||
const placeId = await imported();
|
const placeId = await imported();
|
||||||
client.searchById.mockResolvedValue(null);
|
client.searchById.mockResolvedValue(null);
|
||||||
expect((await check(placeId)).body).toMatchObject({ state: 'unavailable', reimport: 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);
|
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('<?xml version="1.0"?><gpx version="1.1"><wpt lat="47" lon="11"/></gpx>');
|
||||||
|
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 () => {
|
it('without a connection, or one to another instance, there is nothing to ask', async () => {
|
||||||
const placeId = await imported();
|
const placeId = await imported();
|
||||||
await request(server)
|
await request(server)
|
||||||
@@ -476,6 +494,9 @@ describe('wanderer e2e', () => {
|
|||||||
const placeId = await imported();
|
const placeId = await imported();
|
||||||
expect((await check(placeId, VIEWER)).status).toBe(403);
|
expect((await check(placeId, VIEWER)).status).toBe(403);
|
||||||
expect((await check(999_999)).status).toBe(404);
|
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)
|
const planned = await request(server)
|
||||||
.post('/api/trips/5/tours')
|
.post('/api/trips/5/tours')
|
||||||
.set(as(OWNER))
|
.set(as(OWNER))
|
||||||
@@ -496,7 +517,7 @@ describe('wanderer e2e', () => {
|
|||||||
expect(res.body).toMatchObject({ code: 'no_source' });
|
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 placeId = await imported();
|
||||||
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set(as(OWNER));
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set(as(OWNER));
|
||||||
expect(detail.body.provenance).toMatchObject({
|
expect(detail.body.provenance).toMatchObject({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type { McpContext } from '../../../src/nest-mcp';
|
import type { McpContext } from '../../../src/nest-mcp';
|
||||||
import { ToursMcp } from '../../../src/nest/tours/tours.mcp';
|
import { ToursMcp } from '../../../src/nest/tours/tours.mcp';
|
||||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||||
|
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||||
import {
|
import {
|
||||||
addTripMember,
|
addTripMember,
|
||||||
@@ -20,7 +21,7 @@ import { resetTestDb } from '../../helpers/test-db';
|
|||||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||||
import { createTour } from '../../helpers/tours-repos';
|
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();
|
const db = createSnapshotTestDb();
|
||||||
let t: TestModule;
|
let t: TestModule;
|
||||||
@@ -111,4 +112,15 @@ describe('ToursMcp', () => {
|
|||||||
error: 'Tour not found.',
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import 'reflect-metadata';
|
|||||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||||
import type { TourTypesRepository } from '../../../src/db/repositories/TourTypes.repository';
|
import type { TourTypesRepository } from '../../../src/db/repositories/TourTypes.repository';
|
||||||
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.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 type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||||
import { DayAssignmentsController } from '../../../src/nest/assignments/assignments.controller';
|
import { DayAssignmentsController } from '../../../src/nest/assignments/assignments.controller';
|
||||||
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||||
@@ -252,14 +252,36 @@ function tourRepositoryFixture(ownerTripId: number) {
|
|||||||
wanderer_ref: null,
|
wanderer_ref: null,
|
||||||
match_confidence: 1,
|
match_confidence: 1,
|
||||||
max_hiking_difficulty: 2,
|
max_hiking_difficulty: 2,
|
||||||
|
origin: 'trek',
|
||||||
planned: 0,
|
planned: 0,
|
||||||
has_waypoints: 1,
|
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 <T>(fn: () => Promise<T>): Promise<T> => await fn()) };
|
const uow = { transactional: vi.fn(async <T>(fn: () => Promise<T>): Promise<T> => await fn()) };
|
||||||
const toursRepo = {
|
const toursRepo = {
|
||||||
findInTrip: vi.fn(async (tripId: number, placeId: number) =>
|
findInTrip: vi.fn(async (tripId: number, placeId: number) =>
|
||||||
tripId === ownerTripId && placeId === 42 ? row : undefined,
|
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),
|
updateInTrip: vi.fn(async () => true),
|
||||||
keepRecordedTrack: vi.fn(async () => undefined),
|
keepRecordedTrack: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
@@ -291,6 +313,7 @@ describe('Real ToursService update trip boundary (stubbed repositories)', () =>
|
|||||||
const f = tourRepositoryFixture(8);
|
const f = tourRepositoryFixture(8);
|
||||||
expect((await f.service.getTour('8', '42')).tour.place_id).toBe(42);
|
expect((await f.service.getTour('8', '42')).tour.place_id).toBe(42);
|
||||||
f.toursRepo.findInTrip.mockClear();
|
f.toursRepo.findInTrip.mockClear();
|
||||||
|
f.toursRepo.findProvenance.mockClear();
|
||||||
f.waypointsRepo.listForPlace.mockClear();
|
f.waypointsRepo.listForPlace.mockClear();
|
||||||
|
|
||||||
const error: unknown = await f.service.updateTour('7', '42', input, 'socket').catch((e: unknown) => e);
|
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.deleteForPlace,
|
||||||
f.waypointsRepo.insertForPlace,
|
f.waypointsRepo.insertForPlace,
|
||||||
f.waypointsRepo.listForPlace,
|
f.waypointsRepo.listForPlace,
|
||||||
|
f.toursRepo.findProvenance,
|
||||||
f.placesRepo.findWithTagsAndRatings,
|
f.placesRepo.findWithTagsAndRatings,
|
||||||
f.places.broadcast,
|
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.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, input.waypoints);
|
||||||
expect(f.placesRepo.findWithTagsAndRatings).toHaveBeenCalledWith(42);
|
expect(f.placesRepo.findWithTagsAndRatings).toHaveBeenCalledWith(42);
|
||||||
expect(f.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket');
|
expect(f.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket');
|
||||||
|
expect(f.toursRepo.findProvenance).toHaveBeenCalledExactlyOnceWith(7, 42);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||||
import type { TourTypesRepository } from '../../../src/db/repositories/TourTypes.repository';
|
import type { TourTypesRepository } from '../../../src/db/repositories/TourTypes.repository';
|
||||||
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.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 { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||||
@@ -63,9 +63,28 @@ function makeService() {
|
|||||||
wanderer_ref: null,
|
wanderer_ref: null,
|
||||||
match_confidence: 1,
|
match_confidence: 1,
|
||||||
max_hiking_difficulty: 2,
|
max_hiking_difficulty: 2,
|
||||||
|
origin: 'trek',
|
||||||
planned: 0,
|
planned: 0,
|
||||||
has_waypoints: 1,
|
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 };
|
const place = { id: 42, trip_id: 7 };
|
||||||
let inTransaction = false;
|
let inTransaction = false;
|
||||||
/** Records whether each write ran inside the transaction. */
|
/** Records whether each write ran inside the transaction. */
|
||||||
@@ -87,6 +106,7 @@ function makeService() {
|
|||||||
};
|
};
|
||||||
const toursRepo = {
|
const toursRepo = {
|
||||||
findInTrip: vi.fn(async (): Promise<typeof row | undefined> => row),
|
findInTrip: vi.fn(async (): Promise<typeof row | undefined> => row),
|
||||||
|
findProvenance: vi.fn(async (): Promise<TourProvenanceRow | undefined> => provenance),
|
||||||
insertTour: inTx(undefined),
|
insertTour: inTx(undefined),
|
||||||
updateInTrip: inTx(true),
|
updateInTrip: inTx(true),
|
||||||
keepRecordedTrack: inTx(undefined),
|
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(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42);
|
||||||
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true });
|
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true });
|
||||||
expect(result.waypoints).toEqual(request.waypoints);
|
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(1, '7', 'tours:changed', { placeIds: [42] }, 'socket-1');
|
||||||
expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, '7', 'place:created', { place: setup.place }, '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, lng: 11, role: 'start', sequence: 0 },
|
||||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
{ 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 [
|
for (const method of [
|
||||||
setup.uow.transactional,
|
setup.uow.transactional,
|
||||||
setup.placesRepo.insertTourPlace,
|
setup.placesRepo.insertTourPlace,
|
||||||
|
|||||||
Reference in New Issue
Block a user