mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
/**
|
|
* Tours e2e for bike tours (BIK-01 to BIK-05): a bike tour saved, read, copied,
|
|
* imported and exported with its profile, 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 { OverpassElementsClient } from '../../src/nest/overpass/overpass-elements.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 { 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 { 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();
|
|
const mapsPois = vi.fn();
|
|
// The trail grades (BIK-05) read OpenStreetMap through Overpass: no network in a test.
|
|
const overpassQuery = 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 bikeBody = (overrides: Record<string, unknown> = {}) => ({
|
|
name: 'Lakeside ride',
|
|
tour_type: 'bike',
|
|
bike_profile: 'gravel',
|
|
route_geometry: GEOMETRY,
|
|
waypoints: [
|
|
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
|
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
|
],
|
|
duration_seconds: 1800,
|
|
...overrides,
|
|
});
|
|
const GPX = `<?xml version="1.0"?>
|
|
<gpx version="1.1" creator="e2e"><trk><name>Gravel loop</name><trkseg>
|
|
<trkpt lat="47" lon="11"><ele>600</ele></trkpt>
|
|
<trkpt lat="47.01" lon="11.01"><ele>640</ele></trkpt>
|
|
</trkseg></trk></gpx>`;
|
|
|
|
describe('Bike 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)
|
|
.overrideProvider(MapsService)
|
|
.useValue({ pois: mapsPois })
|
|
.overrideProvider(OverpassElementsClient)
|
|
.useValue({ query: overpassQuery })
|
|
.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> = bikeBody()) => {
|
|
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();
|
|
});
|
|
|
|
const send = (method: 'post' | 'put', path: string, body: Record<string, unknown>, userId = OWNER) =>
|
|
request(server)[method](`/api/trips/5/tours${path}`).set('Cookie', sessionCookie(userId)).send(body);
|
|
|
|
const gpxTypeOf = async (placeId: number) => {
|
|
const res = 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));
|
|
});
|
|
return /<type>([^<]+)<\/type>/.exec(String(res.body))?.[1] ?? '';
|
|
};
|
|
|
|
describe('a bike tour and its profile (BIK-01 to BIK-04)', () => {
|
|
it('saves a bike tour with its profile, lists it so and broadcasts the change', async () => {
|
|
const placeId = await createViaApi();
|
|
const row = await findRow(orm, Tours, { place: placeId });
|
|
expect(row).toMatchObject({ bike_profile: 'gravel', max_hiking_difficulty: null, boat_type: null });
|
|
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER));
|
|
expect(list.status).toBe(200);
|
|
const tour = list.body.tours.find((item: { place_id: number }) => item.place_id === placeId);
|
|
expect(tour).toMatchObject({ tour_type: 'bike', bike_profile: 'gravel', duration: 30 });
|
|
expect(tour.elevation_gain).toBe(100);
|
|
expect(realtime.broadcastMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a bike tour without a profile, with a hiking limit, or over a via ferrata', async () => {
|
|
const ferrata = { kind: 'ferrata', ref: 'way/1', name: null, geometry: [GEOMETRY[0], GEOMETRY[2]] };
|
|
const bodies = [
|
|
bikeBody({ bike_profile: null }),
|
|
bikeBody({ bike_profile: 'ebike' }),
|
|
bikeBody({ max_hiking_difficulty: 3 }),
|
|
bikeBody({
|
|
waypoints: [
|
|
{ lat: 47, lng: 11, role: 'start', sequence: 0, fixed_leg: ferrata },
|
|
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
|
],
|
|
}),
|
|
];
|
|
const statuses = await bodies.reduce<Promise<number[]>>(
|
|
async (done, body) => [...(await done), (await send('post', '', body)).status],
|
|
Promise.resolve([]),
|
|
);
|
|
expect(statuses).toEqual([400, 400, 400, 400]);
|
|
expect(await countRows(orm, Tours)).toBe(0);
|
|
});
|
|
|
|
it('changes the profile with an edit, and a hike made of it keeps none', async () => {
|
|
const placeId = await createViaApi();
|
|
const road = await send('put', `/${placeId}`, bikeBody({ bike_profile: 'road', expected_revision: 1 }));
|
|
expect(road.status).toBe(200);
|
|
expect(road.body.tour).toMatchObject({ bike_profile: 'road', revision: 2 });
|
|
const hike = await send(
|
|
'put',
|
|
`/${placeId}`,
|
|
bikeBody({ tour_type: 'hike', bike_profile: undefined, max_hiking_difficulty: 2, expected_revision: 2 }),
|
|
);
|
|
expect(hike.status).toBe(200);
|
|
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
|
|
tour_type: 'hike',
|
|
bike_profile: null,
|
|
max_hiking_difficulty: 2,
|
|
});
|
|
});
|
|
|
|
it('a member who may only read cannot save one', async () => {
|
|
expect((await send('post', '', bikeBody(), VIEWER)).status).toBe(403);
|
|
});
|
|
|
|
it('a copy keeps the profile', async () => {
|
|
const placeId = await createViaApi(5, bikeBody({ bike_profile: 'mtb' }));
|
|
const copy = await send('post', `/${placeId}/copy`, { name: 'Again' });
|
|
expect(copy.status).toBe(201);
|
|
expect(copy.body.tour).toMatchObject({ tour_type: 'bike', bike_profile: 'mtb' });
|
|
});
|
|
|
|
it('exports a bike tour as the activity its profile names', async () => {
|
|
const kinds: Record<string, string> = {
|
|
touring: 'cycling',
|
|
road: 'road_biking',
|
|
gravel: 'gravel_cycling',
|
|
mtb: 'mountain_biking',
|
|
};
|
|
const types = await Object.keys(kinds).reduce<Promise<string[]>>(
|
|
async (done, profile) => [
|
|
...(await done),
|
|
await gpxTypeOf(await createViaApi(5, bikeBody({ bike_profile: profile }))),
|
|
],
|
|
Promise.resolve([]),
|
|
);
|
|
expect(types).toEqual(Object.values(kinds));
|
|
});
|
|
|
|
it('imports a GPX file as a bike tour only with its profile', async () => {
|
|
const importGpx = (fields: Record<string, string>) => {
|
|
let req = request(server).post('/api/trips/5/tours/import/gpx').set('Cookie', sessionCookie(OWNER));
|
|
for (const [key, value] of Object.entries(fields)) req = req.field(key, value);
|
|
return req.attach('file', Buffer.from(GPX), { filename: 'loop.gpx', contentType: 'application/gpx+xml' });
|
|
};
|
|
expect((await importGpx({ tour_type: 'bike' })).status).toBe(400);
|
|
const res = await importGpx({ tour_type: 'bike', bike_profile: 'road' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.tours[0]).toMatchObject({ tour_type: 'bike', bike_profile: 'road', origin: 'gpx' });
|
|
});
|
|
});
|
|
|
|
describe('POST trail-grades (BIK-05)', () => {
|
|
const ask = (body: unknown, userId?: number, tripId = 5) => {
|
|
const req = request(server).post(`/api/trips/${tripId}/tours/trail-grades`);
|
|
return (userId ? req.set('Cookie', sessionCookie(userId)) : req).send(body as object);
|
|
};
|
|
|
|
it('answers the grades of the ways asked about to anyone who sees the trip', async () => {
|
|
overpassQuery.mockResolvedValueOnce([
|
|
{ type: 'way', id: 11, tags: { 'mtb:scale': '2', 'mtb:scale:uphill': '1' } },
|
|
]);
|
|
const res = await ask({ way_ids: [11, 12] }, VIEWER);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({
|
|
ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: '1', mtb_scale_imba: null }],
|
|
unanswered: [],
|
|
});
|
|
});
|
|
|
|
it('refuses an outsider, a body it does not know, and no session', async () => {
|
|
expect((await ask({ way_ids: [1] }, OUTSIDER)).status).toBe(404);
|
|
const statuses = await [{ way_ids: [] }, { way_ids: [0] }, { way_ids: [1], extra: 1 }].reduce<Promise<number[]>>(
|
|
async (done, body) => [...(await done), (await ask(body, OWNER)).status],
|
|
Promise.resolve([]),
|
|
);
|
|
expect(statuses).toEqual([400, 400, 400]);
|
|
expect((await ask({ way_ids: [1] })).status).toBe(401);
|
|
});
|
|
});
|
|
});
|