mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
52ed63715c
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.
879 lines
38 KiB
TypeScript
879 lines
38 KiB
TypeScript
/**
|
|
* Tours module e2e: every /api/trips/:tripId/tours endpoint through the real
|
|
* JwtAuthGuard, AddonGuard and TripAccessGuard against a real migrated and
|
|
* seeded temp SQLite db (createSnapshotTestDb()). ToursService, PlacesService,
|
|
* AssignmentsService and TripsService run their real SQL; only the permission
|
|
* check (a container spy) and the journey hooks are stubbed, and the broadcast
|
|
* goes to a FakeRealtimeService.
|
|
*
|
|
* Fixtures: user 1 owns trips 5 and 6, user 2 is a member of trip 5 whom the
|
|
* permission spy denies 'place_edit', user 3 has no access to either trip.
|
|
* AssignmentsModule and TripsModule are mounted next to ToursModule so the
|
|
* one-tour-per-day 409 on move and the trip copy run through their real routes.
|
|
*/
|
|
import { db } from '../../src/db/database';
|
|
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
|
import { Days } from '../../src/db/entities/Days.entity';
|
|
import { Places } from '../../src/db/entities/Places.entity';
|
|
import { TourSources } from '../../src/db/entities/TourSources.entity';
|
|
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
|
|
import { Tours } from '../../src/db/entities/Tours.entity';
|
|
import { Trips } from '../../src/db/entities/Trips.entity';
|
|
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
|
|
import { BudgetService } from '../../src/nest/budget/budget.service';
|
|
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 { TripsModule } from '../../src/nest/trips/trips.module';
|
|
import { makeDayAssignment } from '../helpers/factories/itinerary';
|
|
import { makePlace, type PlaceRow } from '../helpers/factories/places';
|
|
import { countRows, deleteRows, findRow, insertRows, updateRows } 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 { describe, it, expect, beforeAll, afterAll, beforeEach, 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 } = vi.hoisted(() => ({
|
|
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 broadcast = realtime.broadcastMock;
|
|
|
|
let orm: TestOrm;
|
|
|
|
let checkPermission: MockInstance;
|
|
|
|
const OWNER = 1;
|
|
const VIEWER = 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.02, lng: 11.02, role: 'end', sequence: 1 },
|
|
],
|
|
max_hiking_difficulty: 3,
|
|
duration_seconds: 3600,
|
|
...overrides,
|
|
});
|
|
|
|
const TRACK_GPX = `<?xml version="1.0"?>
|
|
<gpx version="1.1" creator="e2e"><trk><name>Alpine loop</name><trkseg>
|
|
<trkpt lat="49" lon="12"><ele>100</ele></trkpt>
|
|
<trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
|
|
<trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt>
|
|
</trkseg></trk></gpx>`;
|
|
const FLAT_GPX = `<?xml version="1.0"?>
|
|
<gpx version="1.1" creator="e2e"><trk><name>Flat path</name><trkseg>
|
|
<trkpt lat="50" lon="13"/><trkpt lat="50.01" lon="13.01"/>
|
|
</trkseg></trk></gpx>`;
|
|
// Large enough that a guard rejecting before multer drained the body would
|
|
// surface as a connection reset instead of a JSON response.
|
|
const BIG_GPX = TRACK_GPX.replace('</gpx>', `<!-- ${'x'.repeat(512 * 1024)} --></gpx>`);
|
|
|
|
describe('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,
|
|
AssignmentsModule,
|
|
TripsModule,
|
|
],
|
|
})
|
|
.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 setAddon = (enabled: boolean) => setAddonEnabled(orm, 'tours', enabled);
|
|
|
|
/** The named columns of a stored place, as a projected SELECT would return them. */
|
|
const placeColumns = async <K extends keyof PlaceRow>(id: number, ...keys: K[]) => {
|
|
const row = await findRow(orm, Places, { id });
|
|
return Object.fromEntries(keys.map((k) => [k, row?.[k]])) as Pick<PlaceRow, K>;
|
|
};
|
|
|
|
/** A plain place on the trip, no coordinates and no category. */
|
|
const plainPlace = async (tripId: number, name: string) =>
|
|
(await makePlace(orm, tripId, { name, lat: null, lng: null, category: null })).id;
|
|
|
|
const createViaApi = async (tripId = 5, body: Record<string, unknown> = tourBody()) => {
|
|
const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER)).send(body);
|
|
expect(res.status).toBe(201);
|
|
return res.body.tour.place_id as number;
|
|
};
|
|
|
|
const importGpx = (userId: number, xml: string | null, tripId = 5) => {
|
|
const req = request(server).post(`/api/trips/${tripId}/tours/import/gpx`).set('Cookie', sessionCookie(userId));
|
|
return xml === null
|
|
? req.field('note', 'none')
|
|
: req.attach('file', Buffer.from(xml), { filename: 'track.gpx', contentType: 'application/gpx+xml' });
|
|
};
|
|
|
|
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: 'viewer', email: 'viewer@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);
|
|
await insertRows(orm, Days, [
|
|
{ id: 3, trip: 5, day_number: 1 },
|
|
{ id: 4, trip: 5, day_number: 2 },
|
|
]);
|
|
app = await build();
|
|
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
|
vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockResolvedValue([]);
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// The viewer is a trip member whose 'place_edit' is withheld; every other
|
|
// action and user passes, so the owner can create, move and copy.
|
|
checkPermission.mockImplementation(
|
|
(action: string, _role: string, _owner: number, userId: number) =>
|
|
!(action === 'place_edit' && userId === VIEWER),
|
|
);
|
|
await setAddon(true);
|
|
broadcast.mockClear();
|
|
await deleteRows(orm, DayAssignments);
|
|
await deleteRows(orm, Places);
|
|
await deleteRows(orm, Trips, { id: { $nin: [5, 6] } });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
});
|
|
|
|
describe('addon and access gates', () => {
|
|
it('401 without a cookie', async () => {
|
|
expect((await request(server).get('/api/trips/5/tours')).status).toBe(401);
|
|
});
|
|
|
|
it('404 on every route while the addon is disabled', async () => {
|
|
const placeId = await createViaApi();
|
|
await setAddon(false);
|
|
const off = { error: 'Tours addon is not enabled' };
|
|
const cookie = sessionCookie(OWNER);
|
|
const responses = [
|
|
await request(server).get('/api/trips/5/tours').set('Cookie', cookie),
|
|
await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie),
|
|
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
|
await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', cookie)
|
|
.send(tourBody({ name: 'Renamed' })),
|
|
await importGpx(OWNER, BIG_GPX),
|
|
];
|
|
for (const res of responses) {
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual(off);
|
|
}
|
|
expect(await countRows(orm, Tours)).toBe(1);
|
|
expect(await placeColumns(placeId, 'name')).toEqual({ name: 'Ridge walk' });
|
|
});
|
|
|
|
it('404 Trip not found for a user without access to the trip', async () => {
|
|
const placeId = await createViaApi();
|
|
const cookie = sessionCookie(OUTSIDER);
|
|
const responses = [
|
|
await request(server).get('/api/trips/5/tours').set('Cookie', cookie),
|
|
await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie),
|
|
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
|
await request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie).send(tourBody()),
|
|
await importGpx(OUTSIDER, BIG_GPX),
|
|
await request(server).get('/api/trips/999/tours').set('Cookie', sessionCookie(OWNER)),
|
|
];
|
|
for (const res of responses) {
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Trip not found' });
|
|
}
|
|
expect(await countRows(orm, Tours)).toBe(1);
|
|
});
|
|
|
|
it('a member without place_edit can read but gets 403 on every write', async () => {
|
|
const placeId = await createViaApi();
|
|
const cookie = sessionCookie(VIEWER);
|
|
|
|
const list = await request(server).get('/api/trips/5/tours').set('Cookie', cookie);
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.tours).toHaveLength(1);
|
|
expect((await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie)).status).toBe(200);
|
|
|
|
const writes = [
|
|
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
|
await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', cookie)
|
|
.send(tourBody({ name: 'Hijacked' })),
|
|
await importGpx(VIEWER, BIG_GPX),
|
|
];
|
|
for (const res of writes) {
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'No permission' });
|
|
}
|
|
expect(await countRows(orm, Tours)).toBe(1);
|
|
expect(await placeColumns(placeId, 'name')).toEqual({ name: 'Ridge walk' });
|
|
});
|
|
});
|
|
|
|
describe('POST and GET', () => {
|
|
it('201 creates the place, the facet and its waypoints, then lists and reads them back', async () => {
|
|
const res = await request(server)
|
|
.post('/api/trips/5/tours')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.set('X-Socket-Id', 'sock-1')
|
|
.send(tourBody());
|
|
expect(res.status).toBe(201);
|
|
const placeId = res.body.tour.place_id as number;
|
|
expect(res.body.tour).toMatchObject({
|
|
place_id: placeId,
|
|
name: 'Ridge walk',
|
|
tour_type: 'hike',
|
|
duration: 60,
|
|
match_confidence: 1,
|
|
max_hiking_difficulty: 3,
|
|
planned: false,
|
|
caution: false,
|
|
has_waypoints: true,
|
|
});
|
|
expect(res.body.tour.distance).toBeGreaterThan(0);
|
|
expect(res.body.tour.elevation_gain).toBe(100);
|
|
expect(res.body.tour.elevation_loss).toBe(50);
|
|
expect(res.body.waypoints).toEqual(tourBody().waypoints);
|
|
|
|
expect(await placeColumns(placeId, 'trip_id', 'lat', 'lng', 'transport_mode', 'route_geometry')).toEqual({
|
|
trip_id: 5,
|
|
lat: 47,
|
|
lng: 11,
|
|
transport_mode: 'walking',
|
|
route_geometry: JSON.stringify(GEOMETRY),
|
|
});
|
|
const events = broadcast.mock.calls.map((c) => c[1]);
|
|
expect(events).toEqual(expect.arrayContaining(['tours:changed', 'place:created']));
|
|
expect(broadcast.mock.calls.every((c) => c[3] === 'sock-1')).toBe(true);
|
|
|
|
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.tours).toEqual([res.body.tour]);
|
|
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(detail.status).toBe(200);
|
|
expect(detail.body).toEqual(res.body);
|
|
});
|
|
|
|
it('lists newest first and reports planned once a day holds the tour', async () => {
|
|
const first = await createViaApi(5, tourBody({ name: 'First' }));
|
|
const second = await createViaApi(5, tourBody({ name: 'Second' }));
|
|
await makeDayAssignment(orm, 3, first, { order_index: 0 });
|
|
const res = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
|
expect(res.body.tours.map((t: { place_id: number; planned: boolean }) => [t.place_id, t.planned])).toEqual([
|
|
[second, false],
|
|
[first, true],
|
|
]);
|
|
});
|
|
|
|
it('400 for an invalid body, nothing written', async () => {
|
|
const cookie = sessionCookie(OWNER);
|
|
const badRole = await request(server)
|
|
.post('/api/trips/5/tours')
|
|
.set('Cookie', cookie)
|
|
.send(
|
|
tourBody({
|
|
waypoints: [
|
|
{ lat: 47, lng: 11, role: 'via', sequence: 0 },
|
|
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
|
],
|
|
}),
|
|
);
|
|
expect(badRole.status).toBe(400);
|
|
const shortRoute = await request(server)
|
|
.post('/api/trips/5/tours')
|
|
.set('Cookie', cookie)
|
|
.send(tourBody({ route_geometry: [[47, 11, 600]] }));
|
|
expect(shortRoute.status).toBe(400);
|
|
const badDifficulty = await request(server)
|
|
.post('/api/trips/5/tours')
|
|
.set('Cookie', cookie)
|
|
.send(tourBody({ max_hiking_difficulty: 7 }));
|
|
expect(badDifficulty.status).toBe(400);
|
|
expect(await countRows(orm, Places)).toBe(0);
|
|
});
|
|
|
|
it('GET 404 for a tour of another trip, a plain place and a non-numeric id', async () => {
|
|
const foreign = await createViaApi(6);
|
|
const plain = await plainPlace(5, 'Cafe');
|
|
for (const id of [foreign, plain, 'abc', 999999]) {
|
|
const res = await request(server).get(`/api/trips/5/tours/${id}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Tour not found' });
|
|
}
|
|
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
|
expect(list.body.tours).toEqual([]);
|
|
});
|
|
|
|
it('GET falls back to the geometry endpoints for a tour without saved waypoints', async () => {
|
|
const res = await importGpx(OWNER, TRACK_GPX);
|
|
expect(res.status).toBe(201);
|
|
const placeId = res.body.tours[0].place_id as number;
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(detail.status).toBe(200);
|
|
expect(detail.body.tour).toMatchObject({ place_id: placeId, has_waypoints: false });
|
|
expect(detail.body.waypoints).toEqual([
|
|
{ lat: 49, lng: 12, role: 'start', sequence: 0 },
|
|
{ lat: 49.02, lng: 12.02, role: 'end', sequence: 1 },
|
|
]);
|
|
expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('PUT', () => {
|
|
it('200 replaces the route, metrics and waypoints', async () => {
|
|
const placeId = await createViaApi();
|
|
const geometry = [
|
|
[46, 10, 1000],
|
|
[46.01, 10.01, 1200],
|
|
[46.02, 10.02, 1100],
|
|
];
|
|
const waypoints = [
|
|
{ lat: 46, lng: 10, role: 'start', sequence: 0 },
|
|
{ lat: 46.01, lng: 10.01, role: 'via', sequence: 1 },
|
|
{ lat: 46.02, lng: 10.02, role: 'end', sequence: 2 },
|
|
];
|
|
const res = await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(
|
|
tourBody({
|
|
name: 'Summit',
|
|
route_geometry: geometry,
|
|
waypoints,
|
|
max_hiking_difficulty: 5,
|
|
duration_seconds: null,
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.tour).toMatchObject({
|
|
place_id: placeId,
|
|
name: 'Summit',
|
|
elevation_gain: 200,
|
|
elevation_loss: 100,
|
|
duration: null,
|
|
max_hiking_difficulty: 5,
|
|
});
|
|
expect(res.body.waypoints).toEqual(waypoints);
|
|
expect(await placeColumns(placeId, 'name', 'lat', 'lng', 'route_geometry')).toEqual({
|
|
name: 'Summit',
|
|
lat: 46,
|
|
lng: 10,
|
|
route_geometry: JSON.stringify(geometry),
|
|
});
|
|
expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(3);
|
|
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
|
|
});
|
|
|
|
it('404 for a tour of another trip, a plain place and a non-numeric id, nothing changes', async () => {
|
|
const foreign = await createViaApi(6);
|
|
const plain = await plainPlace(5, 'Cafe');
|
|
for (const id of [foreign, plain, 'abc']) {
|
|
const res = await request(server)
|
|
.put(`/api/trips/5/tours/${id}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ name: 'Moved' }));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Tour not found' });
|
|
}
|
|
expect(await placeColumns(foreign, 'trip_id', 'name')).toEqual({ trip_id: 6, name: 'Ridge walk' });
|
|
expect(await placeColumns(plain, 'name', 'route_geometry')).toEqual({ name: 'Cafe', route_geometry: null });
|
|
expect(await countRows(orm, Tours, { place: plain })).toBe(0);
|
|
expect(await countRows(orm, TourWaypoints, { place: foreign })).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('POST import/gpx', () => {
|
|
it('201 imports each track as a hike tour', async () => {
|
|
const res = await importGpx(OWNER, TRACK_GPX);
|
|
expect(res.status).toBe(201);
|
|
expect(res.body).toMatchObject({ caution: false, skipped: 0 });
|
|
expect(res.body.tours).toHaveLength(1);
|
|
const tour = res.body.tours[0];
|
|
expect(tour).toMatchObject({
|
|
name: 'Alpine loop',
|
|
tour_type: 'hike',
|
|
match_confidence: 1,
|
|
max_hiking_difficulty: null,
|
|
has_waypoints: false,
|
|
});
|
|
expect(await placeColumns(tour.place_id, 'trip_id')).toEqual({ trip_id: 5 });
|
|
expect(await findRow(orm, Tours, { place: tour.place_id })).toMatchObject({
|
|
tour_type: 'hike',
|
|
max_hiking_difficulty: null,
|
|
});
|
|
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:created']));
|
|
});
|
|
|
|
it('flags a track without elevation with caution', async () => {
|
|
const res = await importGpx(OWNER, FLAT_GPX);
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.caution).toBe(true);
|
|
expect(res.body.tours[0]).toMatchObject({ match_confidence: 0.3, caution: true });
|
|
});
|
|
|
|
it('a second import of the same track is skipped as a duplicate', async () => {
|
|
expect((await importGpx(OWNER, TRACK_GPX)).status).toBe(201);
|
|
const again = await importGpx(OWNER, TRACK_GPX);
|
|
expect(again.status).toBe(201);
|
|
expect(again.body).toEqual({ tours: [], caution: false, skipped: 1 });
|
|
expect(await countRows(orm, Tours)).toBe(1);
|
|
});
|
|
|
|
it('400 without a file, and for a file with no track or route', async () => {
|
|
const missing = await importGpx(OWNER, null);
|
|
expect(missing.status).toBe(400);
|
|
expect(missing.body).toEqual({ error: 'No file uploaded' });
|
|
for (const xml of ['<not-gpx/>', '<gpx/>', '<gpx><wpt lat="1" lon="2"/></gpx>', 'plain text']) {
|
|
const res = await importGpx(OWNER, xml);
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: 'No track or route found in GPX file' });
|
|
}
|
|
expect(await countRows(orm, Places)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('provenance (P2C-02)', () => {
|
|
const detail = async (placeId: number) =>
|
|
(await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body;
|
|
|
|
it('a planned tour says it was planned here, with no source invented', async () => {
|
|
const placeId = await createViaApi();
|
|
const body = await detail(placeId);
|
|
expect(body.tour.origin).toBe('trek');
|
|
expect(body.provenance).toMatchObject({
|
|
origin: 'trek',
|
|
source: { kind: 'trek' },
|
|
derivedFrom: null,
|
|
plannedOver: false,
|
|
editedLocally: null,
|
|
});
|
|
expect(body.provenance.createdAt).toEqual(expect.any(String));
|
|
});
|
|
|
|
it('a GPX import keeps its origin and file name, also after it is planned over in place', async () => {
|
|
const res = await importGpx(OWNER, TRACK_GPX);
|
|
const placeId = res.body.tours[0].place_id as number;
|
|
expect(res.body.tours[0].origin).toBe('gpx');
|
|
expect((await detail(placeId)).provenance).toMatchObject({
|
|
origin: 'gpx',
|
|
source: { kind: 'gpx', fileName: 'track.gpx' },
|
|
plannedOver: false,
|
|
});
|
|
|
|
await request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)).send(tourBody());
|
|
const after = await detail(placeId);
|
|
expect(after.tour).toMatchObject({ origin: 'gpx', has_waypoints: true });
|
|
expect(after.provenance).toMatchObject({ origin: 'gpx', plannedOver: true });
|
|
});
|
|
|
|
it('a tour planned from an import names it while it is in the trip', async () => {
|
|
const res = await importGpx(OWNER, TRACK_GPX);
|
|
const source = res.body.tours[0].place_id as number;
|
|
const derived = await createViaApi(5, tourBody({ derived_from_place_id: source }));
|
|
const sourceName = (await placeColumns(source, 'name')).name;
|
|
expect((await detail(derived)).provenance).toMatchObject({
|
|
origin: 'trek',
|
|
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)', () => {
|
|
const copy = (
|
|
placeId: number,
|
|
body: Record<string, unknown> = { name: 'Ridge (copy)' },
|
|
userId = OWNER,
|
|
tripId = 5,
|
|
) =>
|
|
request(server)
|
|
.post(`/api/trips/${tripId}/tours/${placeId}/copy`)
|
|
.set('Cookie', sessionCookie(userId))
|
|
.send(body);
|
|
const detail = async (placeId: number) =>
|
|
(await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body;
|
|
|
|
it('makes an independent tour with its own line and control points, no days, pointing at its source', async () => {
|
|
const source = await createViaApi();
|
|
await makeDayAssignment(orm, 3, source, { order_index: 0 });
|
|
const res = await copy(source);
|
|
expect(res.status).toBe(201);
|
|
const copyId = res.body.tour.place_id as number;
|
|
expect(copyId).not.toBe(source);
|
|
expect(res.body.tour).toMatchObject({
|
|
name: 'Ridge (copy)',
|
|
derived_from_place_id: source,
|
|
planned: false,
|
|
origin: 'trek',
|
|
});
|
|
expect(res.body.waypoints).toEqual((await detail(source)).waypoints);
|
|
expect(res.body.provenance).toMatchObject({ derivedFrom: { placeId: source } });
|
|
expect((await placeColumns(copyId, 'route_geometry')).route_geometry).toBe(
|
|
(await placeColumns(source, 'route_geometry')).route_geometry,
|
|
);
|
|
|
|
// Editing the copy leaves the original as it was.
|
|
await request(server)
|
|
.put(`/api/trips/5/tours/${copyId}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ name: 'Shorter ridge' }));
|
|
expect((await detail(source)).tour).toMatchObject({ name: tourBody().name, planned: true });
|
|
});
|
|
|
|
it('a copied GPX import stays a GPX import with its file name', async () => {
|
|
const imported = await importGpx(OWNER, TRACK_GPX);
|
|
const source = imported.body.tours[0].place_id as number;
|
|
const res = await copy(source, { name: 'Track again' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.tour).toMatchObject({ origin: 'gpx', has_waypoints: false });
|
|
expect(res.body.provenance).toMatchObject({ source: { kind: 'gpx', fileName: 'track.gpx' } });
|
|
});
|
|
|
|
it('refuses an empty name, a tour of another trip, a plain place, and someone who may not add places', async () => {
|
|
const source = await createViaApi();
|
|
expect((await copy(source, { name: ' ' })).status).toBe(400);
|
|
expect((await copy(source, { name: 'x' }, VIEWER)).status).toBe(403);
|
|
expect((await copy(await plainPlace(5, 'Cafe'))).status).toBe(404);
|
|
const foreign = await createViaApi(6);
|
|
expect((await copy(foreign)).status).toBe(404);
|
|
const notAnId = await request(server)
|
|
.post('/api/trips/5/tours/abc/copy')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send({ name: 'x' });
|
|
expect(notAnId.status).toBe(404);
|
|
});
|
|
|
|
it('a tour whose line holds no point has nothing to copy', async () => {
|
|
const source = await createViaApi();
|
|
await updateRows(orm, Places, { id: source }, { route_geometry: '[]' });
|
|
const before = await countRows(orm, Tours, {});
|
|
expect((await copy(source)).status).toBe(404);
|
|
expect(await countRows(orm, Tours, {})).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('planning over an import (PR1-05)', () => {
|
|
/** An imported GPX tour on trip 5: no waypoints, the recorded line as it came. */
|
|
const importedTour = async (tripId = 5) => {
|
|
const res = await importGpx(OWNER, TRACK_GPX, tripId);
|
|
expect(res.status).toBe(201);
|
|
return res.body.tours[0].place_id as number;
|
|
};
|
|
const listed = async (placeId: number, tripId = 5) =>
|
|
(await request(server).get(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER))).body.tours.find(
|
|
(tour: { place_id: number }) => tour.place_id === placeId,
|
|
);
|
|
const restore = (placeId: number, userId = OWNER, tripId = 5) =>
|
|
request(server)
|
|
.post(`/api/trips/${tripId}/tours/${placeId}/restore-recorded`)
|
|
.set('Cookie', sessionCookie(userId));
|
|
|
|
it('a new tour planned from an import remembers it; the import stays as it was', async () => {
|
|
const source = await importedTour();
|
|
const recorded = (await placeColumns(source, 'route_geometry')).route_geometry;
|
|
const derived = await createViaApi(5, tourBody({ derived_from_place_id: source }));
|
|
|
|
expect(await listed(derived)).toMatchObject({ derived_from_place_id: source, has_waypoints: true });
|
|
expect(await listed(source)).toMatchObject({
|
|
derived_from_place_id: null,
|
|
has_waypoints: false,
|
|
has_recorded_track: false,
|
|
});
|
|
expect((await placeColumns(source, 'route_geometry')).route_geometry).toBe(recorded);
|
|
|
|
// Deleting the import keeps the derived tour and forgets where it came from.
|
|
await request(server).delete(`/api/trips/5/places/${source}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(await listed(derived)).toMatchObject({ derived_from_place_id: null });
|
|
});
|
|
|
|
const refusedOrigins: Record<string, () => Promise<number>> = {
|
|
'a planned tour': () => createViaApi(),
|
|
'a tour of another trip': () => importedTour(6),
|
|
'no tour at all': () => plainPlace(5, 'Cafe'),
|
|
'a place that does not exist': async () => 999_999,
|
|
};
|
|
it.each(Object.keys(refusedOrigins))('400 for an origin that is %s, and nothing is written', async (kind) => {
|
|
const origin = await refusedOrigins[kind]();
|
|
const before = await countRows(orm, Tours, {});
|
|
const res = await request(server)
|
|
.post('/api/trips/5/tours')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ derived_from_place_id: origin }));
|
|
expect(res.status).toBe(400);
|
|
expect(await countRows(orm, Tours, {})).toBe(before);
|
|
});
|
|
|
|
it('planning an import over in place keeps its recorded track, and restoring brings it back', async () => {
|
|
const placeId = await importedTour();
|
|
const recorded = (await placeColumns(placeId, 'route_geometry')).route_geometry;
|
|
const before = await listed(placeId);
|
|
|
|
const put = await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody());
|
|
expect(put.status).toBe(200);
|
|
expect(await listed(placeId)).toMatchObject({ has_waypoints: true, has_recorded_track: true });
|
|
// A second edit does not overwrite the track kept the first time.
|
|
await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ name: 'Edited again' }));
|
|
|
|
broadcast.mockClear();
|
|
const res = await restore(placeId);
|
|
expect(res.status).toBe(200);
|
|
// An import's detail names its two ends from the line, as before it was planned over.
|
|
expect(res.body.waypoints.map((point: { role: string }) => point.role)).toEqual(['start', 'end']);
|
|
expect(res.body.tour).toMatchObject({
|
|
place_id: placeId,
|
|
has_waypoints: false,
|
|
has_recorded_track: false,
|
|
distance: before.distance,
|
|
elevation_gain: before.elevation_gain,
|
|
elevation_loss: before.elevation_loss,
|
|
max_hiking_difficulty: null,
|
|
});
|
|
expect((await placeColumns(placeId, 'route_geometry')).route_geometry).toBe(recorded);
|
|
expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(0);
|
|
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
|
|
});
|
|
|
|
it('409 restoring a tour that keeps no recorded track; 403 without place_edit; 404 for another trip', async () => {
|
|
const planned = await createViaApi();
|
|
await request(server)
|
|
.put(`/api/trips/5/tours/${planned}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ name: 'Edited' }));
|
|
const none = await restore(planned);
|
|
expect(none.status).toBe(409);
|
|
expect(none.body).toMatchObject({ code: 'no_recorded_track' });
|
|
|
|
const replaced = await importedTour();
|
|
await request(server).put(`/api/trips/5/tours/${replaced}`).set('Cookie', sessionCookie(OWNER)).send(tourBody());
|
|
expect((await restore(replaced, VIEWER)).status).toBe(403);
|
|
expect((await restore(replaced, OWNER, 6)).status).toBe(404);
|
|
expect(await listed(replaced)).toMatchObject({ has_recorded_track: true });
|
|
});
|
|
|
|
it('copying the trip points a derived tour at the copied import, never back into the source', async () => {
|
|
const source = await importedTour();
|
|
await createViaApi(5, tourBody({ name: 'Planned from import', derived_from_place_id: source }));
|
|
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 copiedImport = tours.find((tour: { name: string }) => tour.name === 'Alpine loop');
|
|
const copiedDerived = tours.find((tour: { name: string }) => tour.name === 'Planned from import');
|
|
expect(copiedDerived.derived_from_place_id).toBe(copiedImport.place_id);
|
|
expect(copiedDerived.derived_from_place_id).not.toBe(source);
|
|
});
|
|
});
|
|
|
|
describe('other domains', () => {
|
|
it('409 moving a tour onto a day that already holds it, on create and on move', async () => {
|
|
const placeId = await createViaApi();
|
|
const cookie = sessionCookie(OWNER);
|
|
const onDay4 = await request(server)
|
|
.post('/api/trips/5/days/4/assignments')
|
|
.set('Cookie', cookie)
|
|
.send({ place_id: placeId });
|
|
expect(onDay4.status).toBe(201);
|
|
const onDay3 = await request(server)
|
|
.post('/api/trips/5/days/3/assignments')
|
|
.set('Cookie', cookie)
|
|
.send({ place_id: placeId });
|
|
expect(onDay3.status).toBe(201);
|
|
|
|
const twice = await request(server)
|
|
.post('/api/trips/5/days/4/assignments')
|
|
.set('Cookie', cookie)
|
|
.send({ place_id: placeId });
|
|
expect(twice.status).toBe(409);
|
|
expect(twice.body).toEqual({ error: 'Tour is already assigned to this day' });
|
|
|
|
const moving = onDay3.body.assignment.id as number;
|
|
const move = await request(server)
|
|
.put(`/api/trips/5/assignments/${moving}/move`)
|
|
.set('Cookie', cookie)
|
|
.send({ new_day_id: 4, order_index: 1 });
|
|
expect(move.status).toBe(409);
|
|
expect(move.body).toEqual({ error: 'Tour is already assigned to this day' });
|
|
expect((await findRow(orm, DayAssignments, { id: moving }))?.day_id).toBe(3);
|
|
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(2);
|
|
});
|
|
|
|
it('copying the trip carries its tours and waypoints onto the new places', async () => {
|
|
const placeId = await createViaApi(
|
|
5,
|
|
tourBody({
|
|
waypoints: [
|
|
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
|
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1 },
|
|
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
|
],
|
|
}),
|
|
);
|
|
const res = await request(server)
|
|
.post('/api/trips/5/copy')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send({ title: 'Alps again' });
|
|
expect(res.status).toBe(201);
|
|
const copyId = res.body.trip.id as number;
|
|
|
|
const list = await request(server).get(`/api/trips/${copyId}/tours`).set('Cookie', sessionCookie(OWNER));
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.tours).toHaveLength(1);
|
|
const copied = list.body.tours[0];
|
|
expect(copied.place_id).not.toBe(placeId);
|
|
const source = (await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)))
|
|
.body;
|
|
const { place_id: _sourceId, ...sourceRest } = source.tour;
|
|
const { place_id: _copyId, ...copyRest } = copied;
|
|
expect(copyRest).toEqual(sourceRest);
|
|
|
|
const detail = await request(server)
|
|
.get(`/api/trips/${copyId}/tours/${copied.place_id}`)
|
|
.set('Cookie', sessionCookie(OWNER));
|
|
expect(detail.body.waypoints).toEqual(source.waypoints);
|
|
expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(3);
|
|
});
|
|
|
|
it('deleting the place removes its tour and waypoints and reports the tour id', async () => {
|
|
const placeId = await createViaApi();
|
|
const res = await request(server).delete(`/api/trips/5/places/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, tourPlaceIds: [placeId] });
|
|
expect(await countRows(orm, Tours, { place: placeId })).toBe(0);
|
|
expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(0);
|
|
});
|
|
});
|
|
});
|