feat(tours): show a multi-day tour's stages in the list and put each on a day

A multi-day tour in the planner's list of the trip's tours now unfolds into
its stages (MDT-02), so the stages can be seen together and fitted into the
trip without leaving the one route they are cut from.

- The fold beside a multi-day tour lists its stages in route order, each
  from stop to stop with its distance, climb, descent and moving time, read
  off the saved tour the way the planner reads them, and below them what the
  whole tour adds up to. A value no stage tells is left out, a total some
  stages do not tell is marked as at least that much and says for how many,
  and a stage whose route is not planned yet says so.
- Each stage has its own day: a pill opens the trip's days, moves the stage
  to another one or takes it off again. A stage without a day shows a dashed
  "No day" pill. A day holds a tour, or one of its stages, once, and a stage
  is on one day at most; the plan's own offline path writes it.
- A stage opens in the planner: the tour opens as any tour does and the
  stage is shown once its points are in.
- The day plan names the stage a day holds with its own figures, and the
  day's route runs to that stage's start and on from its end; the trip map
  draws that stretch only. The tour list still counts a tour once, however
  many days hold it or its stages.
- A saved change that takes a stage away (its stage end gone, the tour a day
  tour again, back to the recorded track) takes it off its day in the same
  write and tells every client; the planner says so before the save.
- REST and the assign_place_to_day tool take the stage by the key of the
  stop it starts at, with the same checks; list_tours names the stops
  between the stages and the stage each day holds.

