mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(tours): import a ski track from GPX and keep it as recorded
SKI-02. Ski joins the types a GPX file can be imported as, offered in the import preview's type field only: TREK routes no ski track, so the planner never offers it. The preview asks for the discipline on purpose (alpine, snowboard, cross-country or ski touring, none chosen at first) and, for cross-country, the technique (classic, skating or unknown); Import waits until both are named, and the technique stays in sight but dimmed for the other disciplines. The track is stored exactly as recorded, with its file name as provenance, its distance and heights from the file and no time: no ski time is worked out and no hiking estimate stands in for one. Opened in the planner, a ski track gets a rail card with its discipline on a tinted tile, a cross-country technique as a badge beside it, the ski time said as not worked out, and an info dot on what TREK does not say (snow, avalanches, open runs, and that the 3D gradient colours are no difficulty or avalanche rating). Change opens a dialog to classify the track again; only the classification changes, through the offline core, with the revision it was made at. Planning over the import is not offered for ski. The existing 3D terrain view, the elevation profile and its focus work on the track as for any import, with the standard map, topo and satellite and no via ferratas. Lists, the day plan and the detail show the discipline as a frosted corner badge on the tile and as a pill with the technique inside; day assignment, copies and take-overs work as for any tour. The corner badge is now one component that the winter label shares. The server keeps tours.ski_discipline and tours.ski_technique (CHECK lists, NULL on every other type), switches the ski type on without a routing profile, refuses a ski import without its discipline, a technique outside cross-country and ski fields on any other type, and takes a planned save's classification off. PUT /api/trips/:tripId/tours/:placeId/ski classifies a ski track again (place_edit, 409 on a stale revision, 400 for a tour that is no ski track) and broadcasts tours:changed. GPX export names the discipline (alpine_skiing, snowboarding, cross_country_skiing, backcountry_skiing), and list_tours describes both fields with what a ski track does not say. Ski is labelled plainly as Ski in every language, so it no longer reads as ski touring.
This commit is contained in:
@@ -42,6 +42,8 @@ export class Tours {
|
||||
winter_activity?: TToursWinterActivity | null;
|
||||
run_profile?: TToursRunProfile | null;
|
||||
run_pace_seconds_per_km?: number | null;
|
||||
ski_discipline?: TToursSkiDiscipline | null;
|
||||
ski_technique?: TToursSkiTechnique | null;
|
||||
tourTypeRef!: Ref<TourTypes>;
|
||||
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
|
||||
tour_sources: Ref<TourSources> | null = null;
|
||||
@@ -102,6 +104,23 @@ export const ToursRunProfile = {
|
||||
|
||||
export type TToursRunProfile = (typeof ToursRunProfile)[keyof typeof ToursRunProfile];
|
||||
|
||||
export const ToursSkiDiscipline = {
|
||||
ALPINE: 'alpine',
|
||||
SNOWBOARD: 'snowboard',
|
||||
CROSS_COUNTRY: 'cross_country',
|
||||
TOURING: 'touring',
|
||||
} as const;
|
||||
|
||||
export type TToursSkiDiscipline = (typeof ToursSkiDiscipline)[keyof typeof ToursSkiDiscipline];
|
||||
|
||||
export const ToursSkiTechnique = {
|
||||
CLASSIC: 'classic',
|
||||
SKATING: 'skating',
|
||||
UNKNOWN: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type TToursSkiTechnique = (typeof ToursSkiTechnique)[keyof typeof ToursSkiTechnique];
|
||||
|
||||
export const ToursSchema = defineEntity({
|
||||
class: Tours,
|
||||
repository: () => ToursRepository,
|
||||
@@ -153,6 +172,8 @@ export const ToursSchema = defineEntity({
|
||||
winter_activity: p.enum(() => ToursWinterActivity).nullable(),
|
||||
run_profile: p.enum(() => ToursRunProfile).nullable(),
|
||||
run_pace_seconds_per_km: p.integer().nullable(),
|
||||
ski_discipline: p.enum(() => ToursSkiDiscipline).nullable(),
|
||||
ski_technique: p.enum(() => ToursSkiTechnique).nullable(),
|
||||
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
|
||||
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
|
||||
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
|
||||
|
||||
@@ -34,4 +34,6 @@ export interface ToursTable {
|
||||
winter_activity: InsertOptional<string | null>;
|
||||
run_profile: InsertOptional<string | null>;
|
||||
run_pace_seconds_per_km: InsertOptional<number | null>;
|
||||
ski_discipline: InsertOptional<string | null>;
|
||||
ski_technique: InsertOptional<string | null>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* SKI-02: a tour can be a ski track, imported from a GPX file and kept as it
|
||||
* was recorded. The `ski` key has been in `tour_types`, disabled and without
|
||||
* a routing profile, since every contract type was seeded; it is switched on
|
||||
* here and keeps no routing profile, since TREK routes no ski track. The
|
||||
* discipline and, for cross-country, the technique are columns of the tour,
|
||||
* NULL on every other type, so no existing tour changes its meaning.
|
||||
*/
|
||||
export class Migration20200101045520_a_tour_can_be_a_ski_track extends Migration {
|
||||
override name = 'Migration20200101045520_a_tour_can_be_a_ski_track';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(
|
||||
"ALTER TABLE tours ADD COLUMN ski_discipline TEXT CHECK (ski_discipline IS NULL OR ski_discipline IN ('alpine', 'snowboard', 'cross_country', 'touring'))",
|
||||
);
|
||||
this.addSql(
|
||||
"ALTER TABLE tours ADD COLUMN ski_technique TEXT CHECK (ski_technique IS NULL OR ski_technique IN ('classic', 'skating', 'unknown'))",
|
||||
);
|
||||
this.addSql("UPDATE tour_types SET enabled = 1 WHERE key = 'ski'");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
PaddleLakeStyle,
|
||||
PaddleWaterContext,
|
||||
RunProfile,
|
||||
SkiDiscipline,
|
||||
SkiTechnique,
|
||||
WinterActivity,
|
||||
} from '@trek/shared';
|
||||
|
||||
@@ -42,6 +44,9 @@ export interface TourListRow {
|
||||
/** RUN-01: a run's profile and the pace it is planned at, seconds per km; NULL on every other type. */
|
||||
run_profile: string | null;
|
||||
run_pace_seconds_per_km: number | null;
|
||||
/** SKI-02: a ski track's discipline and, for cross-country, its technique; NULL on every other type. */
|
||||
ski_discipline: string | null;
|
||||
ski_technique: string | null;
|
||||
boat_type: string | null;
|
||||
water_context: string | null;
|
||||
paddle_speed_kmh: number | null;
|
||||
@@ -112,6 +117,8 @@ export interface TourRow {
|
||||
winter_activity: string | null;
|
||||
run_profile: string | null;
|
||||
run_pace_seconds_per_km: number | null;
|
||||
ski_discipline: string | null;
|
||||
ski_technique: string | null;
|
||||
}
|
||||
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
|
||||
|
||||
@@ -134,6 +141,9 @@ export interface TourInsert {
|
||||
/** RUN-01: a run's profile and its pace, seconds per km; left out, NULL. */
|
||||
run_profile?: RunProfile | null;
|
||||
run_pace_seconds_per_km?: number | null;
|
||||
/** SKI-02: a ski track's discipline and technique; left out, NULL. */
|
||||
ski_discipline?: SkiDiscipline | null;
|
||||
ski_technique?: SkiTechnique | null;
|
||||
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
|
||||
boat_type?: PaddleBoatType | null;
|
||||
water_context?: PaddleWaterContext | null;
|
||||
@@ -153,6 +163,8 @@ export type TourUpdate = Omit<
|
||||
| 'bike_profile'
|
||||
| 'run_profile'
|
||||
| 'run_pace_seconds_per_km'
|
||||
| 'ski_discipline'
|
||||
| 'ski_technique'
|
||||
| 'boat_type'
|
||||
| 'water_context'
|
||||
| 'paddle_speed_kmh'
|
||||
@@ -164,6 +176,8 @@ export type TourUpdate = Omit<
|
||||
bike_profile: BikeProfile | null;
|
||||
run_profile: RunProfile | null;
|
||||
run_pace_seconds_per_km: number | null;
|
||||
ski_discipline: SkiDiscipline | null;
|
||||
ski_technique: SkiTechnique | null;
|
||||
boat_type: PaddleBoatType | null;
|
||||
water_context: PaddleWaterContext | null;
|
||||
paddle_speed_kmh: number | null;
|
||||
@@ -171,6 +185,12 @@ export type TourUpdate = Omit<
|
||||
paddle_lake_style: PaddleLakeStyle | null;
|
||||
};
|
||||
|
||||
/** SKI-02: a ski track classified again: its discipline and, for cross-country, its technique. */
|
||||
export interface TourSkiUpdate {
|
||||
ski_discipline: SkiDiscipline;
|
||||
ski_technique: SkiTechnique | null;
|
||||
}
|
||||
|
||||
/** A recorded track's paddle plan (PAD-03, PAD-07): its speeds, its marks and the time they make. */
|
||||
export interface TourPaddlePlanUpdate {
|
||||
paddle_speed_kmh: number | null;
|
||||
@@ -269,6 +289,8 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
't.winter_activity as winter_activity',
|
||||
't.run_profile as run_profile',
|
||||
't.run_pace_seconds_per_km as run_pace_seconds_per_km',
|
||||
't.ski_discipline as ski_discipline',
|
||||
't.ski_technique as ski_technique',
|
||||
't.boat_type as boat_type',
|
||||
't.water_context as water_context',
|
||||
't.paddle_speed_kmh as paddle_speed_kmh',
|
||||
@@ -637,6 +659,8 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
winter_activity: input.winter_activity ?? null,
|
||||
run_profile: input.run_profile ?? null,
|
||||
run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null,
|
||||
ski_discipline: input.ski_discipline ?? null,
|
||||
ski_technique: input.ski_technique ?? null,
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
@@ -724,6 +748,8 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
bike_profile: input.bike_profile,
|
||||
run_profile: input.run_profile,
|
||||
run_pace_seconds_per_km: input.run_pace_seconds_per_km,
|
||||
ski_discipline: input.ski_discipline,
|
||||
ski_technique: input.ski_technique,
|
||||
boat_type: input.boat_type,
|
||||
water_context: input.water_context,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh,
|
||||
@@ -758,6 +784,21 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
return Number(result.numUpdatedRows ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* SKI-02: a ski track of the trip classified again, its discipline and
|
||||
* technique only; the track and everything else stay as they are. Returns
|
||||
* whether a row of that trip was updated.
|
||||
*/
|
||||
async updateSkiInTrip(trip_id: number, place_id: number, input: TourSkiUpdate): Promise<boolean> {
|
||||
const result = await this.db()
|
||||
.updateTable('tours')
|
||||
.set({ ski_discipline: input.ski_discipline, ski_technique: input.ski_technique })
|
||||
.where('place_id', '=', place_id)
|
||||
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
|
||||
.executeTakeFirst();
|
||||
return Number(result.numUpdatedRows ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an import's recorded line before it is planned over for the first
|
||||
* time: copies the place's line into the tour while the tour has no
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { tourSkiClassificationRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TourSkiClassificationDto extends createZodDto(tourSkiClassificationRequestSchema) {}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BikeProfile, PaddleBoatType, RunProfile } from '@trek/shared';
|
||||
import type { BikeProfile, PaddleBoatType, RunProfile, SkiDiscipline } from '@trek/shared';
|
||||
|
||||
/** What GPX calls the activity in a track's <type>; a paddle tour says what it is paddled in. */
|
||||
const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
|
||||
@@ -22,22 +22,33 @@ const RUN_ACTIVITY: Record<RunProfile, string> = {
|
||||
trail: 'trail_running',
|
||||
};
|
||||
|
||||
/** SKI-02: a ski track says how it was skied, in the same words; without a known discipline it is skiing. */
|
||||
const SKI_ACTIVITY: Record<SkiDiscipline, string> = {
|
||||
alpine: 'alpine_skiing',
|
||||
snowboard: 'snowboarding',
|
||||
cross_country: 'cross_country_skiing',
|
||||
touring: 'backcountry_skiing',
|
||||
};
|
||||
|
||||
/**
|
||||
* The activity a tour's track names in GPX: a paddle tour its boat, a bike
|
||||
* tour and a run their profile, a hike on snowshoes snowshoeing (HIK-01) and any other
|
||||
* hike hiking; whatever is missing falls back to the plainest.
|
||||
* tour and a run their profile, a ski track its discipline (SKI-02), a hike on
|
||||
* snowshoes snowshoeing (HIK-01) and any other hike hiking; whatever is
|
||||
* missing falls back to the plainest.
|
||||
*/
|
||||
export function gpxActivityOf(tour: {
|
||||
tour_type: string;
|
||||
boat_type?: string | null;
|
||||
bike_profile?: string | null;
|
||||
run_profile?: string | null;
|
||||
ski_discipline?: string | null;
|
||||
winter_activity?: string | null;
|
||||
}): string {
|
||||
const { tour_type: type, boat_type: boat, bike_profile: bike } = tour;
|
||||
if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other;
|
||||
if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring;
|
||||
if (type === 'run') return RUN_ACTIVITY[(tour.run_profile ?? 'running') as RunProfile] ?? RUN_ACTIVITY.running;
|
||||
if (type === 'ski') return SKI_ACTIVITY[tour.ski_discipline as SkiDiscipline] ?? 'skiing';
|
||||
if (type !== 'hike') return type;
|
||||
// A winter hike is walked as any hike is; only snowshoes make it an activity of its own.
|
||||
return tour.winter_activity === 'snowshoe' ? 'snowshoeing' : 'hiking';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourSkiClassificationDto } from './dto/tour-ski.dto';
|
||||
import { TourSkiService } from './tour-ski.service';
|
||||
import { Body, Controller, Headers, Param, Put, UseGuards } from '@nestjs/common';
|
||||
import { tourDetailResponseSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* SKI-02: PUT /api/trips/:tripId/tours/:placeId/ski classifies a ski track
|
||||
* again, its discipline and cross-country technique only, as someone who may
|
||||
* edit the trip's places. Answers the tour's detail, as the editor reads it.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tours/:placeId/ski')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class TourSkiController {
|
||||
constructor(private readonly ski: TourSkiService) {}
|
||||
|
||||
@RequirePermission('place_edit')
|
||||
@Put()
|
||||
@ResponseContract(tourDetailResponseSchema)
|
||||
async classify(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('placeId') placeId: string,
|
||||
@Body() body: TourSkiClassificationDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return await this.ski.classify(tripId, placeId, body, socketId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { advanceTourRevision } from './tour-revision';
|
||||
import { ToursService } from './tours.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* SKI-02: a ski track classified again after its import, when the discipline
|
||||
* or the cross-country technique chosen then was not the right one. Only the
|
||||
* classification changes: the recorded track, its measures, its days and its
|
||||
* provenance stay as they are, and nothing is routed. A tour that is no ski
|
||||
* track has no classification to change.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourSkiService {
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly tours: ToursService,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
) {}
|
||||
|
||||
async classify(
|
||||
tripId: string,
|
||||
placeId: string,
|
||||
input: TourSkiClassificationRequest,
|
||||
socketId?: string,
|
||||
): Promise<TourDetailResponse> {
|
||||
const tid = toRowId(tripId);
|
||||
const pid = toRowId(placeId);
|
||||
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
|
||||
|
||||
await this.uow.transactional(async () => {
|
||||
const row = await this.toursRepo.findInTrip(tid, pid);
|
||||
if (!row) throw new NotFoundException('Tour not found');
|
||||
if (row.tour_type !== 'ski') {
|
||||
throw new DomainError(400, 'Only a ski track has a ski classification', {
|
||||
code: 'not_a_ski_track',
|
||||
details: { code: 'not_a_ski_track' },
|
||||
});
|
||||
}
|
||||
await advanceTourRevision(this.toursRepo, pid, input.expected_revision);
|
||||
await this.toursRepo.updateSkiInTrip(tid, pid, {
|
||||
ski_discipline: input.ski_discipline,
|
||||
ski_technique: input.ski_technique ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await this.tours.getTour(tripId, placeId);
|
||||
this.tours.broadcastToursChanged(tripId, [pid], socketId);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, or ski for a ski track), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -28,6 +28,8 @@ import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import { TourPaddlePlanService } from './tour-paddle-plan.service';
|
||||
import { TourPoiSearchController } from './tour-poi-search.controller';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { TourSkiController } from './tour-ski.controller';
|
||||
import { TourSkiService } from './tour-ski.service';
|
||||
import { TourTrailGradeController } from './tour-trail-grade.controller';
|
||||
import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { TourVisitsController } from './tour-visits.controller';
|
||||
@@ -88,6 +90,7 @@ import { Module } from '@nestjs/common';
|
||||
controllers: [
|
||||
TourTakeOverController,
|
||||
TourPoiSearchController,
|
||||
TourSkiController,
|
||||
TourTrailGradeController,
|
||||
TourVisitsController,
|
||||
TourWalkImportController,
|
||||
@@ -106,6 +109,7 @@ import { Module } from '@nestjs/common';
|
||||
ToursWandererService,
|
||||
TourLibraryService,
|
||||
TourPoiSearchService,
|
||||
TourSkiService,
|
||||
TourTrailGradeService,
|
||||
TourVisitsService,
|
||||
TourWalkImportService,
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
type PaddleLakeStyle,
|
||||
type PaddleWaterContext,
|
||||
type RunProfile,
|
||||
type SkiDiscipline,
|
||||
type SkiTechnique,
|
||||
type TourCreateRequest,
|
||||
type TourCreateResponse,
|
||||
type TourDetailResponse,
|
||||
@@ -179,6 +181,8 @@ export class ToursService {
|
||||
winter_activity: r.winter_activity as WinterActivity | null,
|
||||
run_profile: r.run_profile as RunProfile | null,
|
||||
run_pace_seconds_per_km: r.run_pace_seconds_per_km,
|
||||
ski_discipline: r.ski_discipline as SkiDiscipline | null,
|
||||
ski_technique: r.ski_technique as SkiTechnique | null,
|
||||
boat_type: r.boat_type as PaddleBoatType | null,
|
||||
water_context: r.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: r.paddle_speed_kmh,
|
||||
@@ -222,6 +226,9 @@ export class ToursService {
|
||||
bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null,
|
||||
winter_activity: winterField(input),
|
||||
...runFields(input),
|
||||
// SKI-02: TREK plans no ski track, so a planned save is never one and takes its classification off.
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
@@ -521,6 +528,8 @@ export class ToursService {
|
||||
bike_profile: row.bike_profile as BikeProfile | null,
|
||||
run_profile: row.run_profile as RunProfile | null,
|
||||
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
|
||||
ski_discipline: row.ski_discipline as SkiDiscipline | null,
|
||||
ski_technique: row.ski_technique as SkiTechnique | null,
|
||||
boat_type: row.boat_type as PaddleBoatType | null,
|
||||
water_context: row.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: row.paddle_speed_kmh,
|
||||
@@ -632,6 +641,8 @@ export class ToursService {
|
||||
winter_activity: row.winter_activity as WinterActivity | null,
|
||||
run_profile: row.run_profile as RunProfile | null,
|
||||
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
|
||||
ski_discipline: row.ski_discipline as SkiDiscipline | null,
|
||||
ski_technique: row.ski_technique as SkiTechnique | null,
|
||||
boat_type: row.boat_type as PaddleBoatType | null,
|
||||
water_context: row.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: row.paddle_speed_kmh,
|
||||
@@ -815,12 +826,15 @@ export class ToursService {
|
||||
const rows = this.readGpxTracks(fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
await this.assertTourTypeEnabled(fields.tour_type);
|
||||
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water.
|
||||
// A run comes in without a pace: its time stays unknown until one is planned from it.
|
||||
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water,
|
||||
// a ski track its discipline and technique (SKI-02). A run comes in without a pace and a ski track without a
|
||||
// time: neither is worked out for them, so they stay unknown.
|
||||
const typeFields = {
|
||||
bike_profile: fields.bike_profile ?? null,
|
||||
run_profile: fields.run_profile ?? null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: fields.ski_discipline ?? null,
|
||||
ski_technique: fields.ski_technique ?? null,
|
||||
boat_type: fields.boat_type ?? null,
|
||||
water_context: fields.water_context ?? null,
|
||||
paddle_speed_kmh: null,
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Tours e2e for ski tracks (SKI-02): a GPX file imported as a ski track with
|
||||
* its discipline (and a cross-country technique), kept as recorded with no
|
||||
* time worked out, listed, opened, exported, copied and classified again
|
||||
* without touching the track, its days or its provenance, 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-run.e2e.test.ts.
|
||||
*
|
||||
* Fixtures: user 1 owns trip 5 and trip 6, user 2 is a member of trip 5 whom
|
||||
* the permission spy denies 'place_edit'.
|
||||
*/
|
||||
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 { 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 realtime = new FakeRealtimeService();
|
||||
|
||||
let orm: TestOrm;
|
||||
let checkPermission: MockInstance;
|
||||
|
||||
const OWNER = 1;
|
||||
const VIEWER = 2;
|
||||
|
||||
// Up on skins to the hut and back down: heights recorded, no timestamps.
|
||||
const GPX = `<?xml version="1.0"?>
|
||||
<gpx version="1.1" creator="e2e"><trk><name>Up to the hut</name><trkseg>
|
||||
<trkpt lat="47.2" lon="11.3"><ele>1200</ele></trkpt>
|
||||
<trkpt lat="47.21" lon="11.31"><ele>1650</ele></trkpt>
|
||||
<trkpt lat="47.22" lon="11.32"><ele>1980</ele></trkpt>
|
||||
</trkseg></trk></gpx>`;
|
||||
|
||||
// A planned hike, the type a ski track is not.
|
||||
const hikeBody = {
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
route_geometry: [
|
||||
[47.2, 11.3, 1200],
|
||||
[47.22, 11.32, 1980],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 47.2, lng: 11.3, role: 'start', sequence: 0 },
|
||||
{ lat: 47.22, lng: 11.32, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 2,
|
||||
duration_seconds: 3600,
|
||||
};
|
||||
|
||||
describe('Ski tracks 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: vi.fn() })
|
||||
.overrideProvider(OverpassElementsClient)
|
||||
.useValue({ query: vi.fn() })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const importGpx = (fields: Record<string, string>, tripId = 5) => {
|
||||
let req = request(server).post(`/api/trips/${tripId}/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: 'hut.gpx', contentType: 'application/gpx+xml' });
|
||||
};
|
||||
|
||||
const importSki = async (fields: Record<string, string> = { ski_discipline: 'touring' }, tripId = 5) => {
|
||||
const res = await importGpx({ tour_type: 'ski', ...fields }, tripId);
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.tours[0].place_id as number;
|
||||
};
|
||||
|
||||
const classify = (placeId: number, body: Record<string, unknown>, userId = OWNER, tripId = 5) =>
|
||||
request(server)
|
||||
.put(`/api/trips/${tripId}/tours/${placeId}/ski`)
|
||||
.set('Cookie', sessionCookie(userId))
|
||||
.set('X-Socket-Id', 'sock-9')
|
||||
.send(body);
|
||||
|
||||
const detail = async (placeId: number) =>
|
||||
(await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body;
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
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 insertRows(orm, Trips, [
|
||||
{ id: 5, user: 1, title: 'Ski week' },
|
||||
{ id: 6, user: 1, title: 'Other trip' },
|
||||
]);
|
||||
await addTripMember(orm, 5, 2);
|
||||
await insertRows(orm, Days, [{ id: 3, trip: 5, day_number: 1, date: '2027-02-10' }]);
|
||||
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();
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-001: imports a ski track with its discipline, kept as recorded, with its heights and no time', async () => {
|
||||
const placeId = await importSki();
|
||||
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'touring',
|
||||
ski_technique: null,
|
||||
duration: null,
|
||||
max_hiking_difficulty: null,
|
||||
origin: 'gpx',
|
||||
origin_file_name: 'hut.gpx',
|
||||
});
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER));
|
||||
expect(list.status).toBe(200);
|
||||
const listed = list.body.tours[0];
|
||||
expect(listed).toMatchObject({ place_id: placeId, tour_type: 'ski', ski_discipline: 'touring', duration: null });
|
||||
expect(listed.elevation_gain).toBeGreaterThan(0);
|
||||
expect(listed.distance).toBeGreaterThan(0);
|
||||
const opened = await detail(placeId);
|
||||
expect(opened.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'touring', has_waypoints: false });
|
||||
expect(opened.provenance).toMatchObject({ origin: 'gpx', source: { kind: 'gpx', fileName: 'hut.gpx' } });
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-002: a cross-country track keeps its technique, unknown included', async () => {
|
||||
for (const technique of ['classic', 'skating', 'unknown']) {
|
||||
const placeId = await importSki({ ski_discipline: 'cross_country', ski_technique: technique });
|
||||
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: technique,
|
||||
});
|
||||
await deleteRows(orm, Places);
|
||||
}
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-003: refuses a ski import without its discipline or with a technique it has not, and ski fields on a hike', async () => {
|
||||
const refused = await Promise.all(
|
||||
[
|
||||
{ tour_type: 'ski' },
|
||||
{ tour_type: 'ski', ski_discipline: 'cross_country' },
|
||||
{ tour_type: 'ski', ski_discipline: 'alpine', ski_technique: 'classic' },
|
||||
{ tour_type: 'ski', ski_discipline: 'freeride' },
|
||||
{ tour_type: 'hike', ski_discipline: 'alpine' },
|
||||
].map((fields) => importGpx(fields)),
|
||||
);
|
||||
expect(refused.map((res) => res.status)).toEqual([400, 400, 400, 400, 400]);
|
||||
expect(await countRows(orm, Places)).toBe(0);
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-004: a ski track is never planned: a planned save of one is refused', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ ...hikeBody, tour_type: 'ski', max_hiking_difficulty: null });
|
||||
expect(res.status).toBe(400);
|
||||
expect(await countRows(orm, Tours)).toBe(0);
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-005: classified again, only the classification changes: track, measures, days and provenance stay', async () => {
|
||||
const placeId = await importSki({ ski_discipline: 'cross_country', ski_technique: 'unknown' });
|
||||
await insertRows(orm, DayAssignments, [{ day: 3, place: placeId, order_index: 0 }]);
|
||||
const before = await findRow(orm, Places, { id: placeId });
|
||||
const measured = await findRow(orm, Tours, { place: placeId });
|
||||
|
||||
const res = await classify(placeId, {
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: 'skating',
|
||||
expected_revision: 1,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tour).toMatchObject({
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: 'skating',
|
||||
revision: 2,
|
||||
planned: true,
|
||||
distance: measured?.distance,
|
||||
elevation_gain: measured?.elevation_gain,
|
||||
});
|
||||
expect(res.body.provenance).toMatchObject({ origin: 'gpx', source: { kind: 'gpx', fileName: 'hut.gpx' } });
|
||||
expect((await findRow(orm, Places, { id: placeId }))?.route_geometry).toBe(before?.route_geometry);
|
||||
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
|
||||
expect(realtime.broadcastMock).toHaveBeenCalledWith('5', 'tours:changed', { placeIds: [placeId] }, 'sock-9');
|
||||
|
||||
// Another discipline takes the technique off.
|
||||
const alpine = await classify(placeId, { ski_discipline: 'alpine' });
|
||||
expect(alpine.status).toBe(200);
|
||||
expect(alpine.body.tour).toMatchObject({ ski_discipline: 'alpine', ski_technique: null, revision: 3 });
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-006: a classification against a tour that moved on is a 409 and changes nothing', async () => {
|
||||
const placeId = await importSki({ ski_discipline: 'alpine' });
|
||||
expect((await classify(placeId, { ski_discipline: 'snowboard' })).status).toBe(200);
|
||||
const stale = await classify(placeId, { ski_discipline: 'touring', expected_revision: 1 });
|
||||
expect(stale.status).toBe(409);
|
||||
expect(stale.body).toMatchObject({ code: 'tour_revision_conflict', revision: 2 });
|
||||
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_discipline: 'snowboard', revision: 2 });
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-007: refuses a classification that is no ski classification, or of a tour that is no ski track', async () => {
|
||||
const placeId = await importSki({ ski_discipline: 'alpine' });
|
||||
const invalid = await Promise.all(
|
||||
[
|
||||
{},
|
||||
{ ski_discipline: 'cross_country' },
|
||||
{ ski_discipline: 'touring', ski_technique: 'classic' },
|
||||
{ ski_discipline: 'monoski' },
|
||||
].map((body) => classify(placeId, body)),
|
||||
);
|
||||
expect(invalid.map((res) => res.status)).toEqual([400, 400, 400, 400]);
|
||||
|
||||
const hike = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(hikeBody);
|
||||
expect(hike.status).toBe(201);
|
||||
const notSki = await classify(hike.body.tour.place_id as number, { ski_discipline: 'alpine' });
|
||||
expect(notSki.status).toBe(400);
|
||||
expect(notSki.body).toMatchObject({ error: 'Only a ski track has a ski classification' });
|
||||
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_discipline: 'alpine', revision: 1 });
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-008: a member who may only read cannot classify, and a ski track of another trip is not found', async () => {
|
||||
const placeId = await importSki({ ski_discipline: 'alpine' });
|
||||
expect((await classify(placeId, { ski_discipline: 'snowboard' }, VIEWER)).status).toBe(403);
|
||||
const elsewhere = await importSki({ ski_discipline: 'alpine' }, 6);
|
||||
expect((await classify(elsewhere, { ski_discipline: 'snowboard' })).status).toBe(404);
|
||||
expect((await classify(999999, { ski_discipline: 'snowboard' })).status).toBe(404);
|
||||
expect(await findRow(orm, Tours, { place: elsewhere })).toMatchObject({ ski_discipline: 'alpine' });
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-009: exports a ski track as its discipline, and a copy keeps the classification', async () => {
|
||||
const placeId = await importSki();
|
||||
const res = await request(server)
|
||||
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.buffer(true)
|
||||
.parse((response, done) => {
|
||||
let text = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk: string) => (text += chunk));
|
||||
response.on('end', () => done(null, text));
|
||||
});
|
||||
expect(/<type>([^<]+)<\/type>/.exec(String(res.body))?.[1]).toBe('backcountry_skiing');
|
||||
|
||||
const copy = await request(server)
|
||||
.post(`/api/trips/5/tours/${placeId}/copy`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ name: 'Again' });
|
||||
expect(copy.status).toBe(201);
|
||||
expect(copy.body.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'touring', ski_technique: null });
|
||||
});
|
||||
});
|
||||
@@ -1021,7 +1021,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
[
|
||||
{ tour_type: 'kayak', water_context: 'lake' },
|
||||
{ tour_type: 'hike', boat_type: 'sup' },
|
||||
{ tour_type: 'ski' },
|
||||
{ tour_type: 'food' },
|
||||
].map((fields) => importGpx(OWNER, TRACK_GPX, 5, fields)),
|
||||
);
|
||||
for (const res of refused) {
|
||||
|
||||
@@ -68,6 +68,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as',
|
||||
'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity',
|
||||
'Migration20200101045510_a_tour_can_be_a_run',
|
||||
'Migration20200101045520_a_tour_can_be_a_ski_track',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -106,10 +106,10 @@ describe('ToursRepository reads', () => {
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
},
|
||||
{
|
||||
place_id: older.id,
|
||||
@@ -143,10 +143,10 @@ describe('ToursRepository reads', () => {
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
},
|
||||
]);
|
||||
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
|
||||
@@ -263,6 +263,8 @@ describe('ToursRepository writes', () => {
|
||||
winter_activity: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,6 +274,8 @@ describe('ToursRepository writes', () => {
|
||||
bike_profile: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
@@ -333,6 +337,33 @@ describe('ToursRepository writes', () => {
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null });
|
||||
});
|
||||
|
||||
it('TOURREPO-018: a ski track keeps its classification, is classified again only in its own trip, and a route edit takes it off (SKI-02)', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const place = createPlace(testDb, trip.id, { name: 'Valley loop' });
|
||||
const ski = { ...METRICS, tour_type: 'ski', max_hiking_difficulty: null, duration: null };
|
||||
await repo.insertTour({ place_id: place.id, ...ski, ski_discipline: 'cross_country', ski_technique: 'classic' });
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: 'classic',
|
||||
duration: null,
|
||||
});
|
||||
|
||||
const touring = { ski_discipline: 'touring' as const, ski_technique: null };
|
||||
expect(await repo.updateSkiInTrip(other.id, place.id, touring)).toBe(false);
|
||||
expect((await repo.findInTrip(trip.id, place.id))?.ski_discipline).toBe('cross_country');
|
||||
expect(await repo.updateSkiInTrip(trip.id, place.id, touring)).toBe(true);
|
||||
expect((await repo.listForTrip(trip.id))[0]).toMatchObject({
|
||||
ski_discipline: 'touring',
|
||||
ski_technique: null,
|
||||
distance: METRICS.distance,
|
||||
elevation_gain: METRICS.elevation_gain,
|
||||
});
|
||||
|
||||
expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ ski_discipline: null, ski_technique: null });
|
||||
});
|
||||
|
||||
it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const source = createPlace(testDb, trip.id);
|
||||
@@ -379,10 +410,10 @@ describe('ToursRepository writes', () => {
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
taken_over_from_trip_title: null,
|
||||
taken_over_from_tour_name: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* SKI-02: `Migration20200101045520_a_tour_can_be_a_ski_track` switches the ski
|
||||
* type on, without a routing profile, and gives a tour its ski discipline and
|
||||
* cross-country technique. Both columns hold a CHECK list the entity carries
|
||||
* as its enum and the contract holds too: a value one side takes and the
|
||||
* other refuses would be a ski track the import accepts and the database
|
||||
* cannot store.
|
||||
*/
|
||||
import { ToursSkiDiscipline, ToursSkiTechnique } from '../../../src/db/entities/Tours.entity';
|
||||
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
|
||||
import type { MikroORM } from '@mikro-orm/sqlite';
|
||||
import { SKI_DISCIPLINES, SKI_TECHNIQUES } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const TARGET = 'Migration20200101045520_a_tour_can_be_a_ski_track';
|
||||
|
||||
async function ormBeforeTarget(): Promise<MikroORM> {
|
||||
const orm = await createMigrationOrm();
|
||||
const names = await pendingNames(orm);
|
||||
const idx = names.indexOf(TARGET);
|
||||
expect(idx).toBeGreaterThan(0);
|
||||
await migrateTo(orm, names[idx - 1]);
|
||||
return orm;
|
||||
}
|
||||
|
||||
describe('ski columns match the contract', () => {
|
||||
it('TOURSKIMIG-001: stores exactly the disciplines and techniques the contract knows', () => {
|
||||
expect([...Object.values(ToursSkiDiscipline)].sort()).toEqual([...SKI_DISCIPLINES].sort());
|
||||
expect([...Object.values(ToursSkiTechnique)].sort()).toEqual([...SKI_TECHNIQUES].sort());
|
||||
});
|
||||
|
||||
it('TOURSKIMIG-002: switches the ski type on without a router, leaves every tour as it was and refuses an unknown value', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')",
|
||||
);
|
||||
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk'), (101, 10, 'Up to the hut')",
|
||||
);
|
||||
await rawExec(orm, "INSERT INTO tours (place_id, tour_type, distance) VALUES (100, 'hike', 12.5)");
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(await rawQuery(orm, "SELECT enabled, routing_profile FROM tour_types WHERE key = 'ski'")).toEqual([
|
||||
{ enabled: 1, routing_profile: null },
|
||||
]);
|
||||
expect(await rawQuery(orm, 'SELECT ski_discipline, ski_technique FROM tours')).toEqual([
|
||||
{ ski_discipline: null, ski_technique: null },
|
||||
]);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tours (place_id, tour_type, ski_discipline, ski_technique) VALUES (101, 'ski', 'cross_country', 'unknown')",
|
||||
);
|
||||
await expect(rawExec(orm, "UPDATE tours SET ski_discipline = 'freeride' WHERE place_id = 101")).rejects.toThrow(
|
||||
/CHECK/,
|
||||
);
|
||||
await expect(rawExec(orm, "UPDATE tours SET ski_technique = 'diagonal' WHERE place_id = 101")).rejects.toThrow(
|
||||
/CHECK/,
|
||||
);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-005
|
||||
|
||||
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01)', () => {
|
||||
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02)', () => {
|
||||
it('TOUR-GPX-ACTIVITY-001: a bike tour names its profile, and the plainest riding without a known one', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: 'mtb' })).toBe('mountain_biking');
|
||||
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: null })).toBe('cycling');
|
||||
@@ -35,4 +35,13 @@ describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01)', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'sprint' })).toBe('running');
|
||||
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'trail' })).toBe('trail_running');
|
||||
});
|
||||
|
||||
it('TOUR-GPX-ACTIVITY-006: a ski track names its discipline, and skiing without a known one (SKI-02)', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'alpine' })).toBe('alpine_skiing');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'snowboard' })).toBe('snowboarding');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'cross_country' })).toBe('cross_country_skiing');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'touring' })).toBe('backcountry_skiing');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: null })).toBe('skiing');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'freeride' })).toBe('skiing');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* TourSkiController (SKI-02): the route metadata of classifying a ski track
|
||||
* again, and its delegation to TourSkiService with the validated body and the
|
||||
* socket id.
|
||||
*/
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
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 { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import { TourSkiController } from '../../../src/nest/tours/tour-ski.controller';
|
||||
import type { TourSkiService } from '../../../src/nest/tours/tour-ski.service';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { tourDetailResponseSchema, type TourDetailResponse } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('TourSkiController', () => {
|
||||
it('TOUR-SKI-CTL-001: sits behind the addon guard before auth and trip access, and needs place_edit', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, TourSkiController)).toBe('api/trips/:tripId/tours/:placeId/ski');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, TourSkiController)).toEqual([
|
||||
AddonGuard,
|
||||
JwtAuthGuard,
|
||||
TripAccessGuard,
|
||||
]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, TourSkiController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
||||
const handler = TourSkiController.prototype.classify;
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.PUT);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('/');
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBe('place_edit');
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(tourDetailResponseSchema);
|
||||
});
|
||||
|
||||
it('TOUR-SKI-CTL-002: hands the trip, tour, classification and socket id to the service and answers what it answers', async () => {
|
||||
const answer = { tour: { place_id: 9 }, waypoints: [] } as unknown as TourDetailResponse;
|
||||
const service = { classify: vi.fn(async () => answer) };
|
||||
const controller = new TourSkiController(service as unknown as TourSkiService);
|
||||
const body = { ski_discipline: 'cross_country' as const, ski_technique: 'skating' as const, expected_revision: 2 };
|
||||
|
||||
expect(await controller.classify('7', '9', body, 'sock-1')).toBe(answer);
|
||||
expect(service.classify).toHaveBeenCalledExactlyOnceWith('7', '9', body, 'sock-1');
|
||||
});
|
||||
});
|
||||
@@ -411,4 +411,30 @@ describe('ToursMcp', () => {
|
||||
expect(description).toContain('trail for forest tracks and natural paths up to mountain paths (T2)');
|
||||
expect(description).toContain('never a statement that a way can be run');
|
||||
});
|
||||
|
||||
it('TOURS-MCP-018: list_tours names a ski track with its discipline and technique, and says it is kept as recorded (SKI-02)', async () => {
|
||||
const { user, trip, ridge, gorge } = fixture();
|
||||
const orm = await createTestOrm(db);
|
||||
await updateRows(
|
||||
orm,
|
||||
Tours,
|
||||
{ place: ridge.id },
|
||||
{ tourTypeRef: 'ski', max_hiking_difficulty: null, ski_discipline: 'cross_country', ski_technique: 'skating' },
|
||||
);
|
||||
await orm.close();
|
||||
const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
|
||||
const byId = new Map(tours.map((tour: { place_id: number }) => [tour.place_id, tour]));
|
||||
expect(byId.get(ridge.id)).toMatchObject({
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: 'skating',
|
||||
});
|
||||
expect(byId.get(gorge.id)).toMatchObject({ ski_discipline: null, ski_technique: null });
|
||||
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
|
||||
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
|
||||
expect(description).toMatch(/ski_discipline: alpine, snowboard, cross_country or touring/);
|
||||
expect(description).toContain('never routed');
|
||||
expect(description).toContain('it has no ski time worked out');
|
||||
expect(description).toContain('nothing in it says anything about snow, avalanches');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +195,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
bike_profile: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
});
|
||||
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(request.waypoints));
|
||||
@@ -315,6 +317,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
bike_profile: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
});
|
||||
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(update.waypoints));
|
||||
|
||||
Reference in New Issue
Block a user