Files
TREK/server/tests/e2e/collection-tours.e2e.test.ts
T
Maurice 3fe34061fd feat(tours): keep whole tours in collections and take them into trips
A saved tour can now be kept in a collection, apart from any trip, and
later made into a tour of another trip. The bookmark button in a tour's
footer and in the planner's head (waiting for the save while there are
unsaved changes) opens a dialog with every list of the user; a list they
may only read stays in it, dimmed with a lock and a tooltip. The list
keeps an independent copy: the line, the type, every measure and setting,
the waypoints in order with their roles, the origin, and the trip and tour
it was saved from as they were called then. Its days, its links to places
of the trip and its obstacle decisions stay in the trip, and later changes
to the tour do not reach the copy.

A collection shows its kept tours as cards above its places: the grade
tile with the source badge, measures and the waypoint count as badges,
the trip it came from, and who kept it. The owner or an admin of the list
can remove one after a question; tours made from it stay. "Add to a trip"
shows the kept tour on a map with its facts and numbered waypoints, lists
the user's trips (the ones they may not add tours to dimmed), and asks
outright whether the waypoints come along. "Line only" keeps the line as
saved and adds no waypoints; a tour kept without a line can only bring
them. The new tour has an id of its own, no days and no new places, and
names the trip and tour it was saved from; the dialog then offers to open
that trip. Neither the collection nor the original tour changes.

On the server the kept tours are a table of their own in the tours domain
(collection_tours, migration 046100), each row written whole once. Every
copy, the in-trip "save as new tour", the take-over from another trip and
both directions here, now goes through one copy model (tour-copy.helpers)
instead of a second one. Reading and removing follow the collection's
roles, keeping one also needs access to the source trip, and taking one
over needs place_edit on the target trip; the kept tour is read again
inside the write, so one removed meanwhile leaves no half-made tour. The
MCP tools list_collection_tours, get_collection_tour,
save_tour_to_collection, take_over_collection_tour and
delete_collection_tour run over the same service and checks. All 27
locales carry the new strings, and the wiki describes the flow.
2026-10-20 15:32:47 +02:00

294 lines
13 KiB
TypeScript

