mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
c40d00b830
PAD-06 and PAD-09. The tours list carries the check bench of every paddle tour and the detail its whole checklist, worked out of the obstacle data along the tour's line and the decisions stored for it. The obstacle data is read along the line run by run and kept in memory under the line, the boat and the data version, so a list of a trip reads it once; a hike costs nothing. While the data cannot be read the checklist says so and keeps every decision visible. PUT /api/trips/:tripId/tours/:placeId/obstacle-reviews stores what the paddlers decided, each against the basis the user saw, and answers the detail. It needs place_edit, refuses a tour that is no paddle tour with a 400, waits with a 503 while the data cannot be read, and skips an obstacle no longer on the checklist instead of failing. A copied tour and a copied trip take their decisions along. The MCP read tools describe the new fields; there is no write tool, since a decision is a statement of the paddlers. The obstacle data keeps the dates of checks on site, covers a line by sampling it, and tells its version by its rows and regions.
278 lines
13 KiB
TypeScript
278 lines
13 KiB
TypeScript
/**
|
|
* ToursController and ToursImportController: route metadata, delegation to
|
|
* ToursService, and the GPX import's handler-level checks. The import is not
|
|
* guarded (a guard answers before multer drains the body and the client sees
|
|
* ECONNRESET), so its handler has to reproduce the guards' answers itself, in
|
|
* the same order and with the same bodies (parity with AddonGuard and
|
|
* TripAccessGuard).
|
|
*/
|
|
import 'reflect-metadata';
|
|
|
|
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
|
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
|
|
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
|
import type { PlacesService } from '../../../src/nest/places/places.service';
|
|
import type { TourObstacleReviewsService } from '../../../src/nest/tours/tour-obstacle-reviews.service';
|
|
import type { TourPaddlePlanService } from '../../../src/nest/tours/tour-paddle-plan.service';
|
|
import { ToursImportController } from '../../../src/nest/tours/tours-import.controller';
|
|
import { ToursController } from '../../../src/nest/tours/tours.controller';
|
|
import type { ToursService } from '../../../src/nest/tours/tours.service';
|
|
import type { User } from '../../../src/types';
|
|
import { HttpException, RequestMethod } from '@nestjs/common';
|
|
import {
|
|
GUARDS_METADATA,
|
|
HTTP_CODE_METADATA,
|
|
INTERCEPTORS_METADATA,
|
|
METHOD_METADATA,
|
|
PATH_METADATA,
|
|
} from '@nestjs/common/constants';
|
|
import type { TourCreateRequest } from '@trek/shared';
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
|
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
|
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../../src/db/database', () => ({
|
|
db: new Proxy(
|
|
{},
|
|
{
|
|
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
|
},
|
|
),
|
|
}));
|
|
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
|
|
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
|
|
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
|
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
|
|
vi.mock('../../../src/nest/auth-core/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
|
|
|
const request: TourCreateRequest = {
|
|
name: 'Ridge walk',
|
|
tour_type: 'hike' as const,
|
|
route_geometry: [
|
|
[48, 11, 600],
|
|
[48.02, 11.04, 630],
|
|
] as [number, number, number][],
|
|
waypoints: [
|
|
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
|
|
{ lat: 48.02, lng: 11.04, role: 'end' as const, sequence: 1 },
|
|
],
|
|
max_hiking_difficulty: 2,
|
|
duration_seconds: 3600,
|
|
};
|
|
|
|
const user = { id: 2, role: 'user', email: 'u@example.test' } as User;
|
|
const trip = { id: 7, user_id: 1 };
|
|
const file = { buffer: Buffer.from('<gpx/>'), originalname: 'ridge.gpx' } as Express.Multer.File;
|
|
|
|
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected throw');
|
|
}
|
|
|
|
function importFixture(
|
|
o: { addon?: boolean; trip?: typeof trip | undefined; canEdit?: boolean; result?: unknown } = {},
|
|
) {
|
|
const addons = { isAddonEnabled: vi.fn(async () => o.addon ?? true) };
|
|
const places = {
|
|
verifyTripAccess: vi.fn(async () => ('trip' in o ? o.trip : trip)),
|
|
canEdit: vi.fn(async () => o.canEdit ?? true),
|
|
};
|
|
const result = 'result' in o ? o.result : { tours: [{ place_id: 42 }], caution: false, skipped: 0 };
|
|
const tours = { importGpxAsTour: vi.fn(async () => result) };
|
|
const controller = new ToursImportController(
|
|
tours as unknown as ToursService,
|
|
places as unknown as PlacesService,
|
|
addons as unknown as AddonsService,
|
|
);
|
|
return { addons, places, tours, controller, result };
|
|
}
|
|
|
|
const paddlePlansStub = { update: vi.fn() } as unknown as TourPaddlePlanService;
|
|
const reviewsStub = { save: vi.fn() } as unknown as TourObstacleReviewsService;
|
|
|
|
describe('ToursController planner routes', () => {
|
|
it('does not access the legacy global database initializer', () => {
|
|
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TOURS-CTL-001: hides every Tours route behind the addon guard before auth', () => {
|
|
expect(Reflect.getMetadata(PATH_METADATA, ToursController)).toBe('api/trips/:tripId/tours');
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
|
|
expect(Reflect.getMetadata(REQUIRE_ADDON, ToursController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
|
});
|
|
|
|
it('TOURS-CTL-001b: routes, methods and write permissions', () => {
|
|
const routes = [
|
|
['list', RequestMethod.GET, '/', undefined],
|
|
['detail', RequestMethod.GET, ':placeId', undefined],
|
|
['create', RequestMethod.POST, '/', 'place_edit'],
|
|
['update', RequestMethod.PUT, ':placeId', 'place_edit'],
|
|
['updatePaddlePlan', RequestMethod.PUT, ':placeId/paddle-plan', 'place_edit'],
|
|
['saveObstacleReviews', RequestMethod.PUT, ':placeId/obstacle-reviews', 'place_edit'],
|
|
] as const;
|
|
for (const [handler, method, path, permission] of routes) {
|
|
const fn = ToursController.prototype[handler];
|
|
expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method);
|
|
expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path);
|
|
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBe(permission);
|
|
}
|
|
// POST keeps Nest's default 201, as the PR's route answered.
|
|
expect(Reflect.getMetadata(HTTP_CODE_METADATA, ToursController.prototype.create)).toBeUndefined();
|
|
});
|
|
|
|
it('TOURS-CTL-002: POST /tours delegates the validated payload and socket id', async () => {
|
|
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
|
const service = { createTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
|
const controller = new ToursController(service, paddlePlansStub, reviewsStub);
|
|
|
|
expect(await controller.create('7', request, 'socket-1')).toBe(result);
|
|
expect(service.createTour).toHaveBeenCalledWith('7', request, 'socket-1');
|
|
});
|
|
|
|
it('TOURS-CTL-003: GET /tours/:placeId delegates within the guarded trip', async () => {
|
|
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
|
const service = { getTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
|
const controller = new ToursController(service, paddlePlansStub, reviewsStub);
|
|
|
|
expect(await controller.detail('7', '1')).toBe(result);
|
|
expect(service.getTour).toHaveBeenCalledWith('7', '1');
|
|
});
|
|
|
|
it('TOURS-CTL-004: PUT /tours/:placeId delegates the validated payload and socket id', async () => {
|
|
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
|
const service = { updateTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
|
const controller = new ToursController(service, paddlePlansStub, reviewsStub);
|
|
|
|
expect(await controller.update('7', '1', request, 'socket-2')).toBe(result);
|
|
expect(service.updateTour).toHaveBeenCalledWith('7', '1', request, 'socket-2');
|
|
});
|
|
|
|
it('TOURS-CTL-006: PUT /tours/:placeId/paddle-plan delegates the validated plan and socket id (PAD-03)', async () => {
|
|
const result = { tour: { place_id: 1 }, waypoints: [] };
|
|
const paddlePlans = { update: vi.fn().mockResolvedValue(result) } as unknown as TourPaddlePlanService;
|
|
const controller = new ToursController({} as ToursService, paddlePlans, reviewsStub);
|
|
const plan = { paddle_speed_kmh: 4, portage_speed_kmh: null, track_marks: { portages: [], locks: [] } };
|
|
|
|
expect(await controller.updatePaddlePlan('7', '1', plan, 'socket-3')).toBe(result);
|
|
expect(paddlePlans.update).toHaveBeenCalledWith('7', '1', plan, 'socket-3');
|
|
});
|
|
|
|
it('TOURS-CTL-007: PUT /tours/:placeId/obstacle-reviews delegates the changes with the user and socket id (PAD-06)', async () => {
|
|
const result = { tour: { place_id: 1 }, waypoints: [] };
|
|
const reviews = { save: vi.fn().mockResolvedValue(result) } as unknown as TourObstacleReviewsService;
|
|
const controller = new ToursController({} as ToursService, paddlePlansStub, reviews);
|
|
const body = { ops: [{ key: 'node/1', op: 'clear' as const, decision: null, note: null, basis: null }] };
|
|
|
|
expect(await controller.saveObstacleReviews('7', '1', body, { id: 4 } as User, 'socket-4')).toBe(result);
|
|
expect(reviews.save).toHaveBeenCalledWith('7', '1', body, 4, 'socket-4');
|
|
});
|
|
|
|
it('TOURS-CTL-005: GET /tours wraps the list in { tours }', async () => {
|
|
const tours = [{ place_id: 1 }];
|
|
const service = { listTours: vi.fn().mockResolvedValue(tours) } as unknown as ToursService;
|
|
|
|
expect(await new ToursController(service, paddlePlansStub, reviewsStub).list('7')).toEqual({ tours });
|
|
expect(service.listTours).toHaveBeenCalledWith('7');
|
|
});
|
|
});
|
|
|
|
describe('ToursImportController (POST /api/trips/:tripId/tours/import/gpx)', () => {
|
|
it('TOURS-IMP-001: the multipart route carries only the JWT guard and checks the rest in the handler', () => {
|
|
expect(Reflect.getMetadata(PATH_METADATA, ToursImportController)).toBe('api/trips/:tripId/tours');
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]);
|
|
expect(Reflect.getMetadata(REQUIRE_ADDON, ToursImportController)).toBeUndefined();
|
|
const handler = ToursImportController.prototype.importGpx;
|
|
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST);
|
|
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('import/gpx');
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toBeUndefined();
|
|
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
|
|
// The multipart reader, and the response contract check the test environment adds.
|
|
expect(Reflect.getMetadata(INTERCEPTORS_METADATA, handler)).toHaveLength(2);
|
|
const preview = ToursImportController.prototype.previewGpx;
|
|
expect(Reflect.getMetadata(METHOD_METADATA, preview)).toBe(RequestMethod.POST);
|
|
expect(Reflect.getMetadata(PATH_METADATA, preview)).toBe('import/gpx/preview');
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, preview)).toBeUndefined();
|
|
});
|
|
|
|
it('TOURS-IMP-002: delegates the route trip, file and socket id and returns the service result', async () => {
|
|
const { controller, tours, places, result } = importFixture();
|
|
|
|
expect(await controller.importGpx(user, '7', file, 'socket')).toBe(result);
|
|
expect(places.verifyTripAccess).toHaveBeenCalledWith('7', user.id);
|
|
expect(places.canEdit).toHaveBeenCalledWith(trip, user);
|
|
expect(tours.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket', { tour_type: 'hike' });
|
|
});
|
|
|
|
it('TOURS-IMP-003: a duplicate-only import is a successful skipped result', async () => {
|
|
const allSkipped = { tours: [], caution: false, skipped: 2 };
|
|
const { controller } = importFixture({ result: allSkipped });
|
|
expect(await controller.importGpx(user, '7', file)).toBe(allSkipped);
|
|
});
|
|
|
|
it('TOURS-IMP-004: addon off is the AddonGuard 404 before any trip lookup', async () => {
|
|
const { controller, places, tours, addons } = importFixture({ addon: false });
|
|
|
|
expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({
|
|
status: 404,
|
|
body: { error: 'Tours addon is not enabled' },
|
|
});
|
|
expect(addons.isAddonEnabled).toHaveBeenCalledWith('tours');
|
|
expect(places.verifyTripAccess).not.toHaveBeenCalled();
|
|
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TOURS-IMP-005: an inaccessible trip is the TripAccessGuard 404 before any permission check', async () => {
|
|
const { controller, places, tours } = importFixture({ trip: undefined });
|
|
|
|
expect(await thrownAsync(() => controller.importGpx(user, '8', file))).toEqual({
|
|
status: 404,
|
|
body: { error: 'Trip not found' },
|
|
});
|
|
expect(places.verifyTripAccess).toHaveBeenCalledWith('8', user.id);
|
|
expect(places.canEdit).not.toHaveBeenCalled();
|
|
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TOURS-IMP-006: without place_edit it is the TripAccessGuard 403, checked before the file', async () => {
|
|
const { controller, tours } = importFixture({ canEdit: false });
|
|
|
|
expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({
|
|
status: 403,
|
|
body: { error: 'No permission' },
|
|
});
|
|
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TOURS-IMP-007: 400 without a file', async () => {
|
|
const { controller, tours } = importFixture();
|
|
|
|
expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({
|
|
status: 400,
|
|
body: { error: 'No file uploaded' },
|
|
});
|
|
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TOURS-IMP-008: 400 when the GPX has no track or route', async () => {
|
|
const { controller } = importFixture({ result: null });
|
|
|
|
expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({
|
|
status: 400,
|
|
body: { error: 'No track or route found in GPX file' },
|
|
});
|
|
});
|
|
});
|