mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(tours): a hike can be marked as a winter hike or a snowshoe hike
HIK-01. The details of the hiking planner gain an optional Winter activity switch, off by default, so the normal hiking flow asks for nothing more. Switched on, the hike is a winter hike or a snowshoe hike; the choice stays dimmed and out of reach while the switch is off. The label is saved with the tour, comes back when it is opened, survives a draft reload, a copy and a take-over, and switching it off takes only the label away: the route, its grade and the days stay as they were. The label is a label only. Routing, the walking time and the 3D terrain view work as for any hike, and the info dot and the head pill's tooltip say that nothing was checked for snow, avalanches or whether a path can be walked in winter. The saved label shows as a frosted snowflake on the corner of the tour's tile in the tour list and the day plan, and as a pill beside the type in the tour's detail. The server keeps it in tours.winter_activity (CHECK list, NULL for every tour saved before and for every other type), refuses it on a bike tour, a city walk and a paddle tour, and keeps the stored label when an older client saves a hike without the field. A snowshoe hike exports to GPX as snowshoeing. list_tours names the field, with the same words on what it does not say.
This commit is contained in:
@@ -39,6 +39,7 @@ export class Tours {
|
||||
taken_over_from_trip_title?: string | null;
|
||||
taken_over_from_tour_name?: string | null;
|
||||
bike_profile?: TToursBikeProfile | null;
|
||||
winter_activity?: TToursWinterActivity | null;
|
||||
tourTypeRef!: Ref<TourTypes>;
|
||||
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
|
||||
tour_sources: Ref<TourSources> | null = null;
|
||||
@@ -85,6 +86,13 @@ export const ToursBikeProfile = {
|
||||
|
||||
export type TToursBikeProfile = (typeof ToursBikeProfile)[keyof typeof ToursBikeProfile];
|
||||
|
||||
export const ToursWinterActivity = {
|
||||
WINTER_HIKE: 'winter_hike',
|
||||
SNOWSHOE: 'snowshoe',
|
||||
} as const;
|
||||
|
||||
export type TToursWinterActivity = (typeof ToursWinterActivity)[keyof typeof ToursWinterActivity];
|
||||
|
||||
export const ToursSchema = defineEntity({
|
||||
class: Tours,
|
||||
repository: () => ToursRepository,
|
||||
@@ -127,6 +135,7 @@ export const ToursSchema = defineEntity({
|
||||
taken_over_from_trip_title: p.text().nullable(),
|
||||
taken_over_from_tour_name: p.text().nullable(),
|
||||
bike_profile: p.enum(() => ToursBikeProfile).nullable(),
|
||||
winter_activity: p.enum(() => ToursWinterActivity).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(),
|
||||
|
||||
@@ -31,4 +31,5 @@ export interface ToursTable {
|
||||
taken_over_from_trip_title: InsertOptional<string | null>;
|
||||
taken_over_from_tour_name: InsertOptional<string | null>;
|
||||
bike_profile: InsertOptional<string | null>;
|
||||
winter_activity: InsertOptional<string | null>;
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* HIK-01: a hike can carry a winter label, winter hiking or snowshoeing. It
|
||||
* is a column of the tour, NULL on every tour saved before and on every other
|
||||
* type, so no existing tour changes, and nothing routes or times by it.
|
||||
*/
|
||||
export class Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity extends Migration {
|
||||
override name = 'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(
|
||||
"ALTER TABLE tours ADD COLUMN winter_activity TEXT CHECK (winter_activity IS NULL OR winter_activity IN ('winter_hike', 'snowshoe'))",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en
|
||||
import type { DB } from '../kysely/db';
|
||||
import type { AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared';
|
||||
import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext, WinterActivity } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* A tour as the Tours list and detail read it: the `tours` facet joined with
|
||||
@@ -30,6 +30,8 @@ export interface TourListRow {
|
||||
origin: string;
|
||||
/** BIK-01: a bike tour's profile; NULL on every other type. */
|
||||
bike_profile: string | null;
|
||||
/** HIK-01: a hike's winter label; NULL for none and on every other type. */
|
||||
winter_activity: string | null;
|
||||
boat_type: string | null;
|
||||
water_context: string | null;
|
||||
paddle_speed_kmh: number | null;
|
||||
@@ -97,6 +99,7 @@ export interface TourRow {
|
||||
taken_over_from_trip_title: string | null;
|
||||
taken_over_from_tour_name: string | null;
|
||||
bike_profile: string | null;
|
||||
winter_activity: string | null;
|
||||
}
|
||||
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
|
||||
|
||||
@@ -114,6 +117,8 @@ export interface TourInsert {
|
||||
max_hiking_difficulty: number | null;
|
||||
/** BIK-01: a bike tour's profile; left out, NULL. */
|
||||
bike_profile?: BikeProfile | null;
|
||||
/** HIK-01: a hike's winter label; left out, NULL on a new tour and kept as it is by an edit. */
|
||||
winter_activity?: WinterActivity | null;
|
||||
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
|
||||
boat_type?: PaddleBoatType | null;
|
||||
water_context?: PaddleWaterContext | null;
|
||||
@@ -242,6 +247,7 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
't.derived_from_place_id as derived_from_place_id',
|
||||
't.origin as origin',
|
||||
't.bike_profile as bike_profile',
|
||||
't.winter_activity as winter_activity',
|
||||
't.boat_type as boat_type',
|
||||
't.water_context as water_context',
|
||||
't.paddle_speed_kmh as paddle_speed_kmh',
|
||||
@@ -607,6 +613,7 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
match_confidence: input.match_confidence,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
bike_profile: input.bike_profile ?? null,
|
||||
winter_activity: input.winter_activity ?? null,
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
@@ -698,6 +705,7 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
portage_speed_kmh: input.portage_speed_kmh,
|
||||
paddle_lake_style: input.paddle_lake_style,
|
||||
...(input.hiking_preferences !== undefined ? { hiking_preferences: input.hiking_preferences } : {}),
|
||||
...(input.winter_activity !== undefined ? { winter_activity: input.winter_activity } : {}),
|
||||
})
|
||||
.where('place_id', '=', place_id)
|
||||
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
|
||||
|
||||
@@ -16,14 +16,21 @@ const BIKE_ACTIVITY: Record<BikeProfile, string> = {
|
||||
mtb: 'mountain_biking',
|
||||
};
|
||||
|
||||
/** The activity a tour's track names in GPX: a paddle tour its boat, a bike tour its profile, a hike hiking; whatever is missing falls back to the plainest. */
|
||||
/**
|
||||
* The activity a tour's track names in GPX: a paddle tour its boat, a bike
|
||||
* tour its profile, 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;
|
||||
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;
|
||||
return type === 'hike' ? 'hiking' : type;
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes, city walks, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, 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 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, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, 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 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),
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
type TourProvenanceSource,
|
||||
type TourWaypoint,
|
||||
type WandererDifficulty,
|
||||
type WinterActivity,
|
||||
walkStationFacts,
|
||||
walkStationsOf,
|
||||
wandererDifficultyToSac,
|
||||
@@ -103,6 +104,15 @@ function hikingFields(input: TourCreateCommand): string | null {
|
||||
return preferences ? encodeJson(TOUR_HIKING_PREFERENCES, preferences) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HIK-01: a save's winter label as stored. Only a hike keeps one; another type
|
||||
* takes it off. A hike's save that leaves the field out (a client from before
|
||||
* the label) keeps the one the tour has, rather than taking it off unasked.
|
||||
*/
|
||||
function winterField(input: TourCreateCommand): WinterActivity | null | undefined {
|
||||
return input.tour_type === 'hike' ? input.winter_activity : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03: every point with its key, kept from the write where it came with one,
|
||||
* so the visits of a city walk's stations outlast a reorder or a new point. A
|
||||
@@ -155,6 +165,7 @@ export class ToursService {
|
||||
derived_from_place_id: r.derived_from_place_id,
|
||||
origin: r.origin as TourOrigin,
|
||||
bike_profile: r.bike_profile as BikeProfile | null,
|
||||
winter_activity: r.winter_activity as WinterActivity | null,
|
||||
boat_type: r.boat_type as PaddleBoatType | null,
|
||||
water_context: r.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: r.paddle_speed_kmh,
|
||||
@@ -196,6 +207,7 @@ export class ToursService {
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null,
|
||||
winter_activity: winterField(input),
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
@@ -601,6 +613,7 @@ export class ToursService {
|
||||
match_confidence: row.match_confidence,
|
||||
max_hiking_difficulty: row.max_hiking_difficulty,
|
||||
bike_profile: row.bike_profile as BikeProfile | null,
|
||||
winter_activity: row.winter_activity as WinterActivity | null,
|
||||
boat_type: row.boat_type as PaddleBoatType | null,
|
||||
water_context: row.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: row.paddle_speed_kmh,
|
||||
@@ -843,6 +856,7 @@ export class ToursService {
|
||||
route_missing: 0,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
winter_activity: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Tours e2e for the winter label of a hike (HIK-01): saved, listed, kept by an
|
||||
* edit that leaves it out, taken off without touching the route or the days,
|
||||
* refused on any other type, copied and exported, 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-bike.e2e.test.ts.
|
||||
*
|
||||
* Fixtures: user 1 owns trip 5, 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;
|
||||
|
||||
const GEOMETRY = [
|
||||
[47, 11, 1200],
|
||||
[47.01, 11.01, 1350],
|
||||
[47.02, 11.02, 1300],
|
||||
];
|
||||
const hikeBody = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'Snowy ridge',
|
||||
tour_type: 'hike',
|
||||
max_hiking_difficulty: 2,
|
||||
winter_activity: 'snowshoe',
|
||||
route_geometry: GEOMETRY,
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
duration_seconds: 5400,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Winter hikes 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() })
|
||||
// No network in a test: the trail grades read OpenStreetMap through Overpass.
|
||||
.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 send = (method: 'post' | 'put', path: string, body: Record<string, unknown>, userId = OWNER) =>
|
||||
request(server)[method](`/api/trips/5/tours${path}`).set('Cookie', sessionCookie(userId)).send(body);
|
||||
|
||||
const createViaApi = async (body: Record<string, unknown> = hikeBody()) => {
|
||||
const res = await send('post', '', body);
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.tour.place_id as number;
|
||||
};
|
||||
|
||||
const gpxTypeOf = async (placeId: number) => {
|
||||
const res = await request(server)
|
||||
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.buffer(true)
|
||||
.parse((res, done) => {
|
||||
let text = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk: string) => (text += chunk));
|
||||
res.on('end', () => done(null, text));
|
||||
});
|
||||
return /<type>([^<]+)<\/type>/.exec(String(res.body))?.[1] ?? '';
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
// Pinned ids: the session cookies sign for exactly these users.
|
||||
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
|
||||
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
|
||||
await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Winter week' }]);
|
||||
await addTripMember(orm, 5, 2);
|
||||
await insertRows(orm, Days, [{ id: 3, trip: 5, day_number: 1, date: '2027-01-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-WINTER-E2E-001: saves a hike with its winter label, and lists and opens it so', async () => {
|
||||
const placeId = await createViaApi();
|
||||
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
|
||||
tour_type: 'hike',
|
||||
winter_activity: 'snowshoe',
|
||||
max_hiking_difficulty: 2,
|
||||
});
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.tours[0]).toMatchObject({ place_id: placeId, winter_activity: 'snowshoe' });
|
||||
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body.tour).toMatchObject({ tour_type: 'hike', winter_activity: 'snowshoe' });
|
||||
});
|
||||
|
||||
it('TOURS-WINTER-E2E-002: a hike saved without one carries none, as every hike did before', async () => {
|
||||
const placeId = await createViaApi(hikeBody({ winter_activity: undefined }));
|
||||
expect((await findRow(orm, Tours, { place: placeId }))?.winter_activity).toBeNull();
|
||||
});
|
||||
|
||||
it('TOURS-WINTER-E2E-003: an edit leaving it out keeps it; taking it off keeps the route and the days', async () => {
|
||||
const placeId = await createViaApi();
|
||||
await insertRows(orm, DayAssignments, [{ day: 3, place: placeId, order_index: 0 }]);
|
||||
|
||||
const kept = await send('put', `/${placeId}`, hikeBody({ winter_activity: undefined, expected_revision: 1 }));
|
||||
expect(kept.status).toBe(200);
|
||||
expect(kept.body.tour).toMatchObject({ winter_activity: 'snowshoe', revision: 2 });
|
||||
|
||||
const off = await send('put', `/${placeId}`, hikeBody({ winter_activity: null, expected_revision: 2 }));
|
||||
expect(off.status).toBe(200);
|
||||
expect(off.body.tour).toMatchObject({ winter_activity: null, tour_type: 'hike', planned: true });
|
||||
expect(off.body.waypoints).toHaveLength(2);
|
||||
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
|
||||
expect((await findRow(orm, Places, { id: placeId }))?.route_geometry).toBe(JSON.stringify(GEOMETRY));
|
||||
});
|
||||
|
||||
it('TOURS-WINTER-E2E-004: refuses a label it does not know, and one on another type', async () => {
|
||||
const refused = await Promise.all(
|
||||
[
|
||||
hikeBody({ winter_activity: 'ski_tour' }),
|
||||
hikeBody({ tour_type: 'bike', bike_profile: 'mtb', max_hiking_difficulty: undefined }),
|
||||
hikeBody({ tour_type: 'walk', max_hiking_difficulty: undefined, winter_activity: 'winter_hike' }),
|
||||
].map((body) => send('post', '', body)),
|
||||
);
|
||||
expect(refused.map((res) => res.status)).toEqual([400, 400, 400]);
|
||||
expect(await countRows(orm, Tours)).toBe(0);
|
||||
});
|
||||
|
||||
it('TOURS-WINTER-E2E-005: a hike made into a bike tour loses its label', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const bike = await send(
|
||||
'put',
|
||||
`/${placeId}`,
|
||||
hikeBody({
|
||||
tour_type: 'bike',
|
||||
bike_profile: 'touring',
|
||||
max_hiking_difficulty: undefined,
|
||||
winter_activity: undefined,
|
||||
}),
|
||||
);
|
||||
expect(bike.status).toBe(200);
|
||||
expect(bike.body.tour).toMatchObject({ tour_type: 'bike', winter_activity: null });
|
||||
});
|
||||
|
||||
it('TOURS-WINTER-E2E-006: a member who may only read cannot save one', async () => {
|
||||
expect((await send('post', '', hikeBody(), VIEWER)).status).toBe(403);
|
||||
});
|
||||
|
||||
it('TOURS-WINTER-E2E-007: a copy keeps the label', async () => {
|
||||
const placeId = await createViaApi(hikeBody({ winter_activity: 'winter_hike' }));
|
||||
const copy = await send('post', `/${placeId}/copy`, { name: 'Again' });
|
||||
expect(copy.status).toBe(201);
|
||||
expect(copy.body.tour).toMatchObject({ tour_type: 'hike', winter_activity: 'winter_hike' });
|
||||
});
|
||||
|
||||
it('TOURS-WINTER-E2E-008: exports a snowshoe hike as snowshoeing, a winter hike as hiking', async () => {
|
||||
expect(await gpxTypeOf(await createViaApi())).toBe('snowshoeing');
|
||||
expect(await gpxTypeOf(await createViaApi(hikeBody({ winter_activity: 'winter_hike' })))).toBe('hiking');
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101045200_a_tour_can_be_ridden',
|
||||
'Migration20200101045300_a_tour_can_be_a_city_walk',
|
||||
'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as',
|
||||
'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -105,6 +105,7 @@ describe('ToursRepository reads', () => {
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
},
|
||||
{
|
||||
place_id: older.id,
|
||||
@@ -137,6 +138,7 @@ describe('ToursRepository reads', () => {
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
},
|
||||
]);
|
||||
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
|
||||
@@ -250,6 +252,7 @@ describe('ToursRepository writes', () => {
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,6 +290,22 @@ describe('ToursRepository writes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('TOURREPO-016: a hike keeps its winter label through an edit that leaves it out, and loses it only when told (HIK-01)', async () => {
|
||||
const { trip } = fixture();
|
||||
const place = createPlace(testDb, trip.id, { name: 'Snowy ridge' });
|
||||
await repo.insertTour({ place_id: place.id, ...METRICS, winter_activity: 'snowshoe' });
|
||||
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBe('snowshoe');
|
||||
|
||||
expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true);
|
||||
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBe('snowshoe');
|
||||
|
||||
expect(await repo.updateInTrip(trip.id, place.id, { ...HIKE_UPDATE, winter_activity: 'winter_hike' })).toBe(true);
|
||||
expect((await repo.listForTrip(trip.id))[0]?.winter_activity).toBe('winter_hike');
|
||||
|
||||
expect(await repo.updateInTrip(trip.id, place.id, { ...HIKE_UPDATE, winter_activity: null })).toBe(true);
|
||||
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -332,6 +351,7 @@ describe('ToursRepository writes', () => {
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
taken_over_from_trip_title: null,
|
||||
taken_over_from_tour_name: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ToursWinterActivity } from '../../../src/db/entities/Tours.entity';
|
||||
import { WINTER_ACTIVITIES } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* HIK-01: the winter label column holds a CHECK list (migration 045500) that
|
||||
* the entity carries as its enum, and the contract holds its own list. A label
|
||||
* one side knows and the other refuses would be a hike the planner can save
|
||||
* and the database cannot store.
|
||||
*/
|
||||
describe('winter activity column matches the contract', () => {
|
||||
it('knows the same winter activities as the tours contract', () => {
|
||||
expect(Object.values(ToursWinterActivity).sort()).toEqual([...WINTER_ACTIVITIES].sort());
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,9 @@ import { gpxActivityOf } from '../../../src/nest/tours/tour-gpx-export.helpers';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-003
|
||||
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-004
|
||||
|
||||
describe('gpxActivityOf (PR1-06A, BIK-01)', () => {
|
||||
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01)', () => {
|
||||
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');
|
||||
@@ -21,4 +21,11 @@ describe('gpxActivityOf (PR1-06A, BIK-01)', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'hike' })).toBe('hiking');
|
||||
expect(gpxActivityOf({ tour_type: 'walk' })).toBe('walk');
|
||||
});
|
||||
|
||||
it('TOUR-GPX-ACTIVITY-004: a hike on snowshoes is snowshoeing, a winter hike stays hiking (HIK-01)', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: 'snowshoe' })).toBe('snowshoeing');
|
||||
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: 'winter_hike' })).toBe('hiking');
|
||||
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: null })).toBe('hiking');
|
||||
expect(gpxActivityOf({ tour_type: 'walk', winter_activity: 'snowshoe' })).toBe('walk');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
|
||||
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { PlacesMcp } from '../../../src/nest/places/places.mcp';
|
||||
@@ -355,4 +356,19 @@ describe('ToursMcp', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('TOURS-MCP-015: list_tours names the winter label of a hike, and says it is only a label (HIK-01)', async () => {
|
||||
const { user, trip, ridge, gorge } = fixture();
|
||||
const orm = await createTestOrm(db);
|
||||
await updateRows(orm, Tours, { place: ridge.id }, { winter_activity: 'snowshoe' });
|
||||
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: 'hike', winter_activity: 'snowshoe' });
|
||||
expect(byId.get(gorge.id)).toMatchObject({ winter_activity: null });
|
||||
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
|
||||
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
|
||||
expect(description).toMatch(/winter_activity: winter_hike or snowshoe/);
|
||||
expect(description).toContain('never a statement that a way can be walked in winter');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user