/**
* Whole tours kept in a collection (LIB-03), through the real JwtAuthGuard,
* AddonGuard and TripAccessGuard against a migrated and seeded temp SQLite db,
* every answer checked against its response contract. The permission check is
* a container spy and the broadcasts go to a FakeRealtimeService.
*
* Fixtures: user 1 owns trips 5 and 6 and the collection, user 2 is a member
* of trip 5 whom the spy denies 'place_edit' and a viewer of the collection,
* user 3 has no access to either.
*/
import { CollectionTours } from '../../src/db/entities/CollectionTours.entity';
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 { Trips } from '../../src/db/entities/Trips.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { addCollectionMember, makeCollection } from '../helpers/factories/collections';
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const { journeyHooks } = vi.hoisted(() => ({
journeyHooks: {
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
},
}));
const db = createSnapshotTestDb();
const realtime = new FakeRealtimeService();
let orm: TestOrm;
let checkPermission: MockInstance;
let collectionId: number;
const OWNER = 1;
const MEMBER = 2;
const OUTSIDER = 3;
const GEOMETRY = [
[47, 11, 600],
[47.01, 11.01, 700],
[47.02, 11.02, 650],
];
const tourBody = (overrides: Record<string, unknown> = {}) => ({
name: 'Ridge walk',
route_geometry: GEOMETRY,
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, name: 'Saddle' },
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
],
max_hiking_difficulty: 3,
duration_seconds: 3600,
...overrides,
});
describe('Collection tours e2e (real guards + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
ToursModule,
],
})
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(JourneyDomainService)
.useValue(journeyHooks)
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
const createTour = async (tripId = 5, body: Record<string, unknown> = tourBody()) => {
const res = await request(server).post(`/api/trips/${tripId}/tours`).set(as(OWNER)).send(body);
expect(res.status).toBe(201);
return res.body.tour.place_id as number;
};
const keep = async (placeId: number, userId = OWNER, extra: Record<string, unknown> = {}) =>
request(server)
.post(`/api/addons/collections/${collectionId}/tours`)
.set(as(userId))
.send({ source_trip_id: 5, source_place_id: placeId, ...extra });
beforeAll(async () => {
orm = await createTestOrm(db);
// Pinned ids: the session cookies sign for exactly these users.
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'member', email: 'member@example.test', role: 'user', password_version: 0 });
await makeUser(orm, {
id: 3,
username: 'outsider',
email: 'outsider@example.test',
role: 'user',
password_version: 0,
});
await insertRows(orm, Trips, [
{ id: 5, user: 1, title: 'Alps' },
{ id: 6, user: 1, title: 'Other' },
]);
await addTripMember(orm, 5, 2);
const list = await makeCollection(orm, OWNER, { name: 'Hikes' });
collectionId = list.id;
await addCollectionMember(orm, collectionId, MEMBER, { role: 'viewer' });
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(async () => {
checkPermission.mockImplementation(
async (action: string, _role: string, _owner: number, userId: number) =>
!(action === 'place_edit' && userId === MEMBER),
);
await setAddonEnabled(orm, 'tours', true);
await setAddonEnabled(orm, 'collections', true);
realtime.broadcastMock.mockClear();
realtime.broadcastToUserMock.mockClear();
await deleteRows(orm, CollectionTours);
await deleteRows(orm, DayAssignments);
await deleteRows(orm, Places);
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('keeps a tour, lists it for every member and shows what taking it over asks about', async () => {
const source = await createTour();
const kept = await keep(source, OWNER, { name: 'Ridge, kept' });
expect(kept.status).toBe(201);
expect(kept.body.tour).toMatchObject({
name: 'Ridge, kept',
tour_type: 'hike',
has_line: true,
waypoint_count: 3,
source: { tripTitle: 'Alps', tourName: 'Ridge walk' },
saved_by: 'owner',
});
expect(realtime.broadcastToUserMock).toHaveBeenCalledWith(
MEMBER,
{ type: 'collections:updated', collectionId },
undefined,
);
const listed = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(MEMBER));
expect(listed.status).toBe(200);
expect(listed.body.tours.map((tour: { id: number }) => tour.id)).toEqual([kept.body.tour.id]);
const detail = await request(server)
.get(`/api/addons/collections/${collectionId}/tours/${kept.body.tour.id}`)
.set(as(MEMBER));
expect(detail.status).toBe(200);
expect(detail.body.line).toEqual(GEOMETRY.map(([lat, lng]) => [lat, lng]));
expect(detail.body.points).toEqual([
{ role: 'start', name: null, shaping: false },
{ role: 'via', name: 'Saddle', shaping: false },
{ role: 'end', name: null, shaping: false },
]);
// The member sees the trip they belong to, without the right to add a tour to it.
expect(detail.body.trips).toEqual([{ id: 5, title: 'Alps', start_date: null, end_date: null, can_add: false }]);
});
it('takes a kept tour into a trip with its points, or with its line only, and changes neither source', async () => {
const source = await createTour();
const kept = (await keep(source)).body.tour as { id: number };
// A later change to the source does not reach the kept tour.
await request(server)
.put(`/api/trips/5/tours/${source}`)
.set(as(OWNER))
.send(tourBody({ name: 'Changed later' }));
const full = await request(server)
.post('/api/trips/6/tours/from-collection')
.set(as(OWNER))
.send({ collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: true });
expect(full.status).toBe(201);
expect(full.body.tour).toMatchObject({ name: 'Ridge walk', planned: false, revision: 1 });
const roles = full.body.waypoints.map((point: { role: string; name?: string }) => [point.role, point.name ?? null]);
expect(roles).toEqual([
['start', null],
['via', 'Saddle'],
['end', null],
]);
expect(full.body.provenance.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge walk' });
expect(realtime.broadcastMock.mock.calls.every(([tripId]) => String(tripId) === '6')).toBe(true);
const bare = await request(server)
.post('/api/trips/6/tours/from-collection')
.set(as(OWNER))
.send({ collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: false, name: 'Bare' });
expect(bare.status).toBe(201);
expect(bare.body.tour).toMatchObject({ name: 'Bare', has_waypoints: false });
expect(await countRows(orm, TourWaypoints, { place: bare.body.tour.place_id })).toBe(0);
expect((await findRow(orm, Places, { id: bare.body.tour.place_id }))?.route_geometry).toBe(
JSON.stringify(GEOMETRY),
);
expect(await countRows(orm, Places, { trip: 6 })).toBe(2);
expect(await countRows(orm, DayAssignments)).toBe(0);
expect(await countRows(orm, CollectionTours)).toBe(1);
expect((await findRow(orm, Places, { id: source }))?.name).toBe('Changed later');
});
it('refuses a viewer keeping, a stranger reading, a trip without place_edit and an unsaid choice about the points', async () => {
const source = await createTour();
expect((await keep(source, MEMBER)).status).toBe(403);
const kept = (await keep(source)).body.tour as { id: number };
const stranger = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OUTSIDER));
expect(stranger.status).toBe(404);
expect(stranger.body).toEqual({ error: 'Collection not found' });
const body = { collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: true };
const takeOver = (tripId: number, userId: number) =>
request(server).post(`/api/trips/${tripId}/tours/from-collection`).set(as(userId)).send(body);
expect((await takeOver(5, MEMBER)).status).toBe(403);
expect((await takeOver(6, OUTSIDER)).status).toBe(404);
const unsaid = await request(server)
.post('/api/trips/6/tours/from-collection')
.set(as(OWNER))
.send({ collection_id: collectionId, collection_tour_id: kept.id });
expect(unsaid.status).toBe(400);
const wrongList = await request(server)
.post('/api/trips/6/tours/from-collection')
.set(as(OWNER))
.send({ ...body, collection_id: collectionId + 1000 });
expect(wrongList.status).toBe(404);
expect(await countRows(orm, Places, { trip: 6 })).toBe(0);
});
it('removes a kept tour for the owner only, and answers malformed ids, sessions and addons as the guards do', async () => {
const source = await createTour();
const kept = (await keep(source)).body.tour as { id: number };
const path = `/api/addons/collections/${collectionId}/tours/${kept.id}`;
expect((await request(server).delete(path).set(as(MEMBER))).status).toBe(403);
const removed = await request(server).delete(path).set(as(OWNER));
expect(removed.status).toBe(200);
expect(removed.body).toEqual({ success: true });
expect((await request(server).delete(path).set(as(OWNER))).status).toBe(404);
expect((await request(server).get(`/api/addons/collections/abc/tours`).set(as(OWNER))).status).toBe(404);
const malformed = await request(server).get(`/api/addons/collections/${collectionId}/tours/x`).set(as(OWNER));
expect(malformed.status).toBe(404);
expect((await request(server).get(`/api/addons/collections/${collectionId}/tours`)).status).toBe(401);
await setAddonEnabled(orm, 'collections', false);
const noCollections = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OWNER));
expect(noCollections.body).toEqual({ error: 'Collections addon is not enabled' });
await setAddonEnabled(orm, 'tours', false);
const noTours = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OWNER));
expect(noTours.status).toBe(404);
expect(noTours.body).toEqual({ error: 'Tours addon is not enabled' });
});
});