mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
adcaca3c5f
A tour can be a bike tour with a routing profile (touring, road, gravel, mtb), stored per tour by a new migration. The server grades the ways of a mountain bike route from OpenStreetMap (mtb:scale) through one shared Overpass client, offers CyclOSM behind its own admin switch, and exports a bike tour's GPX with the activity of its profile. A hike's route choices are now exactly the three the card names: steep climbs, steps and ferries.
752 lines
33 KiB
TypeScript
752 lines
33 KiB
TypeScript
/**
|
|
* Tours e2e for the planner's second round: the GPX export of one tour
|
|
* (PR1-06A), through the real JwtAuthGuard, AddonGuard and TripAccessGuard
|
|
* against a migrated and seeded temp SQLite db. The permission check is a
|
|
* container spy and the broadcast goes to a FakeRealtimeService, as in
|
|
* tours.e2e.test.ts.
|
|
*
|
|
* 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.
|
|
*/
|
|
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
|
import { Places } from '../../src/db/entities/Places.entity';
|
|
import { Tours } from '../../src/db/entities/Tours.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 { MapsService } from '../../src/nest/maps/maps.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 { makePlace } from '../helpers/factories/places';
|
|
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
|
|
import { setAddonEnabled } from '../helpers/factories/settings';
|
|
import { makeTour } from '../helpers/factories/tours';
|
|
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/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();
|
|
// The map search behind the POI search (PLN-02): no network in a test.
|
|
const mapsPois = vi.fn();
|
|
const realtime = new FakeRealtimeService();
|
|
|
|
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,
|
|
});
|
|
|
|
describe('Tours planned 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)
|
|
.overrideProvider(MapsService)
|
|
.useValue({ pois: mapsPois })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
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);
|
|
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, DayAssignments);
|
|
await deleteRows(orm, Places);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
});
|
|
|
|
describe('GET :placeId/export.gpx (PR1-06A)', () => {
|
|
const exportGpx = (userId: number, placeId: number, tripId = 5) =>
|
|
request(server)
|
|
.get(`/api/trips/${tripId}/tours/${placeId}/export.gpx`)
|
|
.set('Cookie', sessionCookie(userId))
|
|
.buffer(true)
|
|
.parse((res, done) => {
|
|
let text = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', (chunk: string) => (text += chunk));
|
|
res.on('end', () => done(null, text));
|
|
});
|
|
|
|
it('writes the whole saved line with its heights as one track, named after the tour', async () => {
|
|
const placeId = await createViaApi(5, tourBody({ name: 'Grat über Ü', description: 'Früh los.' }));
|
|
const res = await exportGpx(OWNER, placeId);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-type']).toBe('application/gpx+xml; charset=utf-8');
|
|
expect(res.headers['content-disposition']).toContain("filename*=UTF-8''Grat-%C3%BCber-%C3%9C.gpx");
|
|
const gpx = res.body as string;
|
|
expect(gpx).toContain('<name>Grat über Ü</name>');
|
|
expect(gpx).toContain('<desc>Früh los.</desc>');
|
|
expect(gpx).toContain('<type>hiking</type>');
|
|
expect(gpx.match(/<trkpt /g)).toHaveLength(GEOMETRY.length);
|
|
expect(gpx).toContain('<trkpt lat="47.01" lon="11.01">');
|
|
expect(gpx).toContain('<ele>700</ele>');
|
|
});
|
|
|
|
it('writes no height a point lacks, and names a paddle tour by its boat', async () => {
|
|
const place = await makePlace(orm, 5, {
|
|
name: 'Canal',
|
|
route_geometry: JSON.stringify([
|
|
[48, 12],
|
|
[48.01, 12.01, 300],
|
|
]),
|
|
});
|
|
await makeTour(orm, place.id, {
|
|
tourTypeRef: 'kayak',
|
|
boat_type: 'canoe',
|
|
water_context: 'river',
|
|
origin: 'gpx',
|
|
});
|
|
const res = await exportGpx(OWNER, place.id);
|
|
expect(res.status).toBe(200);
|
|
const gpx = res.body as string;
|
|
expect(gpx).toContain('<type>canoeing</type>');
|
|
expect(gpx.match(/<ele>/g)).toHaveLength(1);
|
|
});
|
|
|
|
it('a member who may only read can export; the export changes nothing', async () => {
|
|
const placeId = await createViaApi();
|
|
const before = await findRow(orm, Tours, { place: placeId });
|
|
realtime.broadcastMock.mockClear();
|
|
const res = await exportGpx(VIEWER, placeId);
|
|
expect(res.status).toBe(200);
|
|
expect(await findRow(orm, Tours, { place: placeId })).toEqual(before);
|
|
expect(realtime.broadcastMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('422 for a tour without a line of two points, 404 for one of another trip or none', async () => {
|
|
const lonely = await makePlace(orm, 5, { name: 'Dot', route_geometry: JSON.stringify([[47, 11, 1]]) });
|
|
await makeTour(orm, lonely.id, { origin: 'gpx' });
|
|
const refused = await request(server)
|
|
.get(`/api/trips/5/tours/${lonely.id}/export.gpx`)
|
|
.set('Cookie', sessionCookie(OWNER));
|
|
expect(refused.status).toBe(422);
|
|
expect(refused.body).toEqual({ error: 'This tour has no route to export' });
|
|
|
|
const elsewhere = await createViaApi(6);
|
|
const wrongTrip = await request(server)
|
|
.get(`/api/trips/5/tours/${elsewhere}/export.gpx`)
|
|
.set('Cookie', sessionCookie(OWNER));
|
|
expect(wrongTrip.status).toBe(404);
|
|
expect(wrongTrip.body).toEqual({ error: 'Tour not found' });
|
|
const plain = await makePlace(orm, 5, { name: 'Cafe' });
|
|
expect(
|
|
(await request(server).get(`/api/trips/5/tours/${plain.id}/export.gpx`).set('Cookie', sessionCookie(OWNER)))
|
|
.status,
|
|
).toBe(404);
|
|
});
|
|
|
|
it('404 Trip not found for an outsider, 404 while the addon is off, 401 without a cookie', async () => {
|
|
const placeId = await createViaApi();
|
|
const outsider = await request(server)
|
|
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
|
|
.set('Cookie', sessionCookie(OUTSIDER));
|
|
expect(outsider.status).toBe(404);
|
|
expect(outsider.body).toEqual({ error: 'Trip not found' });
|
|
expect((await request(server).get(`/api/trips/5/tours/${placeId}/export.gpx`)).status).toBe(401);
|
|
await setAddonEnabled(orm, 'tours', false);
|
|
const off = await request(server)
|
|
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
|
|
.set('Cookie', sessionCookie(OWNER));
|
|
expect(off.status).toBe(404);
|
|
expect(off.body).toEqual({ error: 'Tours addon is not enabled' });
|
|
});
|
|
});
|
|
|
|
describe('revisions and conflicts (P2C-03)', () => {
|
|
const put = (placeId: number, body: Record<string, unknown>) =>
|
|
request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)).send(body);
|
|
|
|
it('a tour starts at 1, and every saved change raises it by one, in the list and the detail', async () => {
|
|
const placeId = await createViaApi();
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(detail.body.tour.revision).toBe(1);
|
|
|
|
const saved = await put(placeId, tourBody({ name: 'Renamed', expected_revision: 1 }));
|
|
expect(saved.status).toBe(200);
|
|
expect(saved.body.tour).toMatchObject({ name: 'Renamed', revision: 2 });
|
|
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
|
expect(list.body.tours[0].revision).toBe(2);
|
|
});
|
|
|
|
it('409 for a draft based on a revision the tour has left, and nothing of it is written', async () => {
|
|
const placeId = await createViaApi();
|
|
expect((await put(placeId, tourBody({ name: 'First', expected_revision: 1 }))).status).toBe(200);
|
|
realtime.broadcastMock.mockClear();
|
|
|
|
const stale = await put(
|
|
placeId,
|
|
tourBody({
|
|
name: 'Stale',
|
|
expected_revision: 1,
|
|
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 },
|
|
],
|
|
}),
|
|
);
|
|
expect(stale.status).toBe(409);
|
|
expect(stale.body).toEqual({
|
|
error: 'This tour was changed in the meantime',
|
|
code: 'tour_revision_conflict',
|
|
revision: 2,
|
|
});
|
|
const after = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(after.body.tour).toMatchObject({ name: 'First', revision: 2 });
|
|
expect(after.body.waypoints).toHaveLength(2);
|
|
expect(realtime.broadcastMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a write naming no revision writes as before, so an older client or a queued write still lands', async () => {
|
|
const placeId = await createViaApi();
|
|
expect((await put(placeId, tourBody({ name: 'One', expected_revision: 1 }))).status).toBe(200);
|
|
const blind = await put(placeId, tourBody({ name: 'Two' }));
|
|
expect(blind.status).toBe(200);
|
|
expect(blind.body.tour).toMatchObject({ name: 'Two', revision: 3 });
|
|
});
|
|
|
|
it('a copy starts at 1 of its own, and the original keeps its count', async () => {
|
|
const placeId = await createViaApi();
|
|
await put(placeId, tourBody({ expected_revision: 1 }));
|
|
const copy = await request(server)
|
|
.post(`/api/trips/5/tours/${placeId}/copy`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send({ name: 'Copy' });
|
|
expect(copy.status).toBe(201);
|
|
expect(copy.body.tour.revision).toBe(1);
|
|
expect((await findRow(orm, Tours, { place: placeId }))?.revision).toBe(2);
|
|
});
|
|
|
|
it('the plan laid onto a recorded paddle track names its revision too', async () => {
|
|
const place = await makePlace(orm, 5, {
|
|
name: 'Canal',
|
|
route_geometry: JSON.stringify([
|
|
[48, 12],
|
|
[48.01, 12.01],
|
|
]),
|
|
});
|
|
await makeTour(orm, place.id, {
|
|
tourTypeRef: 'kayak',
|
|
boat_type: 'kayak',
|
|
water_context: 'river',
|
|
origin: 'gpx',
|
|
});
|
|
const plan = { paddle_speed_kmh: 5, portage_speed_kmh: null, track_marks: { portages: [], locks: [] } };
|
|
const putPlan = (body: Record<string, unknown>) =>
|
|
request(server)
|
|
.put(`/api/trips/5/tours/${place.id}/paddle-plan`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(body);
|
|
|
|
const first = await putPlan({ ...plan, expected_revision: 1 });
|
|
expect(first.status).toBe(200);
|
|
expect(first.body.tour.revision).toBe(2);
|
|
const stale = await putPlan({ ...plan, paddle_speed_kmh: 3, expected_revision: 1 });
|
|
expect(stale.status).toBe(409);
|
|
expect(stale.body).toMatchObject({ code: 'tour_revision_conflict', revision: 2 });
|
|
expect((await findRow(orm, Tours, { place: place.id }))?.paddle_speed_kmh).toBe(5);
|
|
});
|
|
|
|
it('rejects a revision below 1 at the boundary', async () => {
|
|
const placeId = await createViaApi();
|
|
expect((await put(placeId, tourBody({ expected_revision: 0 }))).status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('trip places as waypoints (PR1-10)', () => {
|
|
const linked = (placeId: number | null) => [
|
|
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
|
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, linked_place_id: placeId },
|
|
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
|
];
|
|
|
|
it('a waypoint stands for a place of the trip, read back with it, and the place stays as it was', async () => {
|
|
const hut = await makePlace(orm, 5, { name: 'Höllentalangerhütte', lat: 47.4, lng: 11.0 });
|
|
const placeId = await createViaApi(5, tourBody({ waypoints: linked(hut.id) }));
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(detail.body.waypoints.map((point: { linked_place_id?: number }) => point.linked_place_id ?? null)).toEqual(
|
|
[null, hut.id, null],
|
|
);
|
|
// The waypoint keeps its own coordinates; the place keeps its own.
|
|
expect(detail.body.waypoints[1]).toMatchObject({ lat: 47.01, lng: 11.01 });
|
|
expect(await findRow(orm, Places, { id: hut.id })).toMatchObject({ name: 'Höllentalangerhütte', lat: 47.4 });
|
|
expect(await countRows(orm, DayAssignments, { place: hut.id })).toBe(0);
|
|
});
|
|
|
|
it('a place of another trip, or one that is gone, leaves the point unlinked; a tour cannot be stood for', async () => {
|
|
const foreign = await makePlace(orm, 6, { name: 'Elsewhere' });
|
|
const placeId = await createViaApi(5, tourBody({ waypoints: linked(foreign.id) }));
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(detail.body.waypoints[1].linked_place_id).toBeUndefined();
|
|
|
|
const missing = await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ waypoints: linked(999_999) }));
|
|
expect(missing.status).toBe(200);
|
|
expect(missing.body.waypoints[1].linked_place_id).toBeUndefined();
|
|
|
|
const otherTour = await createViaApi();
|
|
const refused = await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ waypoints: linked(otherTour) }));
|
|
expect(refused.status).toBe(400);
|
|
expect(refused.body).toEqual({ error: 'A waypoint cannot stand for a tour', code: 'waypoint_links_a_tour' });
|
|
});
|
|
|
|
it('deleting the place keeps the waypoint and only drops the link; the GPX names the stops that stand for a place', async () => {
|
|
const hut = await makePlace(orm, 5, { name: 'Kreuzeckhaus' });
|
|
const lake = await makePlace(orm, 5, { name: 'Eibsee' });
|
|
const placeId = await createViaApi(
|
|
5,
|
|
tourBody({
|
|
waypoints: [
|
|
{ lat: 47, lng: 11, role: 'start', sequence: 0, linked_place_id: lake.id },
|
|
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, linked_place_id: hut.id },
|
|
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
|
],
|
|
}),
|
|
);
|
|
const gpx = await request(server)
|
|
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.buffer(true)
|
|
.parse((res, done) => {
|
|
let text = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', (chunk: string) => (text += chunk));
|
|
res.on('end', () => done(null, text));
|
|
});
|
|
expect((gpx.body as string).match(/<wpt /g)).toHaveLength(2);
|
|
expect(gpx.body).toContain('<name>Kreuzeckhaus</name>');
|
|
|
|
await deleteRows(orm, Places, { id: hut.id });
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(detail.body.waypoints).toHaveLength(3);
|
|
expect(detail.body.waypoints[1].linked_place_id).toBeUndefined();
|
|
expect(detail.body.waypoints[0].linked_place_id).toBe(lake.id);
|
|
});
|
|
});
|
|
|
|
describe('hiking preferences (MF-08)', () => {
|
|
it('a hike keeps the preferences switched on, reads them back, and a copy carries them', async () => {
|
|
const placeId = await createViaApi(
|
|
5,
|
|
tourBody({ hiking_preferences: { avoid_climbs: true, avoid_steps: true, avoid_ferries: false } }),
|
|
);
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(detail.body.tour.hiking_preferences).toEqual({ avoid_climbs: true, avoid_steps: true });
|
|
const copy = await request(server)
|
|
.post(`/api/trips/5/tours/${placeId}/copy`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send({ name: 'Copy' });
|
|
expect(copy.body.tour.hiking_preferences).toEqual({ avoid_climbs: true, avoid_steps: true });
|
|
|
|
// An edit that switches them all off keeps none; one without the field keeps none too.
|
|
const off = await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ hiking_preferences: { avoid_steps: false } }));
|
|
expect(off.body.tour.hiking_preferences).toBeNull();
|
|
expect((await findRow(orm, Tours, { place: placeId }))?.hiking_preferences).toBeNull();
|
|
});
|
|
|
|
it('a tour saved before keeps none, an unknown preference is refused, and a paddle tour has none', async () => {
|
|
const plain = await createViaApi();
|
|
const detail = await request(server).get(`/api/trips/5/tours/${plain}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(detail.body.tour.hiking_preferences).toBeNull();
|
|
const unknown = await request(server)
|
|
.post('/api/trips/5/tours')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ hiking_preferences: { avoid_hills: true } }));
|
|
expect(unknown.status).toBe(400);
|
|
const paddle = await request(server)
|
|
.post('/api/trips/5/tours')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(
|
|
tourBody({
|
|
tour_type: 'kayak',
|
|
boat_type: 'kayak',
|
|
water_context: 'lake',
|
|
max_hiking_difficulty: null,
|
|
hiking_preferences: { avoid_steps: true },
|
|
}),
|
|
);
|
|
expect(paddle.status).toBe(400);
|
|
});
|
|
});
|
|
describe('taking a tour over from another trip (LIB-02)', () => {
|
|
beforeAll(async () => {
|
|
// A trip of someone else's, which the owner never sees.
|
|
await insertRows(orm, Trips, [{ id: 7, user: 3, title: 'Elsewhere' }]);
|
|
});
|
|
|
|
const outsiderTour = async () => {
|
|
const res = await request(server)
|
|
.post('/api/trips/7/tours')
|
|
.set('Cookie', sessionCookie(OUTSIDER))
|
|
.send(tourBody({ name: 'Secret ridge' }));
|
|
expect(res.status).toBe(201);
|
|
return res.body.tour.place_id as number;
|
|
};
|
|
|
|
it('lists the tours of the trips the user can open, with their trip, never one of another user', async () => {
|
|
const ridge = await createViaApi(5, tourBody({ name: 'Ridge 100% walk' }));
|
|
await createViaApi(
|
|
5,
|
|
tourBody({
|
|
name: 'Lake loop',
|
|
tour_type: 'kayak',
|
|
boat_type: 'kayak',
|
|
water_context: 'lake',
|
|
max_hiking_difficulty: null,
|
|
}),
|
|
);
|
|
await createViaApi(6, tourBody({ name: 'Valley walk' }));
|
|
await outsiderTour();
|
|
|
|
const all = await request(server).get('/api/tours/library').set('Cookie', sessionCookie(OWNER));
|
|
expect(all.status).toBe(200);
|
|
expect(all.body.tours.map((tour: { name: string }) => tour.name).sort()).toEqual([
|
|
'Lake loop',
|
|
'Ridge 100% walk',
|
|
'Valley walk',
|
|
]);
|
|
expect(all.body.tours.find((tour: { place_id: number }) => tour.place_id === ridge)).toMatchObject({
|
|
trip: { id: 5, title: 'Alps' },
|
|
tour_type: 'hike',
|
|
has_waypoints: true,
|
|
origin: 'trek',
|
|
});
|
|
expect(all.body.trips).toEqual([
|
|
{ id: 6, title: 'Other', tour_count: 1 },
|
|
{ id: 5, title: 'Alps', tour_count: 2 },
|
|
]);
|
|
expect(all.body.truncated).toBe(false);
|
|
expect(JSON.stringify(all.body)).not.toContain('Secret ridge');
|
|
|
|
// Leaving the asking trip out, by name (case and a literal "%" alike), by type and by trip.
|
|
const from6 = await request(server)
|
|
.get('/api/tours/library?exclude_trip_id=6')
|
|
.set('Cookie', sessionCookie(OWNER));
|
|
expect(from6.body.tours.every((tour: { trip: { id: number } }) => tour.trip.id === 5)).toBe(true);
|
|
expect(from6.body.trips).toEqual([{ id: 5, title: 'Alps', tour_count: 2 }]);
|
|
const named = await request(server).get('/api/tours/library?q=100%25').set('Cookie', sessionCookie(OWNER));
|
|
expect(named.body.tours.map((tour: { name: string }) => tour.name)).toEqual(['Ridge 100% walk']);
|
|
const anyCase = await request(server).get('/api/tours/library?q=VALLEY').set('Cookie', sessionCookie(OWNER));
|
|
expect(anyCase.body.tours.map((tour: { name: string }) => tour.name)).toEqual(['Valley walk']);
|
|
const paddle = await request(server).get('/api/tours/library?type=kayak').set('Cookie', sessionCookie(OWNER));
|
|
expect(paddle.body.tours.map((tour: { name: string }) => tour.name)).toEqual(['Lake loop']);
|
|
const trip6 = await request(server).get('/api/tours/library?trip_id=6').set('Cookie', sessionCookie(OWNER));
|
|
expect(trip6.body.tours.map((tour: { name: string }) => tour.name)).toEqual(['Valley walk']);
|
|
const bad = await request(server).get('/api/tours/library?type=car').set('Cookie', sessionCookie(OWNER));
|
|
expect(bad.status).toBe(400);
|
|
|
|
// A member sees the trips they belong to.
|
|
const member = await request(server).get('/api/tours/library').set('Cookie', sessionCookie(VIEWER));
|
|
expect(member.body.trips.map((trip: { id: number }) => trip.id)).toEqual([5]);
|
|
});
|
|
|
|
it("previews one tour with its line and control points; another user's tour or none is not found", async () => {
|
|
const ridge = await createViaApi(5);
|
|
const preview = await request(server).get(`/api/tours/library/${ridge}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(preview.status).toBe(200);
|
|
expect(preview.body.line).toEqual(GEOMETRY.map(([lat, lng]) => [lat, lng]));
|
|
expect(preview.body.waypoint_count).toBe(2);
|
|
expect(preview.body.tour).toMatchObject({ name: 'Ridge walk', trip: { id: 5, title: 'Alps' } });
|
|
|
|
const secret = await outsiderTour();
|
|
const hidden = await request(server).get(`/api/tours/library/${secret}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(hidden.status).toBe(404);
|
|
expect(hidden.body).toEqual({ error: 'Tour not found' });
|
|
expect((await request(server).get('/api/tours/library/999999').set('Cookie', sessionCookie(OWNER))).status).toBe(
|
|
404,
|
|
);
|
|
expect((await request(server).get('/api/tours/library/abc').set('Cookie', sessionCookie(OWNER))).status).toBe(
|
|
404,
|
|
);
|
|
});
|
|
|
|
it('a long line is thinned for the preview, its ends kept', async () => {
|
|
const long = Array.from({ length: 1200 }, (_, index) => [47 + index / 10000, 11 + index / 10000, 600]);
|
|
const placeId = await createViaApi(5, tourBody({ route_geometry: long }));
|
|
const preview = await request(server).get(`/api/tours/library/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(preview.body.line).toHaveLength(400);
|
|
expect(preview.body.line[0]).toEqual([47, 11]);
|
|
const last = long[long.length - 1] ?? [];
|
|
expect(preview.body.line[399]).toEqual([last[0], last[1]]);
|
|
});
|
|
|
|
it('takes a tour over as an independent tour of this trip, remembering where it came from', async () => {
|
|
const hut = await makePlace(orm, 5, { name: 'Hut', lat: 47.01, lng: 11.01 });
|
|
const source = await createViaApi(
|
|
5,
|
|
tourBody({
|
|
waypoints: [
|
|
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
|
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, linked_place_id: hut.id },
|
|
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
|
],
|
|
}),
|
|
);
|
|
realtime.broadcastMock.mockClear();
|
|
const res = await request(server)
|
|
.post('/api/trips/6/tours/take-over')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send({ source_trip_id: 5, source_place_id: source });
|
|
expect(res.status).toBe(201);
|
|
const copy = res.body.tour.place_id as number;
|
|
expect(copy).not.toBe(source);
|
|
expect(res.body.tour).toMatchObject({
|
|
name: 'Ridge walk',
|
|
revision: 1,
|
|
planned: false,
|
|
max_hiking_difficulty: 3,
|
|
});
|
|
expect(res.body.provenance).toMatchObject({
|
|
derivedFrom: null,
|
|
takenOverFrom: { tripTitle: 'Alps', tourName: 'Ridge walk' },
|
|
});
|
|
// The stop stood for a place of the other trip: in this one it stands for nothing.
|
|
expect(
|
|
res.body.waypoints.map((point: { linked_place_id?: number | null }) => point.linked_place_id ?? null),
|
|
).toEqual([null, null, null]);
|
|
expect(await findRow(orm, Places, { id: copy })).toMatchObject({ trip_id: 6 });
|
|
expect(await countRows(orm, DayAssignments, { place: copy })).toBe(0);
|
|
expect(realtime.broadcastMock.mock.calls.every(([tripId]) => String(tripId) === '6')).toBe(true);
|
|
|
|
// The source is untouched, and a later change to one leaves the other as it is.
|
|
const original = await request(server).get(`/api/trips/5/tours/${source}`).set('Cookie', sessionCookie(OWNER));
|
|
expect(original.body.provenance.takenOverFrom).toBeNull();
|
|
expect(original.body.waypoints[1].linked_place_id).toBe(hut.id);
|
|
await request(server)
|
|
.put(`/api/trips/6/tours/${copy}`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send(tourBody({ name: 'Renamed copy' }));
|
|
expect((await findRow(orm, Places, { id: source }))?.name).toBe('Ridge walk');
|
|
|
|
// A copy of the copy still names where the line came from.
|
|
const again = await request(server)
|
|
.post(`/api/trips/6/tours/${copy}/copy`)
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send({ name: 'Again' });
|
|
expect(again.body.provenance.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge walk' });
|
|
});
|
|
|
|
it('a name can be given; a source out of reach or gone is not found and leaves nothing behind', async () => {
|
|
const source = await createViaApi(5);
|
|
const named = await request(server)
|
|
.post('/api/trips/6/tours/take-over')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send({ source_trip_id: 5, source_place_id: source, name: 'My ridge' });
|
|
expect(named.body.tour.name).toBe('My ridge');
|
|
|
|
const before = await countRows(orm, Places, { trip: 7 });
|
|
const stolen = await request(server)
|
|
.post('/api/trips/7/tours/take-over')
|
|
.set('Cookie', sessionCookie(OUTSIDER))
|
|
.send({ source_trip_id: 5, source_place_id: source });
|
|
expect(stolen.status).toBe(404);
|
|
expect(stolen.body).toEqual({ error: 'Tour not found' });
|
|
// The place id named with the wrong trip is not found either.
|
|
const mismatched = await request(server)
|
|
.post('/api/trips/6/tours/take-over')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send({ source_trip_id: 6, source_place_id: source });
|
|
expect(mismatched.status).toBe(404);
|
|
expect(await countRows(orm, Places, { trip: 7 })).toBe(before);
|
|
});
|
|
|
|
it('needs place_edit on the trip it writes into, the addon on, a session and a well-formed body', async () => {
|
|
const source = await createViaApi(6);
|
|
const viewer = await request(server)
|
|
.post('/api/trips/5/tours/take-over')
|
|
.set('Cookie', sessionCookie(VIEWER))
|
|
.send({ source_trip_id: 6, source_place_id: source });
|
|
expect(viewer.status).toBe(403);
|
|
const extra = await request(server)
|
|
.post('/api/trips/5/tours/take-over')
|
|
.set('Cookie', sessionCookie(OWNER))
|
|
.send({ source_trip_id: 6, source_place_id: source, day_id: 1 });
|
|
expect(extra.status).toBe(400);
|
|
expect((await request(server).get('/api/tours/library')).status).toBe(401);
|
|
await setAddonEnabled(orm, 'tours', false);
|
|
const off = await request(server).get('/api/tours/library').set('Cookie', sessionCookie(OWNER));
|
|
expect(off.status).toBe(404);
|
|
expect(off.body).toEqual({ error: 'Tours addon is not enabled' });
|
|
});
|
|
});
|
|
describe('places along a tour (PLN-02)', () => {
|
|
const body = {
|
|
tour_type: 'hike',
|
|
categories: ['drinking_water'],
|
|
corridor_m: 500,
|
|
line: [
|
|
[47, 11],
|
|
[47.02, 11],
|
|
],
|
|
};
|
|
const spring = {
|
|
osm_id: 'node:7',
|
|
name: 'Spring',
|
|
lat: 47.01,
|
|
lng: 11.001,
|
|
category: 'drinking_water',
|
|
poi_type: 'amenity=drinking_water',
|
|
address: null,
|
|
website: null,
|
|
phone: null,
|
|
opening_hours: null,
|
|
cuisine: null,
|
|
brand: null,
|
|
brand_wikidata: null,
|
|
charging: null,
|
|
source: 'openstreetmap',
|
|
};
|
|
|
|
it('answers the places along a draft line to anyone who sees the trip, and writes nothing', async () => {
|
|
mapsPois.mockResolvedValue({ pois: [spring], source: 'openstreetmap', truncated: false, clamped: false });
|
|
const places = await countRows(orm, Places);
|
|
const res = await request(server)
|
|
.post('/api/trips/5/tours/poi-search')
|
|
.set('Cookie', sessionCookie(VIEWER))
|
|
.send(body);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.pois).toEqual([
|
|
expect.objectContaining({ id: 'node:7', name: 'Spring', category: 'drinking_water' }),
|
|
]);
|
|
expect(res.body).toMatchObject({ truncated: false, partial: false, capped: false });
|
|
expect(await countRows(orm, Places)).toBe(places);
|
|
expect(realtime.broadcastMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('404 for someone outside the trip, 400 for a body it does not know, 401 without a session', async () => {
|
|
const outsider = await request(server)
|
|
.post('/api/trips/5/tours/poi-search')
|
|
.set('Cookie', sessionCookie(OUTSIDER))
|
|
.send(body);
|
|
expect(outsider.status).toBe(404);
|
|
const bad = [
|
|
{ ...body, categories: ['fuel'] },
|
|
{ ...body, corridor_m: 9000 },
|
|
{ ...body, line: [[47, 11]] },
|
|
{ ...body, add: 1 },
|
|
];
|
|
// One after another: the server under test takes one connection at a time.
|
|
const refused = await bad.reduce<Promise<number[]>>(
|
|
async (done, sent) => [
|
|
...(await done),
|
|
(await request(server).post('/api/trips/5/tours/poi-search').set('Cookie', sessionCookie(OWNER)).send(sent))
|
|
.status,
|
|
],
|
|
Promise.resolve([]),
|
|
);
|
|
expect(refused).toEqual([400, 400, 400, 400]);
|
|
expect((await request(server).post('/api/trips/5/tours/poi-search').send(body)).status).toBe(401);
|
|
});
|
|
});
|
|
});
|