A day assignment remembers its stage by that key (migration 046010); every
assignment made before holds the whole tour, as it did.
This commit is contained in:
Maurice
2026-10-20 13:21:10 +02:00
parent 6ec06c90f4
commit 8fa75b3c4e
100 changed files with 2787 additions and 127 deletions
+30 -1
View File
@@ -39,7 +39,7 @@ import {
insertRows,
updateRows,
} from '../helpers/factories/rows';
import { makeTour } from '../helpers/factories/tours';
import { addTourWaypoints, makeTour } from '../helpers/factories/tours';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
@@ -283,6 +283,35 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
}
});
it('MDT-E2E-001: 201 puts a stage of a multi-day tour on a day, 409 a second day, 400 a stage it has not, 400 a malformed key', async () => {
const tourPlaceId = 21;
await insertRow(orm, Places, { id: tourPlaceId, trip: 5, name: 'Hut to hut' });
await makeTour(orm, tourPlaceId, { tourTypeRef: 'hike' });
await addTourWaypoints(orm, tourPlaceId, [
{ lat: 47, lng: 11, station_key: 'stop-start' },
{ lat: 47.1, lng: 11, station_key: 'stop-hut', stage_end: 1 },
{ lat: 47.2, lng: 11, station_key: 'stop-end' },
]);
const post = (dayId: number, body: Record<string, unknown>) =>
request(server).post(`/api/trips/5/days/${dayId}/assignments`).set('Cookie', sessionCookie(1)).send(body);
try {
const ok = await post(3, { place_id: tourPlaceId, tour_stage_key: 'stop-hut' });
expect(ok.status).toBe(201);
expect(ok.body.assignment).toMatchObject({ day_id: 3, tour_place_id: tourPlaceId, tour_stage_key: 'stop-hut' });
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: ok.body.assignment }, undefined);
const twice = await post(4, { place_id: tourPlaceId, tour_stage_key: 'stop-hut' });
expect([twice.status, twice.body]).toEqual([409, { error: 'Stage is already on a day' }]);
const none = await post(4, { place_id: tourPlaceId, tour_stage_key: 'stop-end' });
expect([none.status, none.body]).toEqual([400, { error: 'Tour has no such stage' }]);
const malformed = await post(4, { place_id: tourPlaceId, tour_stage_key: 'no' });
expect(malformed.status).toBe(400);
expect(await countRows(orm, DayAssignments, { place: tourPlaceId })).toBe(1);
} finally {
await dropTourPlace(tourPlaceId);
}
});
it('200 delete assignment reconciles journey skeletons', async () => {
reconcileTripSkeletons.mockClear();
const id = await seedAssignment();
+4 -3
View File
@@ -374,21 +374,22 @@ export interface TestDayAssignment {
place_id: number;
order_index: number;
notes: string | null;
tour_stage_key: string | null;
}
export function createDayAssignment(
db: Database.Database,
dayId: number,
placeId: number,
overrides: Partial<{ order_index: number; notes: string }> = {},
overrides: Partial<{ order_index: number; notes: string; tour_stage_key: string }> = {},
): TestDayAssignment {
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as {
max: number | null;
};
const orderIndex = overrides.order_index ?? (maxOrder.max !== null ? maxOrder.max + 1 : 0);
const result = db
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)')
.run(dayId, placeId, orderIndex, overrides.notes ?? null);
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, notes, tour_stage_key) VALUES (?, ?, ?, ?, ?)')
.run(dayId, placeId, orderIndex, overrides.notes ?? null, overrides.tour_stage_key ?? null);
return db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(result.lastInsertRowid) as TestDayAssignment;
}
@@ -0,0 +1,19 @@
/**
* The part of AssignmentsService that ToursService asks when a saved change
* takes stages of a multi-day tour off their days (MDT-02), for suites that
* build ToursService by hand. Given the day assignments repository it takes
* the rows off for real, so a suite on the test database sees them go;
* without one there is nothing to take off. The journey refresh after it is
* left out: the assignments suites cover it.
*/
import type { DayAssignmentsRepository } from '../../src/db/repositories/DayAssignments.repository';
import type { AssignmentsService } from '../../src/nest/assignments/assignments.service';
export function tourStageDays(dayAssignments?: DayAssignmentsRepository): AssignmentsService {
const stages = {
dropStaleTourStages: async (placeId: number, keys: readonly string[]) =>
dayAssignments ? await dayAssignments.deleteTourStagesNotIn(placeId, keys) : [],
reconcile: async () => undefined,
};
return stages as unknown as AssignmentsService;
}
@@ -75,6 +75,7 @@ const UNNUMBERED = [
'Migration20200101045900_a_tour_keeps_the_track_it_was_done_with',
'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities',
'Migration20200101046000_a_tour_waypoint_can_end_a_stage',
'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -1341,3 +1341,79 @@ describe('DayAssignmentsRepository.listPublicForShare (SH9, share.service.ts)',
expect(await assignments.listPublicForShare([])).toEqual([]);
});
});
describe('DayAssignmentsRepository tour stages (MDT-02)', () => {
/** A tour on a trip with three days, the first holding nothing yet. */
function tourOnTrip() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const days = [createDay(testDb, trip.id), createDay(testDb, trip.id), createDay(testDb, trip.id)];
const place = createPlace(testDb, trip.id);
createTour(testDb, place.id);
return { trip, days, place };
}
const stageOf = async (id: number) => (await findRow(t, DayAssignments, { id }))?.tour_stage_key;
it('ASSIGNREPO-043: writes the stage a day holds, and none for the whole tour', async () => {
const { days, place } = tourOnTrip();
const base = { place_id: place.id, order_index: 0, notes: null, accommodation_id: null };
const stage = await assignments.insertAssignment({ ...base, day_id: days[0].id, tour_stage_key: 'stage-one' });
const whole = await assignments.insertAssignment({ ...base, day_id: days[1].id });
expect(await stageOf(stage)).toBe('stage-one');
expect(await stageOf(whole)).toBeNull();
});
it('ASSIGNREPO-044: finds the day a stage is on, and nothing for a stage on none or of another tour', async () => {
const { trip, days, place } = tourOnTrip();
const held = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
expect(await assignments.findTourStage(place.id, 'stage-two')).toEqual({ id: held.id, day_id: days[1].id });
expect(await assignments.findTourStage(place.id, 'stage-one')).toBeUndefined();
const other = createPlace(testDb, trip.id);
expect(await assignments.findTourStage(other.id, 'stage-two')).toBeUndefined();
});
it('ASSIGNREPO-045: takes off only the stages not kept, leaving the whole tour and other tours alone', async () => {
const { trip, days, place } = tourOnTrip();
const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' });
const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
const whole = createDayAssignment(testDb, days[2].id, place.id);
const other = createPlace(testDb, trip.id);
const otherStage = createDayAssignment(testDb, days[2].id, other.id, { tour_stage_key: 'stage-two' });
const gone = await uow.transactional(() => assignments.deleteTourStagesNotIn(place.id, ['stage-one']));
expect(gone).toEqual([{ id: second.id, day_id: days[1].id }]);
const left = (await findRows(t, DayAssignments, {}, { id: 'asc' })).map((row) => row.id);
expect(left).toEqual([first.id, whole.id, otherStage.id]);
});
it('ASSIGNREPO-046: takes every stage off with no key kept, and writes nothing when no stage is held', async () => {
const { days, place } = tourOnTrip();
const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' });
const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
expect(await assignments.deleteTourStagesNotIn(place.id, [])).toEqual([
{ id: first.id, day_id: days[0].id },
{ id: second.id, day_id: days[1].id },
]);
expect(await assignments.deleteTourStagesNotIn(place.id, [])).toEqual([]);
});
it('ASSIGNREPO-047: a copied assignment keeps the stage it held', async () => {
const { days, place } = tourOnTrip();
const copy = {
place_id: place.id,
order_index: 0,
notes: null,
reservation_status: null,
reservation_notes: null,
reservation_datetime: null,
assignment_time: null,
assignment_end_time: null,
end_day: 0,
};
const stage = await assignments.insertAssignmentCopy({ ...copy, day_id: days[0].id, tour_stage_key: 'stage-one' });
const whole = await assignments.insertAssignmentCopy({ ...copy, day_id: days[1].id });
expect(await stageOf(stage)).toBe('stage-one');
expect(await stageOf(whole)).toBeNull();
});
});
@@ -263,6 +263,44 @@ describe('ToursRepository reads', () => {
});
});
describe('ToursRepository stages on days (MDT-02)', () => {
it('TOURREPO-017: listStagePoints reads the points in route order with key, role and stage end, none for a plain place', async () => {
const { trip } = fixture();
const hutToHut = await makePlace(t, trip.id, { name: 'Hut to hut' });
const plain = await makePlace(t, trip.id, { name: 'Plain' });
createTour(testDb, hutToHut.id);
await insertRows(t, TourWaypoints, [
{ place: hutToHut.id, lat: 47.2, lng: 11, role: 'end', sequence: 3, station_key: 'stop-end' },
{ place: hutToHut.id, lat: 47, lng: 11, role: 'start', sequence: 0, station_key: 'stop-start' },
{ place: hutToHut.id, lat: 47.1, lng: 11, role: 'via', sequence: 2, station_key: 'stop-hut', stage_end: 1 },
{ place: hutToHut.id, lat: 47.05, lng: 11, role: 'via', sequence: 1, shaping: 1 },
]);
expect(await repo.listStagePoints(hutToHut.id)).toEqual([
{ role: 'start', lat: 47, lng: 11, key: 'stop-start' },
{ role: 'via', lat: 47.05, lng: 11, shaping: true },
{ role: 'via', lat: 47.1, lng: 11, key: 'stop-hut', stage_end: true },
{ role: 'end', lat: 47.2, lng: 11, key: 'stop-end' },
]);
expect(await repo.listStagePoints(plain.id)).toEqual([]);
});
it('TOURREPO-018: listTourDays names the stage each day holds, null where a day holds the whole tour', async () => {
const { trip } = fixture();
const tour = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
const first = createDay(testDb, trip.id);
const second = createDay(testDb, trip.id);
createDayAssignment(testDb, first.id, tour.id);
createDayAssignment(testDb, second.id, tour.id, { tour_stage_key: 'stop-hut' });
expect((await repo.listTourDays(trip.id)).map((row) => [row.day_id, row.tour_stage_key])).toEqual([
[first.id, null],
[second.id, 'stop-hut'],
]);
});
});
describe('ToursRepository writes', () => {
it('TOURREPO-006: insertTour writes the metrics and leaves the unset metadata NULL', async () => {
const { trip } = fixture();
@@ -6,6 +6,7 @@ import { db as testDb } from '../../../src/db/database';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import {
createUser,
createTrip,
@@ -15,11 +16,12 @@ import {
createJourney,
} from '../../helpers/factories';
import { linkTripToJourney } from '../../helpers/factories/journeys';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createTour } from '../../helpers/tours-repos';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -108,6 +110,49 @@ describe('Tool: assign_place_to_day', () => {
});
});
it('MDT-MCP-001: puts one stage of a multi-day tour on a day, refusing as REST does', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const secondDay = createDay(testDb, trip.id);
const tour = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
await insertRows(
orm,
TourWaypoints,
['stop-start', 'stop-hut', 'stop-end'].map((station_key, sequence) => ({
place: tour.id,
lat: 47 + sequence / 10,
lng: 11,
role: sequence === 0 ? 'start' : sequence === 2 ? 'end' : 'via',
sequence,
station_key,
stage_end: sequence === 1 ? 1 : 0,
})),
);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: day.id, placeId: tour.id, tourStageKey: 'stop-hut' },
});
expect(parseToolResult(result)).toMatchObject({ assignment: { day_id: day.id, tour_stage_key: 'stop-hut' } });
const again = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: secondDay.id, placeId: tour.id, tourStageKey: 'stop-hut' },
});
expect(again.isError).toBe(true);
expect(JSON.stringify(again.content)).toContain('Stage is already on a day');
const unknown = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: secondDay.id, placeId: tour.id, tourStageKey: 'stop-end' },
});
expect(unknown.isError).toBe(true);
expect(JSON.stringify(unknown.content)).toContain('Tour has no such stage');
});
expect(await countRows(orm, DayAssignments, { place: tour.id })).toBe(1);
});
it('assigns a place to a day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -69,11 +69,23 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
expect(await new DayAssignmentsController(s).create(user, '5', '3', { place_id: 2, notes: 'n' }, 'sock')).toEqual(
{ assignment: { id: 9 } },
);
expect(createAssignment).toHaveBeenCalledWith('3', 2, 'n');
expect(createAssignment).toHaveBeenCalledWith('3', 2, 'n', {});
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock');
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
it('MDT-CTRL-001: hands a stage of a multi-day tour to the service, the same as the MCP tool', async () => {
const createAssignment = vi.fn().mockReturnValue({ id: 9, tour_stage_key: 'stop-hut' });
const broadcast = vi.fn();
const reconcile = vi.fn().mockResolvedValue(undefined);
const s = svc({ createAssignment, broadcast, reconcile } as Partial<AssignmentsService>);
const body = { place_id: 2, tour_stage_key: 'stop-hut' };
expect(await new DayAssignmentsController(s).create(user, '5', '3', body, 'sock')).toEqual({
assignment: { id: 9, tour_stage_key: 'stop-hut' },
});
expect(createAssignment).toHaveBeenCalledWith('3', 2, undefined, { tourStageKey: 'stop-hut' });
});
it('409 duplicate Tour assignment propagates without announcing a new assignment', async () => {
const broadcast = vi.fn();
const reconcile = vi.fn();
@@ -85,7 +97,7 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
expect(await thrownAsync(() => controller.create(user, '5', '3', { place_id: 42 }))).toMatchObject({
status: 409,
});
expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined);
expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined, {});
expect(broadcast).not.toHaveBeenCalled();
expect(reconcile).not.toHaveBeenCalled();
});
@@ -13,12 +13,14 @@ import { db as testDb } 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 { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { type TripAccess } from '../../../src/db/repositories/Trips.repository';
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
@@ -36,7 +38,7 @@ import {
} from '../../helpers/factories';
import { makeDayAccommodation } from '../../helpers/factories/itinerary';
import { tagPlace } from '../../helpers/factories/places';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
import {
createTestJourneysRepo,
createTestJourneyContributorsRepo,
@@ -377,6 +379,111 @@ describe('createAssignment', () => {
});
});
// ── stages of a multi-day tour on days (MDT-02) ─────────────────────────────
describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => {
/** A tour of three stages: its start, two huts that end a stage, its end. */
async function hutToHut(placeId: number) {
await addTourFacet(placeId);
await insertRows(
await orm(),
TourWaypoints,
['stop-start', 'stop-hut-one', 'stop-hut-two', 'stop-end'].map((station_key, sequence) => ({
place: placeId,
lat: 47 + sequence / 10,
lng: 11,
role: sequence === 0 ? 'start' : sequence === 3 ? 'end' : 'via',
sequence,
station_key,
stage_end: sequence === 1 || sequence === 2 ? 1 : 0,
})),
);
}
it('ASG-SVC-051: puts a stage on a day by the stop it starts at, and the answer and the day list say which', async () => {
const { day, place } = fixture();
await hutToHut(place.id);
const stage = await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-one' });
expect(stage).toMatchObject({ tour_place_id: place.id, tour_stage_key: 'stop-hut-one' });
expect((await svc.listDayAssignments(day.id)).map((a) => a.tour_stage_key)).toEqual(['stop-hut-one']);
});
it('ASG-SVC-052: a whole tour and an ordinary place carry no stage', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' });
expect(await svc.createAssignment(day.id, place.id)).toMatchObject({ tour_stage_key: null });
expect(await svc.createAssignment(day.id, ordinary.id)).toMatchObject({ tour_stage_key: null });
});
it('ASG-SVC-053: refuses a stage the tour does not have: its last stop, a stop ending none, a plain place, a day tour', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const plain = createPlace(testDb, trip.id, { name: 'Ordinary place' });
const dayTour = createPlace(testDb, trip.id, { name: 'Day hike' });
await addTourFacet(dayTour.id);
await insertRows(await orm(), TourWaypoints, [
{ place: dayTour.id, lat: 47, lng: 11, role: 'start', sequence: 0, station_key: 'day-start' },
{ place: dayTour.id, lat: 47.1, lng: 11, role: 'end', sequence: 1, station_key: 'day-end' },
]);
for (const [placeId, key] of [
[place.id, 'stop-end'],
[place.id, 'stop-unknown'],
[plain.id, 'stop-start'],
[dayTour.id, 'day-start'],
] as const) {
const refusal = await catchDomainError(() => svc.createAssignment(day.id, placeId, null, { tourStageKey: key }));
expect(refusal instanceof DomainError && [refusal.getStatus(), refusal.publicMessage]).toEqual([
400,
'Tour has no such stage',
]);
}
expect(await countRows(await orm(), DayAssignments, {})).toBe(0);
});
it('ASG-SVC-054: a stage is on one day at most, and a day holds a tour or one of its stages once', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const secondDay = createDay(testDb, trip.id);
await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-start' });
const taken = await catchDomainError(() =>
svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-start' }),
);
expect(taken instanceof DomainError && [taken.getStatus(), taken.publicMessage]).toEqual([
409,
'Stage is already on a day',
]);
await expect(svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-one' })).rejects.toThrow(
ConflictException,
);
// Another stage on another day is fine.
await expect(
svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-hut-one' }),
).resolves.toMatchObject({ day_id: secondDay.id, tour_stage_key: 'stop-hut-one' });
});
it('ASG-SVC-055: a stage keeps its key when it moves to another day', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const secondDay = createDay(testDb, trip.id);
const stage = await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-two' });
const { assignment } = await svc.moveAssignment(stage!.id, secondDay.id, 0);
expect(assignment).toMatchObject({ day_id: secondDay.id, tour_stage_key: 'stop-hut-two' });
});
it('ASG-SVC-056: dropStaleTourStages takes off the stages not kept and answers which', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const secondDay = createDay(testDb, trip.id);
await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-start' });
const second = await svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-hut-one' });
expect(await svc.dropStaleTourStages(place.id, ['stop-start'])).toEqual([
{ id: second!.id, day_id: secondDay.id },
]);
expect(await countRows(await orm(), DayAssignments, { place: place.id })).toBe(1);
});
});
// ── listDayAssignments ────────────────────────────────────────────────────────
describe('listDayAssignments', () => {
@@ -56,6 +56,7 @@ function makeRow(overrides: Partial<AssignmentWithPlaceRow> = {}): AssignmentWit
fill_percent: null,
tour_place_id: null,
tour_route_geometry: null,
tour_stage_key: null,
...overrides,
};
}
@@ -118,6 +119,12 @@ describe('formatAssignmentWithPlace', () => {
expect(tour).toMatchObject({ tour_place_id: 100, tour_route_geometry: geometry });
});
it('MDT-SHAPE-001: carries the stage of a multi-day tour a day holds, null for the whole tour', () => {
expect(formatAssignmentWithPlace(makeRow(), [], []).tour_stage_key).toBeNull();
const stage = formatAssignmentWithPlace(makeRow({ tour_place_id: 100, tour_stage_key: 'stop-hut' }), [], []);
expect(stage.tour_stage_key).toBe('stop-hut');
});
it('passes tags and participants through untouched', () => {
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
expect(result.place.tags).toEqual(sampleTags);
@@ -30,6 +30,7 @@ import { accommodationsOver } from '../../helpers/accommodations-service';
import { createTrip, createUser } from '../../helpers/factories';
import { countRows, findRows } from '../../helpers/factories/rows';
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { noGoogleQuota } from '../../helpers/google-quota';
import {
@@ -176,6 +177,7 @@ beforeAll(async () => {
await createTestTourActivitiesRepo(testDb),
buildPlaceImportService(),
noPaddleChecks(),
tourStageDays(),
);
});
+24 -2
View File
@@ -86,8 +86,8 @@ describe('ToursMcp', () => {
const listedRidge = tours.find((tour: { place_id: number }) => tour.place_id === ridge.id);
expect(listedRidge).toMatchObject({ name: 'Ridge', origin: expect.any(String), max_hiking_difficulty: 3 });
expect(listedRidge.days).toEqual([
{ day_id: day1.id, day_number: 1, date: '2026-07-10', title: 'Arrive' },
{ day_id: day2.id, day_number: 2, date: '2026-07-11', title: null },
{ day_id: day1.id, day_number: 1, date: '2026-07-10', title: 'Arrive', tour_stage_key: null },
{ day_id: day2.id, day_number: 2, date: '2026-07-11', title: null, tour_stage_key: null },
]);
expect(tours.find((tour: { place_id: number }) => tour.place_id === gorge.id).days).toEqual([]);
});
@@ -363,6 +363,28 @@ describe('ToursMcp', () => {
]);
});
it('TOURS-MCP-016: list_tours names the stops between the stages and the stage each day holds (MDT-02)', async () => {
const { user, trip, ridge, day1, day2 } = fixture();
const orm = await createTestOrm(db);
await addTourWaypoints(orm, ridge.id, [
{ lat: 47, lng: 11, station_key: 'stop-start' },
{ lat: 47.05, lng: 11.05, station_key: 'stop-hut', stage_end: 1 },
{ lat: 47.1, lng: 11.1, station_key: 'stop-end' },
]);
await orm.close();
const day3 = createDay(db, trip.id, { day_number: 3 });
createDayAssignment(db, day3.id, ridge.id, { tour_stage_key: 'stop-hut' });
const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
const listed = tours.find((tour: { place_id: number }) => tour.place_id === ridge.id);
expect(listed.stage_stops.map((stop: { key: string }) => stop.key)).toEqual(['stop-start', 'stop-hut', 'stop-end']);
expect(listed.days.map((day: { day_id: number; tour_stage_key: string | null }) => [day.day_id, day.tour_stage_key])).toEqual([
[day1.id, null],
[day2.id, null],
[day3.id, 'stop-hut'],
]);
});
it('TOURS-MCP-014: the tools name the station fields, and the deletes ask before a walk takes its protected stations along', () => {
const description = (ctor: unknown, method: string) => {
const entry = getEntry(ctor as ClassRef, method);
@@ -26,6 +26,7 @@ import type { StorageService } from '../../../src/nest/storage/storage.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import type { User } from '../../../src/types';
import { noActivities, noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { buildPlaceImportService } from '../../helpers/place-import';
import { keyed } from '../../helpers/tour-keys';
import type { EntityManager } from '@mikro-orm/core';
@@ -313,6 +314,7 @@ function tourRepositoryFixture(ownerTripId: number) {
noActivities(),
buildPlaceImportService(),
noPaddleChecks(),
tourStageDays(),
);
return { uow, toursRepo, waypointsRepo, placesRepo, places, service };
}
@@ -14,6 +14,7 @@ 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';
import { noActivities, noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { buildPlaceImportService } from '../../helpers/place-import';
import { keyed } from '../../helpers/tour-keys';
import { NotFoundException } from '@nestjs/common';
@@ -148,6 +149,7 @@ function makeService() {
noActivities(),
buildPlaceImportService(),
noPaddleChecks(),
tourStageDays(),
);
return { uow, toursRepo, waypointsRepo, visitsRepo, placesRepo, places, place, service };
}
+118 -2
View File
@@ -8,18 +8,25 @@
* real PlacesService, is covered by tours.gpx.atomic.test.ts.
*/
import { db as testDb } from '../../../src/db/database';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error';
import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { createPlace, createTrip, createUser } from '../../helpers/factories';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
import { countRows, deleteRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { buildPlaceImportService } from '../../helpers/place-import';
import { resetTestDb } from '../../helpers/test-db';
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
import {
createTestDayAssignmentsRepo,
createTestPlacesRepo,
createTestUnitOfWork,
sharedTestOrm,
} from '../../helpers/test-uow';
import { keyed } from '../../helpers/tour-keys';
import {
createTestTourActivitiesRepo,
@@ -90,6 +97,7 @@ beforeAll(async () => {
await createTestTourActivitiesRepo(testDb),
buildPlaceImportService(),
noPaddleChecks(),
tourStageDays(await createTestDayAssignmentsRepo(testDb)),
);
});
@@ -748,3 +756,111 @@ describe('ToursService multi-day tours (MDT-01)', () => {
expect(await count(Tours)).toBe(0);
});
});
describe('ToursService stages on days (MDT-02)', () => {
/** Three days hut to hut: the stops at the second and third point end a stage. */
const hutToHut: TourCreateRequest = {
...request,
planned_duration_minutes: null,
break_additional_minutes: 60,
duration_seconds: 30 * 3600,
route_geometry: [
[48, 11, 600],
[48.01, 11.02, 650],
[48.02, 11.04, 630],
[48.03, 11.06, 700],
],
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.01, lng: 11.02, role: 'via', sequence: 1, stage_end: true },
{ lat: 48.02, lng: 11.04, role: 'via', sequence: 2, stage_end: true },
{ lat: 48.03, lng: 11.06, role: 'end', sequence: 3 },
],
};
/** The tour saved, and each of its three stages on a day of its own. */
async function onThreeDays() {
const created = await service.createTour(tripId, hutToHut);
const placeId = created.tour.place_id;
const keys = created.waypoints.map((point) => String(point.key));
const days = [1, 2, 3].map(() => createDay(testDb, Number(tripId)));
const held = [0, 1, 2].map(
(stage) => createDayAssignment(testDb, days[stage].id, placeId, { tour_stage_key: keys[stage] }).id,
);
broadcast.mockReset();
return { created, placeId, keys, days, held };
}
const heldStages = async (placeId: number) =>
(await findRows(await sharedTestOrm(testDb), DayAssignments, { place: placeId }, { id: 'asc' })).map(
(row) => row.tour_stage_key,
);
it('MDT-SVC-004: lists the stops the stages run between, keyed, and none for a day tour', async () => {
const created = await service.createTour(tripId, hutToHut);
const keys = created.waypoints.map((point) => point.key);
expect(created.tour.stage_stops).toEqual(
hutToHut.waypoints.map((point, index) => ({ key: keys[index], lat: point.lat, lng: point.lng })),
);
const dayTour = await service.createTour(tripId, request);
expect(dayTour.tour.stage_stops).toBeNull();
const listed = new Map((await service.listTours(tripId)).map((tour) => [tour.place_id, tour.stage_stops]));
expect(listed.get(created.tour.place_id)?.map((stop) => stop.key)).toEqual(keys);
expect(listed.get(dayTour.tour.place_id)).toBeNull();
expect((await service.getTour(tripId, String(created.tour.place_id))).tour.stage_stops).toHaveLength(4);
});
it('MDT-SVC-005: a saved change takes only the stages it took away off their days, and tells every client', async () => {
const { created, placeId, keys, days, held } = await onThreeDays();
// The third point no longer ends a stage: the stage that started there is gone, the others keep their keys.
const joined = created.waypoints.map(({ stage_end: _end, ...point }, index) =>
index === 1 ? { ...point, stage_end: true } : point,
);
await service.updateTour(tripId, String(placeId), { ...hutToHut, waypoints: joined }, 'socket');
expect(await heldStages(placeId)).toEqual([keys[0], keys[1]]);
expect(broadcast).toHaveBeenCalledWith(
tripId,
'assignment:deleted',
{ assignmentId: held[2], dayId: days[2].id },
undefined,
);
// Back to a day tour, no day holds a stage of it any more.
await service.updateTour(tripId, String(placeId), request);
expect(await heldStages(placeId)).toEqual([]);
});
it('MDT-SVC-006: a change that keeps every stage leaves every day as it was and says nothing', async () => {
const { created, placeId, keys } = await onThreeDays();
await service.updateTour(tripId, String(placeId), { ...hutToHut, name: 'Renamed', waypoints: created.waypoints });
expect(await heldStages(placeId)).toEqual(keys.slice(0, 3));
expect(broadcast.mock.calls.filter(([, event]) => event === 'assignment:deleted')).toEqual([]);
});
it('MDT-SVC-007: going back to the recorded track takes every stage off its day', async () => {
const { placeId } = await onThreeDays();
await updateRows(
await sharedTestOrm(testDb),
Tours,
{ place: placeId },
{ recorded_route_geometry: JSON.stringify(hutToHut.route_geometry) },
);
(await sharedTestOrm(testDb)).clear();
await service.restoreRecordedTrack(tripId, String(placeId));
expect(await heldStages(placeId)).toEqual([]);
expect(broadcast.mock.calls.filter(([, event]) => event === 'assignment:deleted')).toHaveLength(3);
});
it('MDT-SVC-008: a stage notice that cannot be sent never fails the saved change', async () => {
const { created, placeId } = await onThreeDays();
broadcast.mockImplementation((_tripId: string, event: string) => {
if (event === 'assignment:deleted') throw new Error('socket closed');
});
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const dayTour = created.waypoints.map(({ stage_end: _end, ...point }) => point);
await expect(
service.updateTour(tripId, String(placeId), { ...hutToHut, duration_seconds: 3600, waypoints: dayTour }),
).resolves.toMatchObject({ tour: { stage_count: null } });
expect(await heldStages(placeId)).toEqual([]);
expect(warn).toHaveBeenCalled();
});
});