mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
654 lines
28 KiB
TypeScript
654 lines
28 KiB
TypeScript
/**
|
|
* Tours e2e for city walks (CW-01 to CW-03, CW-10, CW-11, CW-13): a walk saved and read
|
|
* with its stations, one saved with its stations only waiting for its way, the
|
|
* keys of its stations kept through an edit, and the stations checked off on
|
|
* the days the walk is on, and what is left of it read over MCP, 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' and 'day_edit', user 3 has no access to
|
|
* either trip. Trip 5 has days 3 and 4, trip 6 has day 7.
|
|
*/
|
|
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 { TourStationVisits } from '../../src/db/entities/TourStationVisits.entity';
|
|
import { Tours } from '../../src/db/entities/Tours.entity';
|
|
import { Trips } from '../../src/db/entities/Trips.entity';
|
|
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
|
|
import { createTestRegistry, type McpContext } from '../../src/nest-mcp';
|
|
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { withRequestContext } from '../../src/nest/database/request-context';
|
|
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 { ToursMcp } from '../../src/nest/tours/tours.mcp';
|
|
import { ToursModule } from '../../src/nest/tours/tours.module';
|
|
import { createSnapshotTestDb } from '../helpers/db-mock';
|
|
import { makePlace } from '../helpers/factories/places';
|
|
import { countRows, deleteRows, insertRows } from '../helpers/factories/rows';
|
|
import { setAddonEnabled } from '../helpers/factories/settings';
|
|
import { addTourWaypoints, 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 { MikroORM } from '@mikro-orm/core';
|
|
import { Test } from '@nestjs/testing';
|
|
import { stationKeySchema } from '@trek/shared';
|
|
|
|
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 realtime = new FakeRealtimeService();
|
|
|
|
let orm: TestOrm;
|
|
let checkPermission: MockInstance;
|
|
|
|
const OWNER = 1;
|
|
const VIEWER = 2;
|
|
const OUTSIDER = 3;
|
|
|
|
const GEOMETRY = [
|
|
[48.1, 11.5, 520],
|
|
[48.11, 11.51, 525],
|
|
[48.12, 11.52, 530],
|
|
[48.13, 11.53, 528],
|
|
];
|
|
/** Three stops and a shaping point: a must-see cathedral, a market passed through, a park of unknown stay. */
|
|
const STATIONS = [
|
|
{
|
|
lat: 48.1,
|
|
lng: 11.5,
|
|
role: 'start',
|
|
sequence: 0,
|
|
name: 'Cathedral',
|
|
stay_minutes: 30,
|
|
importance: 'must',
|
|
protected: true,
|
|
opening_hours: 'Mo-Su 09:00-18:00',
|
|
},
|
|
{ lat: 48.105, lng: 11.505, role: 'via', sequence: 1, shaping: true },
|
|
{ lat: 48.11, lng: 11.51, role: 'via', sequence: 2, name: 'Market', stay_minutes: 0 },
|
|
{ lat: 48.13, lng: 11.53, role: 'end', sequence: 3, name: 'Park', importance: 'optional' },
|
|
];
|
|
type Point = Record<string, unknown>;
|
|
const walkBody = (overrides: Record<string, unknown> = {}) => ({
|
|
name: 'Old town',
|
|
tour_type: 'walk',
|
|
route_geometry: GEOMETRY,
|
|
waypoints: STATIONS,
|
|
duration_seconds: 3600,
|
|
...overrides,
|
|
});
|
|
/** The points as a route in this order: numbered from 0, the first the start and the last the end. */
|
|
const inOrder = (points: Point[]): Point[] =>
|
|
points.map((point, sequence) => ({
|
|
...point,
|
|
sequence,
|
|
role: sequence === 0 ? 'start' : sequence === points.length - 1 ? 'end' : 'via',
|
|
}));
|
|
|
|
describe('City walks 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,
|
|
],
|
|
})
|
|
.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;
|
|
}
|
|
|
|
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: 'City days' },
|
|
{ id: 6, user: 1, title: 'Other' },
|
|
]);
|
|
await addTripMember(orm, 5, 2);
|
|
await insertRows(orm, Days, [
|
|
{ id: 3, trip: 5, day_number: 1, date: '2026-07-10' },
|
|
{ id: 4, trip: 5, day_number: 2 },
|
|
{ id: 7, trip: 6, day_number: 1 },
|
|
]);
|
|
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' || action === 'day_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 cookie = (userId = OWNER) => sessionCookie(userId);
|
|
|
|
const saveWalk = async (tripId = 5, body: Record<string, unknown> = walkBody()) => {
|
|
const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', cookie()).send(body);
|
|
expect(res.status).toBe(201);
|
|
return { placeId: res.body.tour.place_id as number, waypoints: res.body.waypoints as Point[] };
|
|
};
|
|
|
|
const assign = async (placeId: number, dayId = 3, tripId = 5) => {
|
|
const res = await request(server)
|
|
.post(`/api/trips/${tripId}/days/${dayId}/assignments`)
|
|
.set('Cookie', cookie())
|
|
.send({ place_id: placeId });
|
|
expect(res.status).toBe(201);
|
|
return res.body.assignment.id as number;
|
|
};
|
|
|
|
/** A walk of trip 5 on day 3, with the keys its stations were given, the shaping point's left out. */
|
|
const walkOnDay3 = async () => {
|
|
const { placeId, waypoints } = await saveWalk();
|
|
const [cathedral, shaping, market, park] = waypoints.map((point) => String(point.key));
|
|
return { placeId, waypoints, assignmentId: await assign(placeId), cathedral, shaping, market, park };
|
|
};
|
|
|
|
const mark = (assignmentId: number | string, key: string, body: unknown, userId = OWNER, tripId = 5) =>
|
|
request(server)
|
|
.put(`/api/trips/${tripId}/tour-visits/${assignmentId}/${key}`)
|
|
.set('Cookie', cookie(userId))
|
|
.set('X-Socket-Id', 'sock-1')
|
|
.send(body as object);
|
|
|
|
const visitsOf = async (tripId = 5, userId = OWNER) => {
|
|
const res = await request(server).get(`/api/trips/${tripId}/tour-visits`).set('Cookie', cookie(userId));
|
|
expect(res.status).toBe(200);
|
|
return res.body.visits as Array<{ assignment_id: number; station_key: string; status: string }>;
|
|
};
|
|
|
|
describe('a walk saved with its stations only (CW-11)', () => {
|
|
/** A walk an assistant created from confirmed stations: no line, no distance, no walking time. */
|
|
const pendingWalk = async () => {
|
|
const place = await makePlace(orm, 5, { name: 'Briefed walk', lat: 48.1, lng: 11.5, route_geometry: null });
|
|
await makeTour(orm, place.id, { tourTypeRef: 'walk' });
|
|
await addTourWaypoints(orm, place.id, [
|
|
{
|
|
lat: 48.1,
|
|
lng: 11.5,
|
|
station_key: 'stop-cathedral',
|
|
name: 'Cathedral',
|
|
stay_minutes: 30,
|
|
importance: 'must',
|
|
protected: 1,
|
|
},
|
|
{ lat: 48.13, lng: 11.53, station_key: 'stop-park', name: 'Park' },
|
|
]);
|
|
return place.id;
|
|
};
|
|
const listed = async (placeId: number) =>
|
|
(await request(server).get('/api/trips/5/tours').set('Cookie', cookie(VIEWER))).body.tours.find(
|
|
(tour: { place_id: number }) => tour.place_id === placeId,
|
|
);
|
|
|
|
it('the list and the detail say it waits for its way, until a save with a line plans it', async () => {
|
|
const { placeId: planned } = await saveWalk();
|
|
const placeId = await pendingWalk();
|
|
expect(await listed(planned)).toMatchObject({ route_pending: false });
|
|
expect(await listed(placeId)).toMatchObject({
|
|
route_pending: true,
|
|
distance: null,
|
|
duration: null,
|
|
station_count: 2,
|
|
});
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie(VIEWER));
|
|
expect(detail.status).toBe(200);
|
|
expect(detail.body.tour.route_pending).toBe(true);
|
|
const [cathedral, park] = detail.body.waypoints as Point[];
|
|
expect(cathedral).toMatchObject({ name: 'Cathedral', protected: true, stay_minutes: 30 });
|
|
|
|
const saved = await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', cookie())
|
|
.send(walkBody({ waypoints: inOrder([cathedral, park]), expected_revision: 1 }));
|
|
expect(saved.status).toBe(200);
|
|
expect(saved.body.tour).toMatchObject({ route_pending: false, duration: 60 });
|
|
expect(await listed(placeId)).toMatchObject({ route_pending: false });
|
|
});
|
|
|
|
it('is copied as it is, and a create over REST still needs its line', async () => {
|
|
const placeId = await pendingWalk();
|
|
const copy = await request(server)
|
|
.post(`/api/trips/5/tours/${placeId}/copy`)
|
|
.set('Cookie', cookie())
|
|
.send({ name: 'Briefed walk again' });
|
|
expect(copy.status).toBe(201);
|
|
expect(copy.body.tour).toMatchObject({ name: 'Briefed walk again', route_pending: true, station_count: 2 });
|
|
const { route_geometry: _line, ...withoutLine } = walkBody();
|
|
const refused = await request(server).post('/api/trips/5/tours').set('Cookie', cookie()).send(withoutLine);
|
|
expect(refused.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('a walk and its stations (CW-01, CW-02, CW-10)', () => {
|
|
it('saves a walk with a key on every point and its station fields, and lists how many stations it has', async () => {
|
|
const { placeId, waypoints } = await saveWalk();
|
|
for (const point of waypoints) expect(stationKeySchema.safeParse(point.key).success).toBe(true);
|
|
expect(waypoints.map(({ key: _key, ...point }) => point)).toEqual(STATIONS);
|
|
|
|
const list = await request(server).get('/api/trips/5/tours').set('Cookie', cookie(VIEWER));
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.tours).toEqual([
|
|
expect.objectContaining({
|
|
place_id: placeId,
|
|
tour_type: 'walk',
|
|
station_count: 3,
|
|
// CW-02: the cathedral's 30 min and the market passed through; the park's stay is not entered yet.
|
|
station_stay_minutes: 30,
|
|
station_stays_unknown: 1,
|
|
max_hiking_difficulty: null,
|
|
}),
|
|
]);
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie(VIEWER));
|
|
expect(detail.status).toBe(200);
|
|
expect(detail.body.waypoints).toEqual(waypoints);
|
|
});
|
|
|
|
it("refuses a station's fields on a hike, and a hike's or a climber's on a walk", async () => {
|
|
const ferrata = { kind: 'ferrata', ref: 'way/1', name: null, geometry: [GEOMETRY[0], GEOMETRY[2]] };
|
|
const hike = (point: Point) =>
|
|
walkBody({
|
|
tour_type: 'hike',
|
|
waypoints: inOrder([{ lat: 48.1, lng: 11.5 }, point]),
|
|
});
|
|
const bodies = [
|
|
hike({ lat: 48.11, lng: 11.51, stay_minutes: 20 }),
|
|
hike({ lat: 48.11, lng: 11.51, importance: 'must' }),
|
|
hike({ lat: 48.11, lng: 11.51, protected: true }),
|
|
walkBody({
|
|
waypoints: inOrder([
|
|
{ lat: 48.1, lng: 11.5, fixed_leg: ferrata },
|
|
{ lat: 48.12, lng: 11.52 },
|
|
]),
|
|
}),
|
|
walkBody({ max_hiking_difficulty: 3 }),
|
|
walkBody({ hiking_preferences: { avoid_ferries: true } }),
|
|
walkBody({
|
|
waypoints: inOrder([
|
|
{ lat: 48.1, lng: 11.5, key: 'a/b' },
|
|
{ lat: 48.12, lng: 11.52 },
|
|
]),
|
|
}),
|
|
walkBody({
|
|
waypoints: inOrder([
|
|
{ lat: 48.1, lng: 11.5, stay_minutes: 1441 },
|
|
{ lat: 48.12, lng: 11.52 },
|
|
]),
|
|
}),
|
|
];
|
|
const statuses = await bodies.reduce<Promise<number[]>>(
|
|
async (done, body) => [
|
|
...(await done),
|
|
(await request(server).post('/api/trips/5/tours').set('Cookie', cookie()).send(body)).status,
|
|
],
|
|
Promise.resolve([]),
|
|
);
|
|
expect(statuses).toEqual(bodies.map(() => 400));
|
|
expect(await countRows(orm, Tours)).toBe(0);
|
|
});
|
|
|
|
it('keeps the keys sent back through an edit and gives a new point a key of its own', async () => {
|
|
const { placeId, waypoints } = await saveWalk();
|
|
const [cathedral, , market, park] = waypoints;
|
|
const fountain = { lat: 48.12, lng: 11.52, name: 'Fountain' };
|
|
const res = await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', cookie())
|
|
.send(walkBody({ waypoints: inOrder([park, fountain, market, cathedral]), expected_revision: 1 }));
|
|
expect(res.status).toBe(200);
|
|
const keys = (res.body.waypoints as Point[]).map((point) => point.key);
|
|
expect([keys[0], keys[2], keys[3]]).toEqual([park.key, market.key, cathedral.key]);
|
|
expect(waypoints.map((point) => point.key)).not.toContain(keys[1]);
|
|
expect(stationKeySchema.safeParse(keys[1]).success).toBe(true);
|
|
expect(res.body.tour).toMatchObject({ station_count: 4, revision: 2 });
|
|
});
|
|
});
|
|
|
|
describe('a crossing by boat taken as a ferry leg (CW-12)', () => {
|
|
const FERRY = {
|
|
kind: 'ferry',
|
|
geometry: [
|
|
[48.11, 11.51],
|
|
[48.12, 11.52],
|
|
[48.13, 11.53],
|
|
],
|
|
crossings: [[0, 1]],
|
|
minutes: 7,
|
|
walk_minutes: 3,
|
|
};
|
|
const across = (leg: Point, type = 'walk') =>
|
|
walkBody({
|
|
tour_type: type,
|
|
waypoints: inOrder([
|
|
{ lat: 48.1, lng: 11.5 },
|
|
{ lat: 48.11, lng: 11.51, fixed_leg: leg },
|
|
{ lat: 48.13, lng: 11.53 },
|
|
]),
|
|
});
|
|
|
|
it('keeps the leg as it was taken, the boat apart from the walk, through a save and a read', async () => {
|
|
const { placeId, waypoints } = await saveWalk(5, across(FERRY));
|
|
expect(waypoints[1]?.fixed_leg).toEqual(FERRY);
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie(VIEWER));
|
|
expect(detail.body.waypoints[1].fixed_leg).toEqual(FERRY);
|
|
expect(detail.body.tour.duration).toBe(60);
|
|
// The boat is part of the day apart from the walking time, so a list can count it into the planned total.
|
|
expect(detail.body.tour.walk_ferry_minutes).toBe(7);
|
|
});
|
|
|
|
it('refuses it on any other type, and a crossing that does not run forwards within its leg', async () => {
|
|
const bodies = [
|
|
across(FERRY, 'hike'),
|
|
across({ ...FERRY, crossings: [[1, 0]] }),
|
|
across({ ...FERRY, crossings: [[1, 3]] }),
|
|
across({ ...FERRY, minutes: -1 }),
|
|
];
|
|
const statuses = await bodies.reduce<Promise<number[]>>(
|
|
async (done, body) => [
|
|
...(await done),
|
|
(await request(server).post('/api/trips/5/tours').set('Cookie', cookie()).send(body)).status,
|
|
],
|
|
Promise.resolve([]),
|
|
);
|
|
expect(statuses).toEqual([400, 400, 400, 400]);
|
|
expect(await countRows(orm, Tours)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('stations checked off on a day (CW-03)', () => {
|
|
it('marks a station visited, then skipped, then takes it back, telling the trip each time', async () => {
|
|
const { assignmentId, cathedral } = await walkOnDay3();
|
|
expect(await visitsOf()).toEqual([]);
|
|
|
|
const visited = await mark(assignmentId, cathedral, { status: 'visited' });
|
|
expect(visited.status).toBe(200);
|
|
const expected = [{ assignment_id: assignmentId, station_key: cathedral, status: 'visited' }];
|
|
expect(visited.body).toEqual({ visits: expected });
|
|
expect(realtime.broadcastMock).toHaveBeenCalledWith(
|
|
'5',
|
|
'tours:visits',
|
|
{ assignmentId, visits: expected },
|
|
'sock-1',
|
|
);
|
|
|
|
expect((await mark(assignmentId, cathedral, { status: 'skipped' })).status).toBe(200);
|
|
expect(await visitsOf(5, VIEWER)).toEqual([
|
|
{ assignment_id: assignmentId, station_key: cathedral, status: 'skipped' },
|
|
]);
|
|
|
|
const back = await mark(assignmentId, cathedral, { status: 'planned' });
|
|
expect(back.status).toBe(200);
|
|
expect(back.body).toEqual({ visits: [] });
|
|
expect(await visitsOf()).toEqual([]);
|
|
});
|
|
|
|
it('keeps a visit to the day it was made on', async () => {
|
|
const { placeId, assignmentId, cathedral, park } = await walkOnDay3();
|
|
const onDay4 = await assign(placeId, 4);
|
|
expect((await mark(assignmentId, cathedral, { status: 'visited' })).status).toBe(200);
|
|
expect((await mark(onDay4, park, { status: 'skipped' })).status).toBe(200);
|
|
expect(await visitsOf()).toEqual([
|
|
{ assignment_id: assignmentId, station_key: cathedral, status: 'visited' },
|
|
{ assignment_id: onDay4, station_key: park, status: 'skipped' },
|
|
]);
|
|
});
|
|
|
|
it('a copy of the walk starts with nothing checked off', async () => {
|
|
const { placeId, assignmentId, cathedral, waypoints } = await walkOnDay3();
|
|
expect((await mark(assignmentId, cathedral, { status: 'visited' })).status).toBe(200);
|
|
const copy = await request(server)
|
|
.post(`/api/trips/5/tours/${placeId}/copy`)
|
|
.set('Cookie', cookie())
|
|
.send({ name: 'Old town again' });
|
|
expect(copy.status).toBe(201);
|
|
expect((copy.body.waypoints as Point[]).map((point) => point.key)).toEqual(waypoints.map((point) => point.key));
|
|
const copyOnDay4 = await assign(copy.body.tour.place_id as number, 4);
|
|
expect((await visitsOf()).map((visit) => visit.assignment_id)).toEqual([assignmentId]);
|
|
expect((await visitsOf()).map((visit) => visit.assignment_id)).not.toContain(copyOnDay4);
|
|
});
|
|
|
|
it('a station taken out of the walk takes its visits along, and a walk made a hike keeps none', async () => {
|
|
const { placeId, waypoints, assignmentId, cathedral, market, park } = await walkOnDay3();
|
|
const reduced = await [cathedral, market, park].reduce<Promise<number[]>>(
|
|
async (done, key) => [...(await done), (await mark(assignmentId, key, { status: 'visited' })).status],
|
|
Promise.resolve([]),
|
|
);
|
|
expect(reduced).toEqual([200, 200, 200]);
|
|
|
|
const withoutMarket = inOrder([waypoints[0], waypoints[3]]);
|
|
realtime.broadcastMock.mockClear();
|
|
const edit = await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', cookie())
|
|
.set('X-Socket-Id', 'sock-1')
|
|
.send(walkBody({ waypoints: withoutMarket, expected_revision: 1 }));
|
|
expect(edit.status).toBe(200);
|
|
const left = await visitsOf();
|
|
expect(left.map((visit) => visit.station_key).sort()).toEqual([cathedral, park].sort());
|
|
// The client that saved still holds the market's visit too, so its own socket hears of the change as well.
|
|
expect(realtime.broadcastMock).toHaveBeenCalledWith(
|
|
'5',
|
|
'tours:visits',
|
|
{ assignmentId, visits: left },
|
|
undefined,
|
|
);
|
|
|
|
const plain = withoutMarket.map(({ stay_minutes: _s, importance: _i, protected: _p, ...point }) => point);
|
|
const hike = await request(server)
|
|
.put(`/api/trips/5/tours/${placeId}`)
|
|
.set('Cookie', cookie())
|
|
.send(walkBody({ tour_type: 'hike', waypoints: plain, expected_revision: 2 }));
|
|
expect(hike.status).toBe(200);
|
|
expect(await visitsOf()).toEqual([]);
|
|
});
|
|
|
|
it('the visits go with the walk when it moves to another day, and go away with its removal from a day', async () => {
|
|
const { assignmentId, cathedral } = await walkOnDay3();
|
|
expect((await mark(assignmentId, cathedral, { status: 'visited' })).status).toBe(200);
|
|
|
|
const move = await request(server)
|
|
.put(`/api/trips/5/assignments/${assignmentId}/move`)
|
|
.set('Cookie', cookie())
|
|
.send({ new_day_id: 4, order_index: 0 });
|
|
expect(move.status).toBe(200);
|
|
expect(await visitsOf()).toEqual([{ assignment_id: assignmentId, station_key: cathedral, status: 'visited' }]);
|
|
|
|
const removed = await request(server)
|
|
.delete(`/api/trips/5/days/4/assignments/${assignmentId}`)
|
|
.set('Cookie', cookie());
|
|
expect(removed.status).toBe(200);
|
|
expect(await visitsOf()).toEqual([]);
|
|
expect(await countRows(orm, TourStationVisits)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('remaining time over MCP (CW-13)', () => {
|
|
const remainingOf = (placeId: number, dayId: number) =>
|
|
withRequestContext(app.get(MikroORM), async () => {
|
|
const mcp = app.get(ToursMcp);
|
|
const result = await mcp.getCityWalkRemainingTime({ tripId: 5, placeId, dayId }, {
|
|
userId: VIEWER,
|
|
} as McpContext);
|
|
return JSON.parse(result.content[0]?.text ?? '{}');
|
|
});
|
|
|
|
it('is registered as a read of the trip', () => {
|
|
expect(() =>
|
|
createTestRegistry([app.get(ToursMcp)], {
|
|
accessPolicy: trekMcpAccessPolicy,
|
|
validateAccess: trekMcpValidateAccess,
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('counts from the stations checked off over REST, and the same walk on another day from its start', async () => {
|
|
const { placeId, assignmentId, cathedral, market } = await walkOnDay3();
|
|
await assign(placeId, 4);
|
|
expect((await mark(assignmentId, cathedral, { status: 'visited' })).status).toBe(200);
|
|
expect((await remainingOf(placeId, 3)).walking).toMatchObject({ total_minutes: 60, remaining_minutes: 60 });
|
|
|
|
expect((await mark(assignmentId, market, { status: 'visited' })).status).toBe(200);
|
|
const day3 = await remainingOf(placeId, 3);
|
|
expect(day3.reference).toMatchObject({ kind: 'last_visited', station: { key: market, name: 'Market' } });
|
|
expect(day3.walking).toMatchObject({ remaining_minutes: 40, upper_bound: false });
|
|
expect(day3.open_stations).toEqual([
|
|
expect.objectContaining({ name: 'Park', importance: 'optional', walk_minutes_from_reference: 40 }),
|
|
]);
|
|
expect(day3.stays).toMatchObject({
|
|
remaining_known_minutes: 0,
|
|
unknown: [expect.objectContaining({ name: 'Park' })],
|
|
});
|
|
|
|
const day4 = await remainingOf(placeId, 4);
|
|
expect(day4.reference).toEqual({ kind: 'start' });
|
|
expect(day4.walking.remaining_minutes).toBe(60);
|
|
expect(await countRows(orm, TourStationVisits)).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('refusals', () => {
|
|
it('404 for an assignment of another trip, a walk that is none, and a station the walk does not have', async () => {
|
|
const { placeId: hikeId } = await saveWalk(
|
|
5,
|
|
walkBody({
|
|
name: 'Ridge',
|
|
tour_type: 'hike',
|
|
waypoints: inOrder([
|
|
{ lat: 48.1, lng: 11.5 },
|
|
{ lat: 48.12, lng: 11.52 },
|
|
]),
|
|
}),
|
|
);
|
|
const hikeOnDay4 = await assign(hikeId, 4);
|
|
const { assignmentId, shaping } = await walkOnDay3();
|
|
const { placeId: elsewhere, waypoints } = await saveWalk(6);
|
|
const foreign = await assign(elsewhere, 7, 6);
|
|
const cases: Array<[number, string, string]> = [
|
|
[foreign, String(waypoints[0].key), 'Assignment not found'],
|
|
[999_999, 'stop-cathedral', 'Assignment not found'],
|
|
[hikeOnDay4, 'stop-cathedral', 'City walk not found'],
|
|
[assignmentId, 'stop-unknown', 'Station not found'],
|
|
[assignmentId, shaping, 'Station not found'],
|
|
];
|
|
const answers = await cases.reduce<Promise<Array<[number, unknown]>>>(async (done, [id, key]) => {
|
|
const before = await done;
|
|
const res = await mark(id, key, { status: 'visited' });
|
|
return [...before, [res.status, res.body]];
|
|
}, Promise.resolve([]));
|
|
expect(answers).toEqual(cases.map(([, , error]) => [404, { error }]));
|
|
expect(await countRows(orm, TourStationVisits)).toBe(0);
|
|
expect(realtime.broadcastMock).not.toHaveBeenCalledWith('5', 'tours:visits', expect.anything(), 'sock-1');
|
|
});
|
|
|
|
it('404 for someone outside the trip and while the addon is off; 403 for a member who may not edit days', async () => {
|
|
const { assignmentId, cathedral } = await walkOnDay3();
|
|
const outsider = await mark(assignmentId, cathedral, { status: 'visited' }, OUTSIDER);
|
|
expect([outsider.status, outsider.body]).toEqual([404, { error: 'Trip not found' }]);
|
|
const outsiderRead = await request(server).get('/api/trips/5/tour-visits').set('Cookie', cookie(OUTSIDER));
|
|
expect([outsiderRead.status, outsiderRead.body]).toEqual([404, { error: 'Trip not found' }]);
|
|
|
|
const viewer = await mark(assignmentId, cathedral, { status: 'visited' }, VIEWER);
|
|
expect([viewer.status, viewer.body]).toEqual([403, { error: 'No permission' }]);
|
|
expect(await visitsOf(5, VIEWER)).toEqual([]);
|
|
|
|
await setAddonEnabled(orm, 'tours', false);
|
|
const off = await mark(assignmentId, cathedral, { status: 'visited' });
|
|
expect([off.status, off.body]).toEqual([404, { error: 'Tours addon is not enabled' }]);
|
|
const offRead = await request(server).get('/api/trips/5/tour-visits').set('Cookie', cookie());
|
|
expect([offRead.status, offRead.body]).toEqual([404, { error: 'Tours addon is not enabled' }]);
|
|
expect(await countRows(orm, TourStationVisits)).toBe(0);
|
|
});
|
|
|
|
it('400 for a key that is no station key, an assignment id that is no number and a body it does not know', async () => {
|
|
const { assignmentId, cathedral } = await walkOnDay3();
|
|
const badKey = await mark(assignmentId, 'ab', { status: 'visited' });
|
|
expect([badKey.status, badKey.body]).toEqual([400, { error: 'Invalid station key' }]);
|
|
const refused: Array<[number | string, unknown]> = [
|
|
['abc', { status: 'visited' }],
|
|
[assignmentId, { status: 'done' }],
|
|
[assignmentId, { status: 'visited', note: 'late' }],
|
|
[assignmentId, {}],
|
|
];
|
|
const statuses = await refused.reduce<Promise<number[]>>(
|
|
async (done, [id, body]) => [...(await done), (await mark(id, cathedral, body)).status],
|
|
Promise.resolve([]),
|
|
);
|
|
expect(statuses).toEqual([400, 400, 400, 400]);
|
|
expect(await countRows(orm, TourStationVisits)).toBe(0);
|
|
});
|
|
|
|
it('401 without a session', async () => {
|
|
expect((await request(server).get('/api/trips/5/tour-visits')).status).toBe(401);
|
|
expect(
|
|
(await request(server).put('/api/trips/5/tour-visits/1/stop-cathedral').send({ status: 'visited' })).status,
|
|
).toBe(401);
|
|
});
|
|
});
|
|
});
|