/**
* ACT-01 e2e: a recorded track assigned to a tour, through the real
* JwtAuthGuard, AddonGuard and TripAccessGuard against a real migrated temp
* SQLite db. The recording comes in through the existing GPX import, is
* assigned on purpose to one execution of a planned tour, read with the
* tour's detail and taken off again; the plan stays as it was all along.
*
* Fixtures: user 1 owns trip 5 (two days), user 2 is a member whom the
* permission spy denies 'place_edit', user 3 has no access.
*/
import { db } from '../../src/db/database';
import { Days } from '../../src/db/entities/Days.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { TourActivities } from '../../src/db/entities/TourActivities.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 { makeDayAssignment } from '../helpers/factories/itinerary';
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/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
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 = {
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
};
const realtime = new FakeRealtimeService();
const OWNER = 1;
const VIEWER = 2;
const OUTSIDER = 3;
const TRIP = 5;
const PLAN = [
[47, 11, 600],
[47.01, 11.01, 700],
[47.02, 11.02, 650],
];
const tourBody = {
name: 'Ridge walk',
route_geometry: PLAN,
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
],
max_hiking_difficulty: 3,
duration_seconds: 3600,
};
const WATCH_GPX = `
Watch export
600
720
660
`;
const PHONE_GPX = `
Phone export
`;
describe('Tour activities e2e (ACT-01)', () => {
let orm: TestOrm;
let server: Server;
let app: Awaited>;
let checkPermission: MockInstance;
let assignmentIds: number[];
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 () => {
const res = await request(server).post(`/api/trips/${TRIP}/tours`).set(as(OWNER)).send(tourBody);
expect(res.status).toBe(201);
return res.body.tour.place_id as number;
};
/** A recording through the one GPX import there is. */
const importTrack = async (xml: string, name: string) => {
const res = await request(server)
.post(`/api/trips/${TRIP}/tours/import/gpx`)
.set(as(OWNER))
.attach('file', Buffer.from(xml), { filename: name, contentType: 'application/gpx+xml' });
expect(res.status).toBe(201);
return res.body.tours[0] as { place_id: number; track_started_at: string | null };
};
const assign = (placeId: number, body: unknown, userId = OWNER) =>
request(server).put(`/api/trips/${TRIP}/tours/${placeId}/activities`).set(as(userId)).send(body);
const unassign = (placeId: number, activityId: number, userId = OWNER) =>
request(server).delete(`/api/trips/${TRIP}/tours/${placeId}/activities/${activityId}`).set(as(userId));
/** The tour on both days of the trip; answers its two day assignments. */
const onBothDays = async (placeId: number) => [
(await makeDayAssignment(orm, 3, placeId)).id,
(await makeDayAssignment(orm, 4, placeId)).id,
];
beforeAll(async () => {
orm = await createTestOrm(db);
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 3, username: 'outsider', email: 'out@example.test', role: 'user', password_version: 0 });
await insertRows(orm, Trips, [{ id: TRIP, user: 1, title: 'Alps' }]);
await addTripMember(orm, TRIP, 2);
await insertRows(orm, Days, [
{ id: 3, trip: TRIP, day_number: 1 },
{ id: 4, trip: TRIP, day_number: 2 },
]);
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(async () => {
checkPermission.mockImplementation(
(action: string, _role: string, _owner: number, userId: number) =>
!(action === 'place_edit' && userId === VIEWER),
);
await setAddonEnabled(orm, 'tours', true);
realtime.broadcastMock.mockClear();
await deleteRows(orm, Places);
assignmentIds = [];
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('ACT-E2E-001: a GPX recording assigned to a day of a planned tour shows its own values; the plan stays', async () => {
const tour = await createTour();
assignmentIds = await onBothDays(tour);
const watch = await importTrack(WATCH_GPX, 'watch.gpx');
expect(watch.track_started_at).toBe('2026-07-02T06:10:00Z');
const before = (await request(server).get(`/api/trips/${TRIP}/tours/${tour}`).set(as(OWNER))).body;
const res = await assign(tour, { track_place_id: watch.place_id, assignment_id: assignmentIds[1] });
expect(res.status).toBe(200);
expect(res.body.activities).toHaveLength(1);
expect(res.body.activities[0]).toMatchObject({
assignment_id: assignmentIds[1],
day_id: 4,
track: { place_id: watch.place_id, name: 'Watch export', origin: 'gpx', file_name: 'watch.gpx' },
metrics: {
elevation_gain_m: 120,
elevation_loss_m: 60,
started_at: '2026-07-02T06:10:00Z',
ended_at: '2026-07-02T09:25:00Z',
elapsed_minutes: 195,
point_count: 3,
},
});
expect(res.body.activities[0].geometry).toHaveLength(3);
// The planned values, line and revision are what they were.
for (const key of ['distance', 'elevation_gain', 'elevation_loss', 'duration', 'revision']) {
expect(res.body.tour[key]).toEqual(before.tour[key]);
}
expect(await findRow(orm, Places, { id: tour })).toMatchObject({ route_geometry: JSON.stringify(PLAN) });
expect(realtime.broadcastMock).toHaveBeenCalledWith(
String(TRIP),
'tours:changed',
{ placeIds: [tour, watch.place_id] },
undefined,
);
const listed = (await request(server).get(`/api/trips/${TRIP}/tours`).set(as(OWNER))).body.tours;
expect(listed.find((item: { place_id: number }) => item.place_id === tour).activity_tracks).toEqual([
{ id: res.body.activities[0].id, track_place_id: watch.place_id, assignment_id: assignmentIds[1] },
]);
expect(listed.find((item: { place_id: number }) => item.place_id === watch.place_id).activity_of).toBe(tour);
});
it('ACT-E2E-002: a track without times measures no time, and taking it off leaves it in the trip', async () => {
const tour = await createTour();
const phone = await importTrack(PHONE_GPX, 'phone.gpx');
const res = await assign(tour, { track_place_id: phone.place_id, assignment_id: null });
expect(res.status).toBe(200);
expect(res.body.activities[0].metrics).toMatchObject({
elevation_gain_m: null,
started_at: null,
ended_at: null,
elapsed_minutes: null,
});
const removed = await unassign(tour, res.body.activities[0].id);
expect(removed.status).toBe(200);
expect(removed.body.activities).toEqual([]);
expect(await countRows(orm, TourActivities)).toBe(0);
expect(await findRow(orm, Places, { id: phone.place_id })).not.toBeNull();
expect((await unassign(tour, res.body.activities[0].id)).status).toBe(404);
});
it('ACT-E2E-003: a refused assignment names why and changes nothing', async () => {
const tour = await createTour();
const other = await createTour();
const watch = await importTrack(WATCH_GPX, 'watch.gpx');
const otherAssignment = (await makeDayAssignment(orm, 3, other)).id;
const planned = await assign(tour, { track_place_id: other, assignment_id: null });
expect(planned.status).toBe(400);
expect(planned.body).toEqual({
error: 'Only an import that was not planned over can be assigned',
code: 'activity_track_not_recorded',
});
const foreignDay = await assign(tour, { track_place_id: watch.place_id, assignment_id: otherAssignment });
expect(foreignDay.status).toBe(400);
expect(foreignDay.body.code).toBe('activity_assignment_not_of_tour');
expect((await assign(tour, { track_place_id: watch.place_id })).status).toBe(400);
expect((await assign(tour, { track_place_id: 999_999, assignment_id: null })).status).toBe(404);
expect((await assign(tour, { track_place_id: watch.place_id, assignment_id: null })).status).toBe(200);
const taken = await assign(other, { track_place_id: watch.place_id, assignment_id: null });
expect(taken.status).toBe(409);
expect(taken.body.code).toBe('activity_track_in_use');
expect(await countRows(orm, TourActivities)).toBe(1);
});
it('ACT-E2E-004: the gates: 401 without a session, 404 without access or the addon, 403 without place_edit', async () => {
const tour = await createTour();
const watch = await importTrack(WATCH_GPX, 'watch.gpx');
const body = { track_place_id: watch.place_id, assignment_id: null };
expect((await request(server).put(`/api/trips/${TRIP}/tours/${tour}/activities`).send(body)).status).toBe(401);
expect((await assign(tour, body, OUTSIDER)).status).toBe(404);
expect((await unassign(tour, 1, OUTSIDER)).status).toBe(404);
expect((await assign(tour, body, VIEWER)).status).toBe(403);
expect((await unassign(tour, 1, VIEWER)).status).toBe(403);
await setAddonEnabled(orm, 'tours', false);
const off = await assign(tour, body);
expect(off.status).toBe(404);
expect(off.body).toEqual({ error: 'Tours addon is not enabled' });
expect(await countRows(orm, TourActivities)).toBe(0);
});
});