mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(tours): one tour type catalogue, and no column nothing writes
tour_types now holds every key the shared contract accepts, all but hike disabled, and create and edit turn a disabled type away with a 400 instead of failing on the foreign key. tours.tour_group_id is dropped: nothing ever wrote or read it. difficulty and wanderer_ref stay for the Wanderer import.
This commit is contained in:
@@ -35,7 +35,6 @@ const tour: TourListItem = {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
|
||||
@@ -27,7 +27,6 @@ const tour: TourListItem = {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
|
||||
@@ -25,7 +25,6 @@ const tour: TourListItem = {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
|
||||
@@ -40,7 +40,6 @@ const savedTour = {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden
|
||||
const tour: TourListItem = {
|
||||
place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 4,
|
||||
elevation_gain: 100, elevation_loss: 80, duration: 60, difficulty: null,
|
||||
wanderer_ref: null, match_confidence: 1, tour_group_id: null,
|
||||
wanderer_ref: null, match_confidence: 1,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock('../../store/tripStore', () => ({
|
||||
const tour: TourListItem = {
|
||||
place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 4,
|
||||
elevation_gain: 100, elevation_loss: 80, duration: 60, difficulty: null,
|
||||
wanderer_ref: null, match_confidence: 1, tour_group_id: null,
|
||||
wanderer_ref: null, match_confidence: 1,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
const days = [{ id: 7, trip_id: 1, day_number: 1, title: 'Summit day', date: '2026-05-15' } as Day]
|
||||
|
||||
@@ -19,7 +19,6 @@ const tour: TourListItem = {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
|
||||
@@ -46,7 +46,6 @@ const baseTour: TourListItem = {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
|
||||
@@ -19,7 +19,6 @@ const tour: TourListItem = {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
has_waypoints: true,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
|
||||
@@ -28,7 +28,6 @@ const tour: TourListItem = {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
|
||||
@@ -546,7 +546,6 @@ describe('TripPlannerPage — shell', () => {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
@@ -600,7 +599,6 @@ describe('TripPlannerPage — shell', () => {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
@@ -735,7 +733,6 @@ describe('TripPlannerPage — shell', () => {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
@@ -762,7 +759,6 @@ describe('TripPlannerPage — shell', () => {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
@@ -866,7 +862,6 @@ describe('TripPlannerPage — shell', () => {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: false,
|
||||
|
||||
@@ -667,7 +667,7 @@ describe('useTripPlanner — map derivations', () => {
|
||||
const tour: TourListItem = {
|
||||
place_id: 7, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
|
||||
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
|
||||
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [{ id: 'tours' }] })
|
||||
@@ -1439,7 +1439,6 @@ describe('useTripPlanner — place CRUD', () => {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
@@ -1638,7 +1637,7 @@ describe('useTripPlanner — day plan CRUD', () => {
|
||||
const tour: TourListItem = {
|
||||
place_id: 1, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
|
||||
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
|
||||
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 })
|
||||
const ordinaryPlace = buildPlace({ id: 2, name: 'Ordinary place' })
|
||||
@@ -1669,7 +1668,7 @@ describe('useTripPlanner — day plan CRUD', () => {
|
||||
const unplanned: TourListItem = {
|
||||
place_id: 1, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
|
||||
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
|
||||
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
const planned = { ...unplanned, planned: true }
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
|
||||
@@ -1707,7 +1706,7 @@ describe('useTripPlanner — day plan CRUD', () => {
|
||||
const unplanned: TourListItem = {
|
||||
place_id: 1, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
|
||||
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
|
||||
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
|
||||
const listTours = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [unplanned] })
|
||||
@@ -1729,7 +1728,7 @@ describe('useTripPlanner — day plan CRUD', () => {
|
||||
const unplanned: TourListItem = {
|
||||
place_id: 1, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
|
||||
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
|
||||
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
const planned: TourListItem = { ...unplanned, planned: true }
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
|
||||
|
||||
@@ -366,7 +366,7 @@ describe('MPlacesBrowser', () => {
|
||||
const ridge: TourListItem = {
|
||||
place_id: TOUR.id, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
|
||||
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
|
||||
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
seedStore(useAddonStore, { addons: [{ id: 'collections', enabled: true }, { id: 'tours', enabled: true }] })
|
||||
const list = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [ridge] })
|
||||
|
||||
@@ -440,7 +440,7 @@ describe('MTripSheets', () => {
|
||||
const mobileTour = {
|
||||
place_id: 42, name: 'Mobile ridge', tour_type: 'hike', distance: 1,
|
||||
elevation_gain: 10, elevation_loss: 0, duration: null, difficulty: null,
|
||||
wanderer_ref: null, match_confidence: 1, tour_group_id: null,
|
||||
wanderer_ref: null, match_confidence: 1,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
} as TourListItem
|
||||
const readOnlyFile = {
|
||||
|
||||
@@ -17,7 +17,6 @@ export class Tours {
|
||||
difficulty?: string | null;
|
||||
wanderer_ref?: string | null;
|
||||
match_confidence?: number | null;
|
||||
tour_group_id?: number | null;
|
||||
created_at?: string | null;
|
||||
max_hiking_difficulty: number & Opt = 2;
|
||||
tourTypeRef!: Ref<TourTypes>;
|
||||
@@ -38,7 +37,6 @@ export const ToursSchema = defineEntity({
|
||||
difficulty: p.text().nullable(),
|
||||
wanderer_ref: p.text().nullable(),
|
||||
match_confidence: p.double().nullable(),
|
||||
tour_group_id: p.integer().nullable(),
|
||||
created_at: p.text().nullable().defaultRaw(`CURRENT_TIMESTAMP`),
|
||||
max_hiking_difficulty: p.integer().default(2),
|
||||
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
import { columnNames } from '../migration-utils';
|
||||
|
||||
/**
|
||||
* `tour_types` holds every key the shared `tourTypeKeySchema` accepts. Only
|
||||
* `hike` was seeded, so a tour of any other type the contract allows failed
|
||||
* on the foreign key with a 500 instead of a clean refusal. The six others
|
||||
* are written disabled: the planner only offers `hike` today, and the
|
||||
* service turns a disabled type away with a 400.
|
||||
*
|
||||
* `tours.tour_group_id` goes. Nothing ever wrote or read it, and a column
|
||||
* nothing writes lets every copy and export carry a meaning nobody defined.
|
||||
* `difficulty` and `wanderer_ref` stay: the Wanderer import fills them.
|
||||
*/
|
||||
export class Migration20200101042300_every_tour_type_the_contract_names extends Migration {
|
||||
override name = 'Migration20200101042300_every_tour_type_the_contract_names';
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`
|
||||
INSERT OR IGNORE INTO tour_types (key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order) VALUES
|
||||
('bike', 'tourTypes.bike', 'Bike', '#2563eb', 'bicycle', 1, 0, 1),
|
||||
('run', 'tourTypes.run', 'Footprints', '#dc2626', 'pedestrian', 1, 0, 2),
|
||||
('ski', 'tourTypes.ski', 'MountainSnow', '#0891b2', NULL, 1, 0, 3),
|
||||
('kayak', 'tourTypes.kayak', 'Sailboat', '#0d9488', NULL, 1, 0, 4),
|
||||
('walk', 'tourTypes.walk', 'Landmark', '#9333ea', 'pedestrian', 0, 0, 5),
|
||||
('food', 'tourTypes.food', 'UtensilsCrossed', '#ea580c', 'pedestrian', 0, 0, 6)
|
||||
`);
|
||||
|
||||
if ((await columnNames(this, 'tours')).has('tour_group_id')) {
|
||||
this.addSql('ALTER TABLE tours DROP COLUMN tour_group_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,14 @@ import type { TourTypes } from '../entities/TourTypes.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/**
|
||||
* `tour_types` — the controlled vocabulary `tours.tour_type` points at. Only
|
||||
* `hike` exists, written by the migration that creates the table; nothing
|
||||
* reads the catalogue yet, so this repository has no methods of its own.
|
||||
* `tour_types` — the controlled vocabulary `tours.tour_type` points at, one
|
||||
* row per key of the shared `tourTypeKeySchema`. The migrations write it;
|
||||
* `enabled` says which types a tour may be created with.
|
||||
*/
|
||||
export class TourTypesRepository extends TrekRepository<TourTypes> {}
|
||||
export class TourTypesRepository extends TrekRepository<TourTypes> {
|
||||
/** Whether `key` is a known tour type a tour may be created with. */
|
||||
async isEnabled(key: string): Promise<boolean> {
|
||||
const row = await this.findOne({ key }, { fields: ['enabled'] });
|
||||
return row?.enabled === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface TourListRow {
|
||||
difficulty: string | null;
|
||||
wanderer_ref: string | null;
|
||||
match_confidence: number | null;
|
||||
tour_group_id: number | null;
|
||||
max_hiking_difficulty: number;
|
||||
planned: number;
|
||||
has_waypoints: number;
|
||||
@@ -36,7 +35,6 @@ export interface TourRow {
|
||||
difficulty: string | null;
|
||||
wanderer_ref: string | null;
|
||||
match_confidence: number | null;
|
||||
tour_group_id: number | null;
|
||||
created_at: string | null;
|
||||
max_hiking_difficulty: number;
|
||||
}
|
||||
@@ -95,7 +93,6 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
't.difficulty as difficulty',
|
||||
't.wanderer_ref as wanderer_ref',
|
||||
't.match_confidence as match_confidence',
|
||||
't.tour_group_id as tour_group_id',
|
||||
't.max_hiking_difficulty as max_hiking_difficulty',
|
||||
eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id')).as('planned'),
|
||||
eb.exists(eb.selectFrom('tour_waypoints as tw').select('tw.id').whereRef('tw.place_id', '=', 'p.id')).as('has_waypoints'),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TourTypes } from '../../db/entities/TourTypes.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { ToursController } from './tours.controller';
|
||||
import { ToursImportController } from './tours-import.controller';
|
||||
@@ -24,7 +25,7 @@ import { AddonsModule } from '../addons/addons.module';
|
||||
PermissionsModule,
|
||||
AuthModule,
|
||||
AddonsModule,
|
||||
MikroOrmModule.forFeature([Tours, TourWaypoints, Places]),
|
||||
MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, Places]),
|
||||
],
|
||||
controllers: [ToursController, ToursImportController],
|
||||
providers: [ToursService],
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type { TourCreateRequest, TourCreateResponse, TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TourTypes } from '../../db/entities/TourTypes.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { TourListRow, ToursRepository, TourUpdate } from '../../db/repositories/Tours.repository';
|
||||
import type { TourTypesRepository } from '../../db/repositories/TourTypes.repository';
|
||||
import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
@@ -33,6 +35,7 @@ export class ToursService {
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly places: PlacesService,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
@InjectRepository(TourTypes) private readonly tourTypesRepo: TourTypesRepository,
|
||||
@InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
) {}
|
||||
@@ -49,7 +52,6 @@ export class ToursService {
|
||||
difficulty: r.difficulty,
|
||||
wanderer_ref: r.wanderer_ref,
|
||||
match_confidence: r.match_confidence,
|
||||
tour_group_id: r.tour_group_id,
|
||||
max_hiking_difficulty: r.max_hiking_difficulty ?? 2,
|
||||
planned: Boolean(r.planned),
|
||||
caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD,
|
||||
@@ -71,6 +73,15 @@ export class ToursService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `tour_types` knows every key the contract accepts, most of them disabled.
|
||||
* Checked before the write, so a disabled type is a 400 and not a tour the
|
||||
* planner cannot show.
|
||||
*/
|
||||
private async assertTourTypeEnabled(key: string): Promise<void> {
|
||||
if (!(await this.tourTypesRepo.isEnabled(key))) throw new BadRequestException('Tour type is not available');
|
||||
}
|
||||
|
||||
/** All tours (the facet + owning place) for a trip, newest first. */
|
||||
async listTours(tripId: string): Promise<TourListItem[]> {
|
||||
const tid = toRowId(tripId);
|
||||
@@ -111,6 +122,7 @@ export class ToursService {
|
||||
async createTour(tripId: string, input: TourCreateRequest, socketId?: string): Promise<TourCreateResponse> {
|
||||
// The controller's TripAccessGuard already resolved this trip id.
|
||||
const tid = toRowId(tripId)!;
|
||||
await this.assertTourTypeEnabled(input.tour_type);
|
||||
const start = input.route_geometry[0];
|
||||
const fields = this.routeFields(input);
|
||||
|
||||
@@ -144,6 +156,7 @@ export class ToursService {
|
||||
const tid = toRowId(tripId);
|
||||
const pid = toRowId(placeId);
|
||||
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
|
||||
await this.assertTourTypeEnabled(input.tour_type);
|
||||
const start = input.route_geometry[0];
|
||||
const fields = this.routeFields(input);
|
||||
|
||||
@@ -226,7 +239,6 @@ export class ToursService {
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: matchConfidence,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
|
||||
@@ -2,6 +2,8 @@ import type Database from 'better-sqlite3';
|
||||
import { sharedTestOrm } from './test-uow';
|
||||
import { Tours } from '../../src/db/entities/Tours.entity';
|
||||
import type { ToursRepository } from '../../src/db/repositories/Tours.repository';
|
||||
import { TourTypes } from '../../src/db/entities/TourTypes.entity';
|
||||
import type { TourTypesRepository } from '../../src/db/repositories/TourTypes.repository';
|
||||
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
|
||||
import type { TourWaypointsRepository } from '../../src/db/repositories/TourWaypoints.repository';
|
||||
|
||||
@@ -15,6 +17,10 @@ export function createTestToursRepo(db: Database.Database): Promise<ToursReposit
|
||||
return sharedTestOrm(db).then((t) => t.repo(Tours));
|
||||
}
|
||||
|
||||
export function createTestTourTypesRepo(db: Database.Database): Promise<TourTypesRepository> {
|
||||
return sharedTestOrm(db).then((t) => t.repo(TourTypes));
|
||||
}
|
||||
|
||||
export function createTestTourWaypointsRepo(db: Database.Database): Promise<TourWaypointsRepository> {
|
||||
return sharedTestOrm(db).then((t) => t.repo(TourWaypoints));
|
||||
}
|
||||
|
||||
@@ -60,12 +60,12 @@ describe('ToursRepository reads', () => {
|
||||
expect(await repo.listForTrip(trip.id)).toEqual([
|
||||
{
|
||||
place_id: newer.id, name: 'Newer', tour_type: 'hike', distance: null, elevation_gain: null, elevation_loss: null,
|
||||
duration: null, difficulty: null, wanderer_ref: null, match_confidence: null, tour_group_id: null,
|
||||
duration: null, difficulty: null, wanderer_ref: null, match_confidence: null,
|
||||
max_hiking_difficulty: 5, planned: 0, has_waypoints: 1,
|
||||
},
|
||||
{
|
||||
place_id: older.id, name: 'Older', tour_type: 'hike', distance: 5, elevation_gain: null, elevation_loss: null,
|
||||
duration: null, difficulty: null, wanderer_ref: null, match_confidence: 0.3, tour_group_id: null,
|
||||
duration: null, difficulty: null, wanderer_ref: null, match_confidence: 0.3,
|
||||
max_hiking_difficulty: 2, planned: 1, has_waypoints: 0,
|
||||
},
|
||||
]);
|
||||
@@ -128,7 +128,7 @@ describe('ToursRepository writes', () => {
|
||||
await repo.insertTour({ place_id: place.id, ...METRICS });
|
||||
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toEqual({
|
||||
place_id: place.id, name: 'Drawn', ...METRICS, difficulty: null, wanderer_ref: null, tour_group_id: null,
|
||||
place_id: place.id, name: 'Drawn', ...METRICS, difficulty: null, wanderer_ref: null,
|
||||
planned: 0, has_waypoints: 0,
|
||||
});
|
||||
});
|
||||
@@ -149,12 +149,12 @@ describe('ToursRepository writes', () => {
|
||||
const { trip, other } = fixture();
|
||||
const source = createPlace(testDb, trip.id);
|
||||
createTour(testDb, source.id, { created_at: '2026-03-04 05:06:07', distance: 3.5, max_hiking_difficulty: 4 });
|
||||
testDb.prepare("UPDATE tours SET difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 9 WHERE place_id = ?").run(source.id);
|
||||
testDb.prepare("UPDATE tours SET difficulty = 'T3', wanderer_ref = 'w-1' WHERE place_id = ?").run(source.id);
|
||||
|
||||
const rows = await repo.listRowsForTrip(trip.id);
|
||||
expect(rows).toEqual([{
|
||||
place_id: source.id, tour_type: 'hike', distance: 3.5, elevation_gain: null, elevation_loss: null, duration: null,
|
||||
difficulty: 'T3', wanderer_ref: 'w-1', match_confidence: null, tour_group_id: 9, created_at: '2026-03-04 05:06:07',
|
||||
difficulty: 'T3', wanderer_ref: 'w-1', match_confidence: null, created_at: '2026-03-04 05:06:07',
|
||||
max_hiking_difficulty: 4,
|
||||
}]);
|
||||
expect(await repo.listRowsForTrip(other.id)).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* `Migration20200101042300_every_tour_type_the_contract_names`: `tour_types`
|
||||
* gets a row for every key the shared contract accepts, and the never-written
|
||||
* `tours.tour_group_id` column goes without touching the rows around it.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { tourTypeKeySchema } from '@trek/shared';
|
||||
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
|
||||
import type { MikroORM } from '@mikro-orm/sqlite';
|
||||
|
||||
const TARGET = 'Migration20200101042300_every_tour_type_the_contract_names';
|
||||
|
||||
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('Tour types migration', () => {
|
||||
it('TOURTYPEMIG-001: every contract key has a row, only hike enabled, and the group column is gone', 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')");
|
||||
await rawExec(orm, "INSERT INTO tours (place_id, tour_type, distance, difficulty, wanderer_ref, tour_group_id, max_hiking_difficulty) VALUES (100, 'hike', 12.5, 'T3', 'w-1', 4, 3)");
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
const types = await rawQuery<{ key: string; enabled: number; label_key: string }>(
|
||||
orm,
|
||||
'SELECT key, enabled, label_key FROM tour_types ORDER BY sort_order',
|
||||
);
|
||||
expect(types.map((t) => t.key)).toEqual([...tourTypeKeySchema.options]);
|
||||
expect(types.filter((t) => t.enabled === 1).map((t) => t.key)).toEqual(['hike']);
|
||||
for (const t of types) expect(t.label_key).toBe(`tourTypes.${t.key}`);
|
||||
|
||||
const columns = await rawQuery<{ name: string }>(orm, "SELECT name FROM pragma_table_info('tours') ORDER BY cid");
|
||||
expect(columns.map((c) => c.name)).not.toContain('tour_group_id');
|
||||
expect(await rawQuery(orm, 'SELECT place_id, distance, difficulty, wanderer_ref, max_hiking_difficulty FROM tours')).toEqual([
|
||||
{ place_id: 100, distance: 12.5, difficulty: 'T3', wanderer_ref: 'w-1', max_hiking_difficulty: 3 },
|
||||
]);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('TOURTYPEMIG-002: a type an instance already defined keeps its own values', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
await rawExec(orm, "INSERT INTO tour_types (key, label_key, icon, color, routing_profile, enabled, sort_order) VALUES ('bike', 'tourTypes.bike', 'Bike', '#000000', 'bicycle', 1, 9)");
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(await rawQuery(orm, "SELECT color, enabled, sort_order FROM tour_types WHERE key = 'bike'")).toEqual([
|
||||
{ color: '#000000', enabled: 1, sort_order: 9 },
|
||||
]);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
|
||||
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
|
||||
} from '../../helpers/journey-repos';
|
||||
import { createTestToursRepo, createTestTourWaypointsRepo } from '../../helpers/tours-repos';
|
||||
import { createTestToursRepo, createTestTourTypesRepo, createTestTourWaypointsRepo } from '../../helpers/tours-repos';
|
||||
|
||||
const mixedGpx = Buffer.from(`<gpx>
|
||||
<wpt lat="1" lon="2"><name>Ignored POI</name></wpt>
|
||||
@@ -111,7 +111,7 @@ beforeAll(async () => {
|
||||
uow = await createTestUnitOfWork(testDb);
|
||||
places = await makePlacesService();
|
||||
tours = new ToursService(
|
||||
uow, places, await createTestToursRepo(testDb), await createTestTourWaypointsRepo(testDb), await createTestPlacesRepo(testDb),
|
||||
uow, places, await createTestToursRepo(testDb), await createTestTourTypesRepo(testDb), await createTestTourWaypointsRepo(testDb), await createTestPlacesRepo(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env
|
||||
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
import type { TourTypesRepository } from '../../../src/db/repositories/TourTypes.repository';
|
||||
import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
|
||||
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
|
||||
import type { User } from '../../../src/types';
|
||||
@@ -203,7 +204,7 @@ function tourRepositoryFixture(ownerTripId: number) {
|
||||
const row = {
|
||||
place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 3,
|
||||
elevation_gain: 30, elevation_loss: 0, duration: null, difficulty: null,
|
||||
wanderer_ref: null, match_confidence: 1, tour_group_id: null,
|
||||
wanderer_ref: null, match_confidence: 1,
|
||||
max_hiking_difficulty: 2, planned: 0, has_waypoints: 1,
|
||||
};
|
||||
const uow = { transactional: vi.fn(async <T>(fn: () => Promise<T>): Promise<T> => await fn()) };
|
||||
@@ -226,6 +227,7 @@ function tourRepositoryFixture(ownerTripId: number) {
|
||||
uow as unknown as UnitOfWork,
|
||||
places as unknown as PlacesService,
|
||||
toursRepo as unknown as ToursRepository,
|
||||
{ isEnabled: vi.fn(async () => true) } as unknown as TourTypesRepository,
|
||||
waypointsRepo as unknown as TourWaypointsRepository,
|
||||
placesRepo as unknown as PlacesRepository,
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
import type { TourTypesRepository } from '../../../src/db/repositories/TourTypes.repository';
|
||||
import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
|
||||
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
|
||||
|
||||
@@ -50,7 +51,7 @@ function makeService() {
|
||||
const row = {
|
||||
place_id: 42, name: request.name, tour_type: 'hike', distance: 3,
|
||||
elevation_gain: 50, elevation_loss: 20, duration: 60, difficulty: null,
|
||||
wanderer_ref: null, match_confidence: 1, tour_group_id: null,
|
||||
wanderer_ref: null, match_confidence: 1,
|
||||
max_hiking_difficulty: 2, planned: 0, has_waypoints: 1,
|
||||
};
|
||||
const place = { id: 42, trip_id: 7 };
|
||||
@@ -88,6 +89,7 @@ function makeService() {
|
||||
uow as unknown as UnitOfWork,
|
||||
places as unknown as PlacesService,
|
||||
toursRepo as unknown as ToursRepository,
|
||||
{ isEnabled: vi.fn(async () => true) } as unknown as TourTypesRepository,
|
||||
waypointsRepo as unknown as TourWaypointsRepository,
|
||||
placesRepo as unknown as PlacesRepository,
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* real PlacesService, is covered by tours.gpx.atomic.test.ts.
|
||||
*/
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
||||
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
||||
@@ -27,7 +27,7 @@ import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestToursRepo, createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
|
||||
import { createTestToursRepo, createTestTourTypesRepo, createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
|
||||
|
||||
const request: TourCreateRequest = {
|
||||
name: 'Ridge walk',
|
||||
@@ -59,6 +59,7 @@ beforeAll(async () => {
|
||||
await createTestUnitOfWork(testDb),
|
||||
{ broadcast } as unknown as PlacesService,
|
||||
await createTestToursRepo(testDb),
|
||||
await createTestTourTypesRepo(testDb),
|
||||
await createTestTourWaypointsRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
);
|
||||
@@ -133,6 +134,30 @@ describe('ToursService planner creation', () => {
|
||||
expect(result.tour.duration).toBeNull();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-010: a type the catalogue holds but has not enabled is a 400 without a write', async () => {
|
||||
// The contract only lets `hike` through today; this pins what happens the
|
||||
// day it lets more keys in before the planner offers them.
|
||||
const bike = { ...request, tour_type: 'bike' } as unknown as typeof request;
|
||||
|
||||
await expect(service.createTour(tripId, bike)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(count('places')).toBe(0);
|
||||
expect(count('tours')).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-011: an edit to a disabled type is a 400 and leaves the saved tour alone', async () => {
|
||||
const created = await service.createTour(tripId, request);
|
||||
broadcast.mockReset();
|
||||
const bike = { ...request, name: 'Renamed', tour_type: 'bike' } as unknown as typeof request;
|
||||
|
||||
await expect(service.updateTour(tripId, String(created.tour.place_id), bike)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(testDb.prepare('SELECT t.tour_type, p.name FROM tours t JOIN places p ON p.id = t.place_id').get())
|
||||
.toEqual({ tour_type: 'hike', name: 'Ridge walk' });
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a committed create successful when Tours invalidation publication fails', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementation((_tripId: string, event: string) => {
|
||||
|
||||
@@ -1900,7 +1900,7 @@ describe('copy: Tours (#2586)', () => {
|
||||
const place = createPlace(testDb, tripId, { name });
|
||||
testDb.prepare("UPDATE places SET transport_mode = 'walking', route_geometry = '[[47,11],[47.2,11.2]]' WHERE id = ?").run(place.id);
|
||||
createTour(testDb, place.id, { distance: 12.5, match_confidence: 0.9, max_hiking_difficulty: 4, created_at: '2025-01-02 03:04:05' });
|
||||
testDb.prepare("UPDATE tours SET elevation_gain = 800, elevation_loss = 790, duration = 5.5, difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 7 WHERE place_id = ?").run(place.id);
|
||||
testDb.prepare("UPDATE tours SET elevation_gain = 800, elevation_loss = 790, duration = 5.5, difficulty = 'T3', wanderer_ref = 'w-1' WHERE place_id = ?").run(place.id);
|
||||
const insert = testDb.prepare('INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, ?, ?, ?, ?)');
|
||||
insert.run(place.id, 47, 11, 'start', 0);
|
||||
insert.run(place.id, 47.1, 11.1, 'via', 1);
|
||||
@@ -1927,7 +1927,7 @@ describe('copy: Tours (#2586)', () => {
|
||||
const newPlain = testDb.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Plain stop'").get(newTripId) as { id: number };
|
||||
expect(newTour.id).not.toBe(tour.id);
|
||||
expect(tourColumns(newTour.id)).toEqual(tourColumns(tour.id));
|
||||
expect(tourColumns(newTour.id)).toMatchObject({ tour_type: 'hike', created_at: '2025-01-02 03:04:05', max_hiking_difficulty: 4, tour_group_id: 7 });
|
||||
expect(tourColumns(newTour.id)).toMatchObject({ tour_type: 'hike', created_at: '2025-01-02 03:04:05', max_hiking_difficulty: 4 });
|
||||
expect(waypoints(newTour.id)).toEqual(waypoints(tour.id));
|
||||
expect(waypoints(newTour.id)).toHaveLength(3);
|
||||
// The plain place stays plain, and the source keeps its own rows.
|
||||
|
||||
@@ -42,7 +42,6 @@ export const tourSchema = z.object({
|
||||
// Nullable reference to a tour in wanderer.
|
||||
wanderer_ref: z.string().nullable(),
|
||||
match_confidence: z.number().min(0).max(1).nullable(),
|
||||
tour_group_id: z.number().int().nullable(),
|
||||
max_hiking_difficulty: tourMaxHikingDifficultySchema,
|
||||
});
|
||||
export type Tour = z.infer<typeof tourSchema>;
|
||||
|
||||
Reference in New Issue
Block a user