mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +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.
150 lines
7.0 KiB
TypeScript
150 lines
7.0 KiB
TypeScript
/**
|
|
* The REST route and the MCP tool of PAD-11 over a stub service: the route's
|
|
* guards, status and contract, the tool's gate, scope and input, and that
|
|
* both hand the same request to the same service. The answers through the
|
|
* real guards are pinned in the e2e suite.
|
|
*/
|
|
import { ADDON_IDS } from '../../../../src/addons';
|
|
import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp';
|
|
import { getEntry, type ClassRef } from '../../../../src/nest-mcp/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 { RESPONSE_CONTRACT_KEY } from '../../../../src/nest/common/response-contract';
|
|
import { PaddleRoutingController } from '../../../../src/nest/paddle-routing/paddle-routing.controller';
|
|
import { PaddleRoutingMcp } from '../../../../src/nest/paddle-routing/paddle-routing.mcp';
|
|
import type { PaddleRoutingService } from '../../../../src/nest/paddle-routing/paddle-routing.service';
|
|
import { TripAccessGuard } from '../../../../src/nest/permissions/trip-access.guard';
|
|
import type { TripAccessService } from '../../../../src/nest/trip-membership/trip-access.service';
|
|
import { RequestMethod } from '@nestjs/common';
|
|
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
|
import { paddleRouteRequestSchema, paddleRouteResponseSchema, type PaddleRouteResponse } from '@trek/shared';
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { z } from 'zod';
|
|
|
|
const A = { lat: 49.1283, lng: 12.1303 };
|
|
const B = { lat: 49.022, lng: 12.111 };
|
|
|
|
const ANSWER: PaddleRouteResponse = {
|
|
graph_rev: 'r1',
|
|
legs: [
|
|
{
|
|
status: 'ok',
|
|
geometry: [
|
|
[A.lat, A.lng],
|
|
[49.07, 12.12],
|
|
[B.lat, B.lng],
|
|
],
|
|
segments: [
|
|
{ kind: 'water', from: 0, to: 1, name: 'Regen', source: null, osm_ref: null, grade: null },
|
|
{ kind: 'portage', from: 1, to: 2, name: 'Wehr', source: 'tagged', osm_ref: 'way/1', grade: null },
|
|
],
|
|
distance_m: 15600,
|
|
snapped_m: { from: 12, to: 0 },
|
|
},
|
|
{ status: 'failed', code: 'no_region', at: B },
|
|
],
|
|
};
|
|
|
|
function service() {
|
|
return { route: vi.fn<PaddleRoutingService['route']>().mockResolvedValue(ANSWER) };
|
|
}
|
|
|
|
describe('PaddleRoutingController', () => {
|
|
it('PADROUTE-CTL-001: POST api/trips/:tripId/paddle/route sits behind Tours, the session and the trip, answers 200 on the contract', () => {
|
|
expect(Reflect.getMetadata(PATH_METADATA, PaddleRoutingController)).toBe('api/trips/:tripId/paddle');
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, PaddleRoutingController)).toEqual([
|
|
AddonGuard,
|
|
JwtAuthGuard,
|
|
TripAccessGuard,
|
|
]);
|
|
expect(Reflect.getMetadata(REQUIRE_ADDON, PaddleRoutingController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
|
const handler = PaddleRoutingController.prototype.route;
|
|
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST);
|
|
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('route');
|
|
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200);
|
|
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(paddleRouteResponseSchema);
|
|
});
|
|
|
|
it('PADROUTE-CTL-002: hands the parsed request to the service and answers what it planned', async () => {
|
|
const routing = service();
|
|
const controller = new PaddleRoutingController(routing as unknown as PaddleRoutingService);
|
|
const body = paddleRouteRequestSchema.parse({ legs: [{ from: A, to: B }], boat: 'sup' });
|
|
await expect(controller.route(body)).resolves.toBe(ANSWER);
|
|
expect(routing.route).toHaveBeenCalledWith(body);
|
|
});
|
|
});
|
|
|
|
const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext;
|
|
|
|
function toolOptions(): ToolOptions {
|
|
const entry = getEntry(PaddleRoutingMcp as unknown as ClassRef, 'planPaddleRoute');
|
|
if (!entry || entry.kind !== 'tool') throw new Error('plan_paddle_route is not registered');
|
|
return entry.options;
|
|
}
|
|
|
|
function makeMcp(accessible: boolean) {
|
|
const routing = service();
|
|
const access = { findAccessible: vi.fn().mockResolvedValue(accessible ? { id: 3 } : null) };
|
|
const mcp = new PaddleRoutingMcp(
|
|
routing as unknown as PaddleRoutingService,
|
|
access as unknown as TripAccessService,
|
|
{} as AddonsService,
|
|
);
|
|
return { mcp, routing, access };
|
|
}
|
|
|
|
describe('PaddleRoutingMcp', () => {
|
|
it('PADROUTE-MCP-001: plan_paddle_route is read only, gated on Tours and scoped to reading trips', () => {
|
|
const options = toolOptions();
|
|
expect(options.name).toBe('plan_paddle_route');
|
|
expect(options.annotations?.readOnlyHint).toBe(true);
|
|
expect(options.access).toEqual({ group: 'trips', mode: 'read' });
|
|
const isAddonEnabled = vi.fn((id: string) => id === ADDON_IDS.TOURS);
|
|
const self = { addons: { isAddonEnabled } } as unknown as PaddleRoutingMcp;
|
|
expect(options.when?.(ctx, self)).toBe(true);
|
|
expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.TOURS);
|
|
});
|
|
|
|
it('PADROUTE-MCP-002: takes the REST contract plus the trip, with the same defaults and limits', () => {
|
|
const input = z.object(toolOptions().inputSchema as z.ZodRawShape);
|
|
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }] }).success).toBe(true);
|
|
expect(input.safeParse({ tripId: 3, legs: [] }).success).toBe(false);
|
|
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }], lake_style: 'zigzag' }).success).toBe(false);
|
|
expect(input.safeParse({ legs: [{ from: A, to: B }] }).success).toBe(false);
|
|
});
|
|
|
|
it('PADROUTE-MCP-003: plans for a trip the caller sees, the same request as REST, and answers each leg without its points', async () => {
|
|
const { mcp, routing, access } = makeMcp(true);
|
|
const result = (await mcp.planPaddleRoute({ tripId: 3, legs: [{ from: A, to: B }] }, ctx)) as McpTextResult;
|
|
expect(access.findAccessible).toHaveBeenCalledWith(3, 7);
|
|
expect(routing.route).toHaveBeenCalledWith(paddleRouteRequestSchema.parse({ legs: [{ from: A, to: B }] }));
|
|
expect(result.isError).toBeUndefined();
|
|
expect(JSON.parse(result.content[0].text)).toEqual({
|
|
graph_rev: 'r1',
|
|
legs: [
|
|
{
|
|
status: 'ok',
|
|
distance_m: 15600,
|
|
snapped_m: { from: 12, to: 0 },
|
|
points: 3,
|
|
segments: [
|
|
{ kind: 'water', name: 'Regen', source: null, osm_ref: null },
|
|
{ kind: 'portage', name: 'Wehr', source: 'tagged', osm_ref: 'way/1' },
|
|
],
|
|
},
|
|
{ status: 'failed', code: 'no_region', at: B },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('PADROUTE-MCP-004: a trip the caller cannot see is refused before anything is planned', async () => {
|
|
const { mcp, routing } = makeMcp(false);
|
|
const result = (await mcp.planPaddleRoute({ tripId: 9, legs: [{ from: A, to: B }] }, ctx)) as McpTextResult;
|
|
expect(result.isError).toBe(true);
|
|
expect(routing.route).not.toHaveBeenCalled();
|
|
});
|
|
});
|