mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
1bc82f97fd
Paddle tours are planned on the water by TREK's own router: a water network per paddle region, built from the same Geofabrik extract as the obstacles and searched in a worker thread of the server (no sidecar). Rivers, canals and lakes (direct or along the shore), portages round weirs, locks by policy, whitewater by boat, gaps marked. Found portages and locks become points of their own; an unplannable leg says why and offers a way out. - Shaping points: a click on the line sets a point that steers the route without being a stop (hikes and paddle tours); recompute a single leg. - Lake style direct/shore, suggested from the wind forecast. - Regions load by themselves where a paddler plans (admin setting: automatic, on request, off; size cap and a daily count), with requests for the admins, a notification, and the load followed on the leg. - Portage from the last point, portages follow footways via the walking router (or stay straight), "put in here" on slipways and put-ins. - MCP plan_paddle_route with the REST route's rules.
208 lines
10 KiB
TypeScript
208 lines
10 KiB
TypeScript
/**
|
|
* The two controllers of PAD-05A over stub services: their routes, guards,
|
|
* status codes and response contracts, and how each hands its input on. The
|
|
* HTTP answers with the real guards are pinned in the e2e suite.
|
|
*/
|
|
import { AddonGuard } from '../../../../src/nest/addons/addon.guard';
|
|
import { REQUIRE_ADDON } from '../../../../src/nest/addons/require-addon.decorator';
|
|
import type { AuditService } from '../../../../src/nest/audit/audit.service';
|
|
import { AdminGuard } from '../../../../src/nest/auth-core/admin.guard';
|
|
import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard';
|
|
import { DomainError } from '../../../../src/nest/common/domain-error';
|
|
import { RESPONSE_CONTRACT_KEY } from '../../../../src/nest/common/response-contract';
|
|
import {
|
|
PaddleObstaclesController,
|
|
PaddleRegionsController,
|
|
} from '../../../../src/nest/paddle-obstacles/paddle-obstacles.controller';
|
|
import type { PaddleObstaclesService } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.service';
|
|
import type { PaddleRegionLoadService } from '../../../../src/nest/paddle-obstacles/paddle-region-load.service';
|
|
import type { PaddleRegionsService } from '../../../../src/nest/paddle-obstacles/paddle-regions.service';
|
|
import type { User } from '../../../../src/types';
|
|
import { RequestMethod } from '@nestjs/common';
|
|
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
|
import {
|
|
paddleObstaclesResponseSchema,
|
|
paddleRegionCatalogResponseSchema,
|
|
paddleRegionListResponseSchema,
|
|
paddleRegionLoadResponseSchema,
|
|
paddleRegionSchema,
|
|
paddleRegionSettingsSchema,
|
|
successResponseSchema,
|
|
type PaddleRegion,
|
|
} from '@trek/shared';
|
|
|
|
import type { Request } from 'express';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const region: PaddleRegion = {
|
|
id: 'oberpfalz',
|
|
name: 'Oberpfalz',
|
|
status: 'queued',
|
|
origin: 'admin',
|
|
source_timestamp: null,
|
|
imported_at: null,
|
|
feature_count: 0,
|
|
error: null,
|
|
progress: null,
|
|
network: null,
|
|
};
|
|
|
|
describe('PaddleObstaclesController', () => {
|
|
it('PADCTL-001: GET api/paddle/obstacles sits behind the Tours addon ahead of the session check', () => {
|
|
expect(Reflect.getMetadata(PATH_METADATA, PaddleObstaclesController)).toBe('api/paddle');
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, PaddleObstaclesController)).toEqual([AddonGuard, JwtAuthGuard]);
|
|
expect(Reflect.getMetadata(REQUIRE_ADDON, PaddleObstaclesController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
|
const handler = PaddleObstaclesController.prototype.inBox;
|
|
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.GET);
|
|
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('obstacles');
|
|
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(paddleObstaclesResponseSchema);
|
|
});
|
|
|
|
it('PADCTL-002: hands the box on and passes a refusal through as it is', async () => {
|
|
const answer = { features: [], coverage: { state: 'none' as const, regions: [] }, truncated: false };
|
|
const inBox = vi.fn<PaddleObstaclesService['inBox']>().mockResolvedValue(answer);
|
|
const controller = new PaddleObstaclesController(
|
|
{ inBox } as unknown as PaddleObstaclesService,
|
|
{} as PaddleRegionLoadService,
|
|
);
|
|
|
|
expect(await controller.inBox({ bbox: '49,12,49.1,12.1' })).toBe(answer);
|
|
expect(inBox).toHaveBeenCalledWith('49,12,49.1,12.1');
|
|
|
|
const refusal = new DomainError(400, 'bbox must be south,west,north,east and at most one degree a side');
|
|
inBox.mockRejectedValueOnce(refusal);
|
|
await expect(controller.inBox({ bbox: '0,0,5,5' })).rejects.toBe(refusal);
|
|
});
|
|
});
|
|
|
|
describe('PaddleObstaclesController.loadRegions (PAD-11C)', () => {
|
|
it('PADCTL-007: anyone signed in with Tours on, POST with 200, the places and the user handed on', async () => {
|
|
const handler = PaddleObstaclesController.prototype.loadRegions;
|
|
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST);
|
|
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('regions/load');
|
|
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200);
|
|
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(paddleRegionLoadResponseSchema);
|
|
const answer = [{ state: 'ready' as const, region: { id: 'oberpfalz', name: 'Oberpfalz' } }];
|
|
const load = { load: vi.fn().mockResolvedValue(answer) };
|
|
const controller = new PaddleObstaclesController(
|
|
{} as PaddleObstaclesService,
|
|
load as unknown as PaddleRegionLoadService,
|
|
);
|
|
const user = { id: 3, role: 'user' } as User;
|
|
const body = { points: [{ lat: 49.3, lng: 12.2 }], retry: false };
|
|
expect(await controller.loadRegions(user, body)).toEqual({ points: answer });
|
|
expect(load.load).toHaveBeenCalledWith(user, body);
|
|
});
|
|
});
|
|
|
|
describe('PaddleRegionsController', () => {
|
|
it('PADCTL-003: admin only, with the routes, status codes and contracts of the admin surface', () => {
|
|
expect(Reflect.getMetadata(PATH_METADATA, PaddleRegionsController)).toBe('api/admin/paddle-regions');
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, PaddleRegionsController)).toEqual([JwtAuthGuard, AdminGuard]);
|
|
const p = PaddleRegionsController.prototype;
|
|
const routes = [
|
|
[p.list, RequestMethod.GET, '/', undefined, paddleRegionListResponseSchema],
|
|
[p.catalog, RequestMethod.GET, 'catalog', undefined, paddleRegionCatalogResponseSchema],
|
|
[p.add, RequestMethod.POST, '/', undefined, paddleRegionSchema],
|
|
[p.refresh, RequestMethod.POST, 'refresh', 200, paddleRegionSchema],
|
|
[p.remove, RequestMethod.POST, 'remove', 200, successResponseSchema],
|
|
] as const;
|
|
for (const [handler, method, path, code, schema] of routes) {
|
|
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(method);
|
|
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe(path);
|
|
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(code);
|
|
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(schema);
|
|
}
|
|
});
|
|
|
|
it('PADCTL-004: wraps the lists in { regions }, hands the ids on, records each change and answers { success: true } on remove', async () => {
|
|
const service = {
|
|
list: vi.fn().mockResolvedValue([region]),
|
|
catalog: vi.fn().mockResolvedValue([{ id: 'oberpfalz', name: 'Oberpfalz', path: ['Europe'] }]),
|
|
add: vi.fn().mockResolvedValue(region),
|
|
refresh: vi.fn().mockResolvedValue(region),
|
|
remove: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const load = { pending: vi.fn().mockResolvedValue([]) };
|
|
const audit = { writeAudit: vi.fn().mockResolvedValue(undefined) };
|
|
const controller = new PaddleRegionsController(
|
|
service as unknown as PaddleRegionsService,
|
|
load as unknown as PaddleRegionLoadService,
|
|
audit as unknown as AuditService,
|
|
);
|
|
const admin = { id: 2 } as User;
|
|
const req = { ip: '203.0.113.5', headers: {}, socket: { remoteAddress: '203.0.113.5' } } as unknown as Request;
|
|
|
|
expect(await controller.list()).toEqual({ regions: [region], requests: [] });
|
|
expect(await controller.catalog({ q: 'ober' })).toEqual({
|
|
regions: [{ id: 'oberpfalz', name: 'Oberpfalz', path: ['Europe'] }],
|
|
});
|
|
expect(service.catalog).toHaveBeenCalledWith('ober');
|
|
expect(await controller.add(admin, { id: 'oberpfalz' }, req)).toBe(region);
|
|
expect(service.add).toHaveBeenCalledWith('oberpfalz');
|
|
expect(await controller.refresh(admin, { id: 'oberpfalz' }, req)).toBe(region);
|
|
expect(service.refresh).toHaveBeenCalledWith('oberpfalz');
|
|
expect(await controller.remove(admin, { id: 'oberpfalz' }, req)).toEqual({ success: true });
|
|
expect(service.remove).toHaveBeenCalledWith('oberpfalz');
|
|
expect(audit.writeAudit.mock.calls.map(([entry]) => entry)).toEqual(
|
|
['admin.paddle_region_add', 'admin.paddle_region_refresh', 'admin.paddle_region_remove'].map((action) => ({
|
|
userId: 2,
|
|
action,
|
|
ip: expect.any(String),
|
|
details: { region: 'oberpfalz' },
|
|
})),
|
|
);
|
|
});
|
|
|
|
it('PADCTL-005: a refused change leaves no audit row', async () => {
|
|
const refusal = new DomainError(404, 'Region not found');
|
|
const service = { remove: vi.fn().mockRejectedValue(refusal) };
|
|
const load = {};
|
|
const audit = { writeAudit: vi.fn() };
|
|
const controller = new PaddleRegionsController(
|
|
service as unknown as PaddleRegionsService,
|
|
load as unknown as PaddleRegionLoadService,
|
|
audit as unknown as AuditService,
|
|
);
|
|
await expect(controller.remove({ id: 2 } as User, { id: 'bayern' }, {} as Request)).rejects.toBe(refusal);
|
|
expect(audit.writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PADCTL-006: the setting and declining a request, each with its route, contract and audit row (PAD-11C)', async () => {
|
|
const p = PaddleRegionsController.prototype;
|
|
const routes = [
|
|
[p.settings, RequestMethod.GET, 'settings', undefined, paddleRegionSettingsSchema],
|
|
[p.updateSettings, RequestMethod.PUT, 'settings', undefined, paddleRegionSettingsSchema],
|
|
[p.decline, RequestMethod.POST, 'decline', 200, successResponseSchema],
|
|
] as const;
|
|
for (const [handler, method, path, code, schema] of routes) {
|
|
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(method);
|
|
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe(path);
|
|
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(code);
|
|
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(schema);
|
|
}
|
|
const settings = { mode: 'ask' as const, max_mb: 250 };
|
|
const load = {
|
|
settings: vi.fn().mockResolvedValue(settings),
|
|
updateSettings: vi.fn().mockResolvedValue(settings),
|
|
decline: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const audit = { writeAudit: vi.fn().mockResolvedValue(undefined) };
|
|
const controller = new PaddleRegionsController(
|
|
{} as PaddleRegionsService,
|
|
load as unknown as PaddleRegionLoadService,
|
|
audit as unknown as AuditService,
|
|
);
|
|
const req = { ip: '203.0.113.5', headers: {}, socket: { remoteAddress: '203.0.113.5' } } as unknown as Request;
|
|
expect(await controller.settings()).toBe(settings);
|
|
expect(await controller.updateSettings({ id: 2 } as User, settings, req)).toBe(settings);
|
|
expect(load.updateSettings).toHaveBeenCalledWith(settings);
|
|
expect(await controller.decline({ id: 2 } as User, { id: 'norway' }, req)).toEqual({ success: true });
|
|
expect(load.decline).toHaveBeenCalledWith('norway');
|
|
expect(audit.writeAudit.mock.calls.map(([entry]) => [entry.action, entry.details])).toEqual([
|
|
['admin.paddle_region_settings', settings],
|
|
['admin.paddle_region_decline', { region: 'norway' }],
|
|
]);
|
|
});
|
|
});
|