mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Merge dev into fix/prettier
Brings in the Tours addon and the MikroORM follow-ups. Conflicts were resolved to dev's content, then every file dev brought in was run through prettier and eslint --fix so the branch stays uniformly formatted.
This commit is contained in:
@@ -12,6 +12,7 @@ export const ADDON_IDS = {
|
||||
LLM_PARSING: 'llm_parsing',
|
||||
COLLECTIONS: 'collections',
|
||||
ROADTRIP: 'roadtrip',
|
||||
TOURS: 'tours',
|
||||
} as const;
|
||||
|
||||
export type AddonId = (typeof ADDON_IDS)[keyof typeof ADDON_IDS];
|
||||
|
||||
@@ -1010,3 +1010,20 @@ export function coalesceOverrideWhileSame(
|
||||
}
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tours (#2586): `DayAssignmentsRepository`'s assignment projection carries a
|
||||
// place's route geometry only when the place is a Tour, so the day list never
|
||||
// hauls an ordinary track's geometry along with every stop.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `CASE WHEN <testRef> IS NOT NULL THEN <thenRef> END`, for use as a
|
||||
* `.select()` value (chain `.as(aliasName)` at the call site). The missing
|
||||
* ELSE is deliberate: a null test column yields SQL NULL.
|
||||
*/
|
||||
export function caseWhenNotNull(platform: Platform, testRef: string, thenRef: string): RawQueryFragment {
|
||||
if (platform instanceof SqlitePlatform)
|
||||
return raw(`CASE WHEN ${column(testRef)} IS NOT NULL THEN ${column(thenRef)} END`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
import { PlacesRepository } from '../repositories/Places.repository';
|
||||
import { DbTimestampType } from '../types';
|
||||
import { BudgetItems } from './BudgetItems.entity';
|
||||
@@ -14,8 +13,10 @@ import { PlaceRegions } from './PlaceRegions.entity';
|
||||
import { Reservations } from './Reservations.entity';
|
||||
import { RoadtripDayTracks } from './RoadtripDayTracks.entity';
|
||||
import { Tags } from './Tags.entity';
|
||||
import { Tours } from './Tours.entity';
|
||||
import { TripFiles } from './TripFiles.entity';
|
||||
import { Trips } from './Trips.entity';
|
||||
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
|
||||
export class Places {
|
||||
[EntityRepositoryType]?: PlacesRepository;
|
||||
@@ -67,6 +68,7 @@ export class Places {
|
||||
place_regions: Ref<PlaceRegions> | null = null;
|
||||
reservations_collection = new Collection<Reservations>(this);
|
||||
roadtrip_day_tracks_collection = new Collection<RoadtripDayTracks>(this);
|
||||
tours: Ref<Tours> | null = null;
|
||||
trip_files_collection = new Collection<TripFiles>(this);
|
||||
}
|
||||
|
||||
@@ -110,9 +112,11 @@ export const PlacesSchema = defineEntity({
|
||||
source: p.text().nullable(),
|
||||
email: p.text().nullable(),
|
||||
opening_hours: p.text().nullable(),
|
||||
place_tags: () => p.manyToMany(Tags).pivotTable('place_tags').joinColumn('place_id').inverseJoinColumn('tag_id').hidden(),
|
||||
place_tags: () =>
|
||||
p.manyToMany(Tags).pivotTable('place_tags').joinColumn('place_id').inverseJoinColumn('tag_id').hidden(),
|
||||
budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('place').hidden(),
|
||||
dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('acceptedPlace').hidden(),
|
||||
dawarich_visit_suggestions_collection: () =>
|
||||
p.oneToMany(DawarichVisitSuggestions).mappedBy('acceptedPlace').hidden(),
|
||||
day_accommodations_collection: () => p.oneToMany(DayAccommodations).mappedBy('place').hidden(),
|
||||
day_assignments_collection: () => p.oneToMany(DayAssignments).mappedBy('place').hidden(),
|
||||
file_links_collection: () => p.oneToMany(FileLinks).mappedBy('place').hidden(),
|
||||
@@ -122,6 +126,7 @@ export const PlacesSchema = defineEntity({
|
||||
place_regions: () => p.oneToOne(PlaceRegions).ref().mappedBy('place').hidden(),
|
||||
reservations_collection: () => p.oneToMany(Reservations).mappedBy('place').hidden(),
|
||||
roadtrip_day_tracks_collection: () => p.oneToMany(RoadtripDayTracks).mappedBy('place').hidden(),
|
||||
tours: () => p.oneToOne(Tours).ref().mappedBy('place').hidden(),
|
||||
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('place').hidden(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TourTypesRepository } from '../repositories/TourTypes.repository';
|
||||
import { Tours } from './Tours.entity';
|
||||
import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
|
||||
|
||||
export class TourTypes {
|
||||
[EntityRepositoryType]?: TourTypesRepository;
|
||||
[PrimaryKeyProp]?: 'key';
|
||||
key?: string | null;
|
||||
label_key!: string;
|
||||
icon!: string;
|
||||
color!: string;
|
||||
routing_profile?: string | null;
|
||||
is_sport: number & Opt = 1;
|
||||
enabled: number & Opt = 1;
|
||||
sort_order: number & Opt = 0;
|
||||
tours_collection = new Collection<Tours>(this);
|
||||
}
|
||||
|
||||
export const TourTypesSchema = defineEntity({
|
||||
class: TourTypes,
|
||||
repository: () => TourTypesRepository,
|
||||
properties: {
|
||||
key: p.text().primary().nullable(),
|
||||
label_key: p.text(),
|
||||
icon: p.text(),
|
||||
color: p.text(),
|
||||
routing_profile: p.text().nullable(),
|
||||
is_sport: p.integer().default(1),
|
||||
enabled: p.integer().default(1),
|
||||
sort_order: p.integer().default(0),
|
||||
tours_collection: () => p.oneToMany(Tours).mappedBy('tourTypeRef').hidden(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TourWaypointsRepository } from '../repositories/TourWaypoints.repository';
|
||||
import { Tours } from './Tours.entity';
|
||||
import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
|
||||
export class TourWaypoints {
|
||||
[EntityRepositoryType]?: TourWaypointsRepository;
|
||||
id!: number & Opt;
|
||||
place!: Ref<Tours>;
|
||||
place_id!: number;
|
||||
lat!: number;
|
||||
lng!: number;
|
||||
role!: string;
|
||||
sequence!: number;
|
||||
}
|
||||
|
||||
export const TourWaypointsSchema = defineEntity({
|
||||
class: TourWaypoints,
|
||||
repository: () => TourWaypointsRepository,
|
||||
uniques: [{ properties: ['place', 'sequence'] }],
|
||||
properties: {
|
||||
id: p.integer().primary(),
|
||||
place: () => p.manyToOne(Tours).ref().name('place_id').deleteRule('cascade').hidden(),
|
||||
place_id: p.integer().persist(false),
|
||||
lat: p.double(),
|
||||
lng: p.double(),
|
||||
role: p.text(),
|
||||
sequence: p.integer(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ToursRepository } from '../repositories/Tours.repository';
|
||||
import { Places } from './Places.entity';
|
||||
import { TourTypes } from './TourTypes.entity';
|
||||
import { TourWaypoints } from './TourWaypoints.entity';
|
||||
import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
|
||||
export class Tours {
|
||||
[EntityRepositoryType]?: ToursRepository;
|
||||
[PrimaryKeyProp]?: 'place';
|
||||
place?: Ref<Places> | null;
|
||||
place_id?: number | null;
|
||||
tour_type!: string;
|
||||
distance?: number | null;
|
||||
elevation_gain?: number | null;
|
||||
elevation_loss?: number | null;
|
||||
duration?: number | null;
|
||||
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>;
|
||||
tour_waypoints_collection = new Collection<TourWaypoints>(this);
|
||||
}
|
||||
|
||||
export const ToursSchema = defineEntity({
|
||||
class: Tours,
|
||||
repository: () => ToursRepository,
|
||||
properties: {
|
||||
place: () => p.oneToOne(Places).primary().ref().deleteRule('cascade').nullable().hidden(),
|
||||
place_id: p.integer().nullable().persist(false),
|
||||
tour_type: p.text().persist(false).index('idx_tours_tour_type'),
|
||||
distance: p.double().nullable(),
|
||||
elevation_gain: p.double().nullable(),
|
||||
elevation_loss: p.double().nullable(),
|
||||
duration: p.double().nullable(),
|
||||
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'),
|
||||
tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(),
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { EntitySchema } from '@mikro-orm/core';
|
||||
import { AddonsSchema } from './Addons.entity';
|
||||
import { AppSettingsSchema } from './AppSettings.entity';
|
||||
import { AssignmentParticipantsSchema } from './AssignmentParticipants.entity';
|
||||
@@ -101,6 +100,9 @@ import { ShareTokensSchema } from './ShareTokens.entity';
|
||||
import { TagsSchema } from './Tags.entity';
|
||||
import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity';
|
||||
import { TodoItemsSchema } from './TodoItems.entity';
|
||||
import { TourTypesSchema } from './TourTypes.entity';
|
||||
import { TourWaypointsSchema } from './TourWaypoints.entity';
|
||||
import { ToursSchema } from './Tours.entity';
|
||||
import { TrekPhotoCacheMetaSchema } from './TrekPhotoCacheMeta.entity';
|
||||
import { TrekPhotosSchema } from './TrekPhotos.entity';
|
||||
import { TripAlbumLinksSchema } from './TripAlbumLinks.entity';
|
||||
@@ -126,6 +128,7 @@ import { VisitedCountriesSchema } from './VisitedCountries.entity';
|
||||
import { VisitedRegionsSchema } from './VisitedRegions.entity';
|
||||
import { WebauthnChallengesSchema } from './WebauthnChallenges.entity';
|
||||
import { WebauthnCredentialsSchema } from './WebauthnCredentials.entity';
|
||||
import type { EntitySchema } from '@mikro-orm/core';
|
||||
|
||||
export const ALL_ENTITIES: readonly EntitySchema[] = [
|
||||
AddonsSchema,
|
||||
@@ -230,6 +233,9 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
|
||||
TagsSchema,
|
||||
TodoCategoryAssigneesSchema,
|
||||
TodoItemsSchema,
|
||||
TourTypesSchema,
|
||||
TourWaypointsSchema,
|
||||
ToursSchema,
|
||||
TrekPhotoCacheMetaSchema,
|
||||
TrekPhotosSchema,
|
||||
TripAlbumLinksSchema,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* Tours: a facet on `places` for hikes and other single-day routes.
|
||||
*
|
||||
* `tours` is keyed on the owning place, so deleting the place drops the facet
|
||||
* and, through it, the ordered `tour_waypoints` the route editor saved.
|
||||
* `tour_types` is the controlled vocabulary the facet points at; the `hike`
|
||||
* row is reference data that FK needs, so it is written here rather than left
|
||||
* to a seeder.
|
||||
*
|
||||
* Every statement is guarded: a test instance that ran the pre-ORM Tours
|
||||
* branch already holds these tables and its rows must survive. That branch
|
||||
* also created `idx_tour_waypoints_place`, which duplicates the autoindex
|
||||
* behind `UNIQUE(place_id, sequence)`, so it is dropped to converge on the
|
||||
* fresh-install shape.
|
||||
*/
|
||||
export class Migration20200101042000_tours extends Migration {
|
||||
override name = 'Migration20200101042000_tours';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(`
|
||||
CREATE TABLE IF NOT EXISTS tour_types (
|
||||
key TEXT PRIMARY KEY,
|
||||
label_key TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
routing_profile TEXT,
|
||||
is_sport INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`);
|
||||
|
||||
this.addSql(`
|
||||
INSERT OR IGNORE INTO tour_types (key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order)
|
||||
VALUES ('hike', 'tourTypes.hike', 'Mountain', '#16a34a', 'pedestrian', 1, 1, 0)
|
||||
`);
|
||||
|
||||
this.addSql(`
|
||||
CREATE TABLE IF NOT EXISTS tours (
|
||||
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
|
||||
tour_type TEXT NOT NULL REFERENCES tour_types(key),
|
||||
distance REAL,
|
||||
elevation_gain REAL,
|
||||
elevation_loss REAL,
|
||||
duration REAL,
|
||||
difficulty TEXT,
|
||||
wanderer_ref TEXT,
|
||||
match_confidence REAL,
|
||||
tour_group_id INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)
|
||||
)
|
||||
`);
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS idx_tours_tour_type ON tours(tour_type)');
|
||||
|
||||
this.addSql(`
|
||||
CREATE TABLE IF NOT EXISTS tour_waypoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE,
|
||||
lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90),
|
||||
lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180),
|
||||
role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')),
|
||||
sequence INTEGER NOT NULL CHECK(sequence >= 0),
|
||||
UNIQUE(place_id, sequence)
|
||||
)
|
||||
`);
|
||||
this.addSql('DROP INDEX IF EXISTS idx_tour_waypoints_place');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions';
|
||||
import { caseWhenNotNull, coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions';
|
||||
import type { DayAssignments } from '../entities/DayAssignments.entity';
|
||||
import { type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
@@ -16,9 +16,12 @@ import type { Platform } from '@mikro-orm/core';
|
||||
* p.duration_minutes, p.notes as place_notes,
|
||||
* p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id,
|
||||
* p.website, p.phone, p.stop_type, p.fill_percent,
|
||||
* t.place_id AS tour_place_id,
|
||||
* CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
|
||||
* c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
* FROM day_assignments da
|
||||
* JOIN places p ON da.place_id = p.id
|
||||
* LEFT JOIN tours t ON t.place_id = p.id
|
||||
* LEFT JOIN categories c ON p.category_id = c.id
|
||||
* WHERE <da.id = ? | da.day_id = ? | da.day_id IN (...)>
|
||||
* [ORDER BY da.order_index ASC, da.created_at ASC]
|
||||
@@ -58,6 +61,10 @@ export interface AssignmentWithPlaceRow extends DayAssignmentRow {
|
||||
phone: string | null;
|
||||
stop_type: string | null;
|
||||
fill_percent: number | null;
|
||||
/** The place's id when it is a Tour (its `tours` facet row), else null. */
|
||||
tour_place_id: number | null;
|
||||
/** The place's `route_geometry`, but only for a Tour; null for every other place. */
|
||||
tour_route_geometry: string | null;
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
category_icon: string | null;
|
||||
@@ -66,11 +73,19 @@ export interface AssignmentWithPlaceRow extends DayAssignmentRow {
|
||||
/**
|
||||
* {@link DayAssignmentsRepository.listPublicForShare}'s projection (SH9) —
|
||||
* {@link AssignmentWithPlaceRow} minus the six columns a public share link
|
||||
* must not leak.
|
||||
* must not leak, and minus the two Tour facet columns the share projection
|
||||
* never selected.
|
||||
*/
|
||||
export type SharePublicAssignmentRow = Omit<
|
||||
AssignmentWithPlaceRow,
|
||||
'google_place_id' | 'google_ftid' | 'osm_id' | 'amap_poi_id' | 'stop_type' | 'fill_percent'
|
||||
| 'google_place_id'
|
||||
| 'google_ftid'
|
||||
| 'osm_id'
|
||||
| 'amap_poi_id'
|
||||
| 'stop_type'
|
||||
| 'fill_percent'
|
||||
| 'tour_place_id'
|
||||
| 'tour_route_geometry'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -266,6 +281,10 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
'p.phone',
|
||||
'p.stop_type',
|
||||
'p.fill_percent',
|
||||
// `place_id` on `Tours` is the `persist(false)` twin of its primary-key
|
||||
// relation, the same trap as `category_id` above, so `columnRef` again.
|
||||
columnRef(platform, 't.place_id').as('tour_place_id'),
|
||||
caseWhenNotNull(platform, 't.place_id', 'p.route_geometry').as('tour_route_geometry'),
|
||||
'c.name as category_name',
|
||||
'c.color as category_color',
|
||||
'c.icon as category_icon',
|
||||
@@ -280,6 +299,7 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.qb('da')
|
||||
.join('da.place', 'p')
|
||||
.leftJoin('p.tours', 't')
|
||||
.leftJoin('p.category', 'c')
|
||||
.select(this.assignmentWithPlaceSelect(platform))
|
||||
.where({ 'da.id': id })
|
||||
@@ -296,6 +316,7 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return await this.qb('da')
|
||||
.join('da.place', 'p')
|
||||
.leftJoin('p.tours', 't')
|
||||
.leftJoin('p.category', 'c')
|
||||
.select(this.assignmentWithPlaceSelect(platform))
|
||||
.where({ 'da.day': day_id })
|
||||
@@ -313,6 +334,7 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return await this.qb('da')
|
||||
.join('da.place', 'p')
|
||||
.leftJoin('p.tours', 't')
|
||||
.leftJoin('p.category', 'c')
|
||||
.select(this.assignmentWithPlaceSelect(platform))
|
||||
.where({ 'da.day': { $in: day_ids } })
|
||||
@@ -516,6 +538,20 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
return row?.day_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* `moveAssignment`'s Tour check: `SELECT place_id FROM day_assignments
|
||||
* WHERE id = ?`, `columnRef` for the same `persist(false)` reason as
|
||||
* {@link getDayId} above.
|
||||
*/
|
||||
async getPlaceId(id: number): Promise<number | undefined> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.qb('da')
|
||||
.select([columnRef(platform, 'da.place_id').as('place_id')])
|
||||
.where({ id })
|
||||
.execute<{ place_id: number } | undefined>('get', false);
|
||||
return row?.place_id;
|
||||
}
|
||||
|
||||
/** AS14 — `UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?`. */
|
||||
async moveToDay(id: number, day_id: number, order_index: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { day: day_id, order_index });
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface PlaceWithCategoryRow extends PlaceRow {
|
||||
export interface PlaceListRow extends PlaceWithCategoryRow {
|
||||
country_code: string | null;
|
||||
region_name: string | null;
|
||||
/** The place's own id when it is a Tour (it has a `tours` facet row), else null. */
|
||||
tour_place_id: number | null;
|
||||
}
|
||||
|
||||
/** {@link PlacesRepository.findActiveTripFile}'s narrow `trip_files` shape. */
|
||||
@@ -344,6 +346,22 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
.execute<{ google_place_id: string | null; image_url: string | null } | undefined>('get', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT 1 FROM tours WHERE place_id = ?`, read through the place's
|
||||
* `tours` facet so the Places domain answers it without the Tours
|
||||
* repository. `remove`/`removeMany` call it inside their delete
|
||||
* transaction to report which deleted places were Tours; the facet row and
|
||||
* its waypoints go with the place through `ON DELETE CASCADE`.
|
||||
*/
|
||||
async isTour(id: number): Promise<boolean> {
|
||||
const row = await this.qb('p')
|
||||
.join('p.tours', 't')
|
||||
.select(['p.id'])
|
||||
.where({ 'p.id': id })
|
||||
.execute<{ id: number } | undefined>('get', false);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** PL19/PL21 — `DELETE FROM places WHERE id = ?`, unscoped: every caller proved trip access first. */
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await this.nativeDelete({ id });
|
||||
@@ -741,13 +759,19 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
assignment?: 'all' | 'unassigned' | 'assigned';
|
||||
},
|
||||
): Promise<PlaceListRow[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const qb = this.qb('p')
|
||||
.leftJoin('p.category', 'c')
|
||||
// #2537: the atlas' cached region rides along (`LEFT JOIN place_regions pr ON pr.place_id = p.id`).
|
||||
.leftJoin('p.place_regions', 'pr')
|
||||
// Tours: `LEFT JOIN tours t ON t.place_id = p.id` marks the Tour-backed
|
||||
// places. `columnRef`, because the bare `t.place_id` twin is
|
||||
// persist(false) and selects nothing.
|
||||
.leftJoin('p.tours', 't')
|
||||
.select(
|
||||
[
|
||||
'p.*',
|
||||
columnRef(platform, 't.place_id').as('tour_place_id'),
|
||||
'c.name as category_name',
|
||||
'c.color as category_color',
|
||||
'c.icon as category_icon',
|
||||
@@ -1455,6 +1479,56 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
.execute();
|
||||
return rows as PublicApiAssignedPlaceRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TO10 (`ToursService.createTour`) — `INSERT INTO places (trip_id, name,
|
||||
* lat, lng, transport_mode, route_geometry) VALUES (?, ?, ?, ?, 'walking',
|
||||
* ?)`. The place a drawn tour lives on starts at the route's first point;
|
||||
* every other column keeps its default. Returns the generated id.
|
||||
*/
|
||||
async insertTourPlace(input: {
|
||||
trip_id: number;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
route_geometry: string;
|
||||
}): Promise<number> {
|
||||
return await this.insert({
|
||||
trip: input.trip_id,
|
||||
name: input.name,
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: input.route_geometry,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TO11 (`ToursService.updateTour`) — `UPDATE places SET name = ?, lat = ?,
|
||||
* lng = ?, transport_mode = 'walking', route_geometry = ?, updated_at =
|
||||
* CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. Returns whether a place
|
||||
* of that trip was updated, so a tour deleted or moved between the check
|
||||
* and the write answers 404 instead of writing nothing.
|
||||
*/
|
||||
async updateTourRoute(
|
||||
id: number,
|
||||
trip_id: number,
|
||||
input: { name: string; lat: number; lng: number; route_geometry: string },
|
||||
): Promise<boolean> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const updated = await this.nativeUpdate(
|
||||
{ id, trip: trip_id },
|
||||
{
|
||||
name: input.name,
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: input.route_geometry,
|
||||
updated_at: currentTimestamp(platform),
|
||||
},
|
||||
);
|
||||
return updated > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** The `places` columns {@link PlacesRepository.listAssignedForPublicApi} selects, typed off {@link PlaceRow}. */
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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.
|
||||
*/
|
||||
export class TourTypesRepository extends TrekRepository<TourTypes> {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { TourWaypoints } from '../entities/TourWaypoints.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/** One saved routing control point of a tour, in route order. */
|
||||
export interface TourWaypointRow {
|
||||
lat: number;
|
||||
lng: number;
|
||||
role: 'start' | 'via' | 'end';
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
/** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */
|
||||
export interface TourWaypointWithPlaceRow extends TourWaypointRow {
|
||||
place_id: number;
|
||||
}
|
||||
|
||||
interface TourWaypointsKyselyDB {
|
||||
tour_waypoints: {
|
||||
id: number;
|
||||
place_id: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
role: TourWaypointRow['role'];
|
||||
sequence: number;
|
||||
};
|
||||
places: { id: number; trip_id: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* `tour_waypoints` — the start, via and end points the route editor saved
|
||||
* for a tour, so reopening it restores the controls rather than only the
|
||||
* derived line. Rows hang off `tours(place_id)` and go with the tour.
|
||||
*/
|
||||
export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
|
||||
private db() {
|
||||
return this.kysely<TourWaypointsKyselyDB>();
|
||||
}
|
||||
|
||||
/** TW1 — `SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
|
||||
async listForPlace(place_id: number): Promise<TourWaypointRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('tour_waypoints')
|
||||
.select(['lat', 'lng', 'role', 'sequence'])
|
||||
.where('place_id', '=', place_id)
|
||||
.orderBy('sequence', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** TW2 — every waypoint of a trip's tours, for the trip copy to remap by place. */
|
||||
async listForTrip(trip_id: number): Promise<TourWaypointWithPlaceRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('tour_waypoints as tw')
|
||||
.innerJoin('places as p', 'p.id', 'tw.place_id')
|
||||
.select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence'])
|
||||
.where('p.trip_id', '=', trip_id)
|
||||
.orderBy('tw.place_id', 'asc')
|
||||
.orderBy('tw.sequence', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence)
|
||||
* VALUES (...)` for each point. The caller owns the transaction: a create
|
||||
* writes the tour first, an edit clears the old points first.
|
||||
*/
|
||||
async insertForPlace(place_id: number, waypoints: readonly TourWaypointRow[]): Promise<void> {
|
||||
if (waypoints.length === 0) return;
|
||||
await this.insertMany(
|
||||
waypoints.map((w) => ({ place: place_id, lat: w.lat, lng: w.lng, role: w.role, sequence: w.sequence })),
|
||||
);
|
||||
}
|
||||
|
||||
/** TW4 — `DELETE FROM tour_waypoints WHERE place_id = ?`. */
|
||||
async deleteForPlace(place_id: number): Promise<void> {
|
||||
await this.nativeDelete({ place: place_id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { Tours } from '../entities/Tours.entity';
|
||||
import type { AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/**
|
||||
* A tour as the Tours list and detail read it: the `tours` facet joined with
|
||||
* its owning place's name, plus two read-model flags (`planned`: the place
|
||||
* sits on at least one day; `has_waypoints`: the route editor saved control
|
||||
* points for it). SQLite hands both flags back as 0/1.
|
||||
*/
|
||||
export interface TourListRow {
|
||||
place_id: number;
|
||||
name: string;
|
||||
tour_type: string;
|
||||
distance: number | null;
|
||||
elevation_gain: number | null;
|
||||
elevation_loss: number | null;
|
||||
duration: number | null;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Every `tours` column, as the trip copy reads and re-inserts it. */
|
||||
export interface TourRow {
|
||||
place_id: number;
|
||||
tour_type: string;
|
||||
distance: number | null;
|
||||
elevation_gain: number | null;
|
||||
elevation_loss: number | null;
|
||||
duration: number | null;
|
||||
difficulty: string | null;
|
||||
wanderer_ref: string | null;
|
||||
match_confidence: number | null;
|
||||
tour_group_id: number | null;
|
||||
created_at: string | null;
|
||||
max_hiking_difficulty: number;
|
||||
}
|
||||
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
|
||||
|
||||
/** What a create or a GPX import writes; the unset metadata columns stay NULL. */
|
||||
export interface TourInsert {
|
||||
place_id: number;
|
||||
tour_type: string;
|
||||
distance: number | null;
|
||||
elevation_gain: number | null;
|
||||
elevation_loss: number | null;
|
||||
duration: number | null;
|
||||
match_confidence: number | null;
|
||||
max_hiking_difficulty: number;
|
||||
}
|
||||
|
||||
/** What a route edit replaces on an existing tour. */
|
||||
export type TourUpdate = Omit<TourInsert, 'place_id'>;
|
||||
|
||||
interface ToursKyselyDB {
|
||||
tours: TourRow;
|
||||
places: { id: number; trip_id: number; name: string };
|
||||
day_assignments: { id: number; day_id: number; place_id: number };
|
||||
tour_waypoints: { id: number; place_id: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* `tours` — the facet that makes a place a tour (#2586). Keyed on the owning
|
||||
* place, so it carries no trip of its own: every trip-scoped statement here
|
||||
* joins `places` for the predicate. Deleting the place cascades the row and
|
||||
* its `tour_waypoints`.
|
||||
*
|
||||
* Kysely throughout: the list projection needs two correlated `EXISTS`
|
||||
* subqueries, and the trip-scoped update and the day check join tables the
|
||||
* entity has no relation path to.
|
||||
*/
|
||||
export class ToursRepository extends TrekRepository<Tours> {
|
||||
private db() {
|
||||
return this.kysely<ToursKyselyDB>();
|
||||
}
|
||||
|
||||
/** The shared SELECT of {@link listForTrip} and {@link findInTrip}. */
|
||||
private listQuery() {
|
||||
return this.db()
|
||||
.selectFrom('tours as t')
|
||||
.innerJoin('places as p', 'p.id', 't.place_id')
|
||||
.select((eb) => [
|
||||
'p.id as place_id',
|
||||
'p.name as name',
|
||||
't.tour_type as tour_type',
|
||||
't.distance as distance',
|
||||
't.elevation_gain as elevation_gain',
|
||||
't.elevation_loss as elevation_loss',
|
||||
't.duration as duration',
|
||||
'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'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* TO1 — `SELECT p.id AS place_id, p.name, t.*, EXISTS(day_assignments) AS
|
||||
* planned, EXISTS(tour_waypoints) AS has_waypoints FROM tours t JOIN places
|
||||
* p ON p.id = t.place_id WHERE p.trip_id = ? ORDER BY t.created_at DESC`,
|
||||
* newest first. The place id breaks ties inside one CURRENT_TIMESTAMP second.
|
||||
*/
|
||||
async listForTrip(trip_id: number): Promise<TourListRow[]> {
|
||||
const rows = await this.listQuery()
|
||||
.where('p.trip_id', '=', trip_id)
|
||||
.orderBy('t.created_at', 'desc')
|
||||
.orderBy('t.place_id', 'desc')
|
||||
.execute();
|
||||
return rows.map(toListRow);
|
||||
}
|
||||
|
||||
/** TO2 — {@link listForTrip}'s projection for one place, `undefined` unless it is a tour of that trip. */
|
||||
async findInTrip(trip_id: number, place_id: number): Promise<TourListRow | undefined> {
|
||||
const row = await this.listQuery().where('p.trip_id', '=', trip_id).where('p.id', '=', place_id).executeTakeFirst();
|
||||
return row ? toListRow(row) : undefined;
|
||||
}
|
||||
|
||||
/** TO3 — `SELECT 1 FROM tours WHERE place_id = ?`: whether the place is a tour. */
|
||||
async existsForPlace(place_id: number): Promise<boolean> {
|
||||
const row = await this.db()
|
||||
.selectFrom('tours')
|
||||
.select('place_id')
|
||||
.where('place_id', '=', place_id)
|
||||
.executeTakeFirst();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** TO4 — `SELECT place_id FROM tours WHERE place_id IN (...)`: which of these places are tours. */
|
||||
async listTourPlaceIds(place_ids: number[]): Promise<number[]> {
|
||||
if (place_ids.length === 0) return [];
|
||||
const rows = await this.db().selectFrom('tours').select('place_id').where('place_id', 'in', place_ids).execute();
|
||||
return rows.map((r) => r.place_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* TO5 — `SELECT 1 FROM day_assignments da JOIN tours t ON t.place_id =
|
||||
* da.place_id WHERE da.day_id = ? AND da.place_id = ? [AND da.id != ?]`.
|
||||
*
|
||||
* A tour fills its day, so one day holds it at most once. Create and move
|
||||
* both ask this inside the transaction that writes the assignment; move
|
||||
* passes the assignment being moved so it does not collide with itself.
|
||||
* False for an ordinary place, which may sit on a day any number of times.
|
||||
*/
|
||||
async isOnDay(day_id: number, place_id: number, except_assignment_id?: number): Promise<boolean> {
|
||||
let query = this.db()
|
||||
.selectFrom('day_assignments as da')
|
||||
.innerJoin('tours as t', 't.place_id', 'da.place_id')
|
||||
.select('da.id')
|
||||
.where('da.day_id', '=', day_id)
|
||||
.where('da.place_id', '=', place_id);
|
||||
if (except_assignment_id !== undefined) query = query.where('da.id', '!=', except_assignment_id);
|
||||
return !!(await query.executeTakeFirst());
|
||||
}
|
||||
|
||||
/** TO6 — every `tours` row of a trip's places, for the trip copy to remap. */
|
||||
async listRowsForTrip(trip_id: number): Promise<TourRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('tours as t')
|
||||
.innerJoin('places as p', 'p.id', 't.place_id')
|
||||
.selectAll('t')
|
||||
.where('p.trip_id', '=', trip_id)
|
||||
.orderBy('t.place_id', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** TO7 — `INSERT INTO tours (place_id, tour_type, distance, ...) VALUES (...)`. */
|
||||
async insertTour(input: TourInsert): Promise<void> {
|
||||
await this.insert({
|
||||
place: input.place_id,
|
||||
tourTypeRef: input.tour_type,
|
||||
distance: input.distance,
|
||||
elevation_gain: input.elevation_gain,
|
||||
elevation_loss: input.elevation_loss,
|
||||
duration: input.duration,
|
||||
match_confidence: input.match_confidence,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TO8 — a copied tour keeps every column, `created_at` included, so the
|
||||
* copy lists its tours in the source's order.
|
||||
*/
|
||||
async insertCopy(place_id: number, row: Omit<TourRow, 'place_id'>): Promise<void> {
|
||||
await this.db()
|
||||
.insertInto('tours')
|
||||
.values({ ...row, place_id })
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* TO9 — `UPDATE tours SET tour_type = ?, distance = ?, ... WHERE place_id =
|
||||
* ? AND place_id IN (SELECT id FROM places WHERE trip_id = ?)`. Returns
|
||||
* whether a row of that trip was updated.
|
||||
*/
|
||||
async updateInTrip(trip_id: number, place_id: number, input: TourUpdate): Promise<boolean> {
|
||||
const result = await this.db()
|
||||
.updateTable('tours')
|
||||
.set({
|
||||
tour_type: input.tour_type,
|
||||
distance: input.distance,
|
||||
elevation_gain: input.elevation_gain,
|
||||
elevation_loss: input.elevation_loss,
|
||||
duration: input.duration,
|
||||
match_confidence: input.match_confidence,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
})
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
function toListRow<
|
||||
R extends Omit<TourListRow, 'planned' | 'has_waypoints'> & { planned: unknown; has_waypoints: unknown },
|
||||
>(r: R): TourListRow {
|
||||
return { ...r, planned: Number(r.planned), has_waypoints: Number(r.has_waypoints) };
|
||||
}
|
||||
@@ -93,6 +93,15 @@ const DEFAULT_ADDONS: AddonRow[] = [
|
||||
enabled: 0,
|
||||
sort_order: 7,
|
||||
},
|
||||
{
|
||||
id: 'tours',
|
||||
name: 'Tours',
|
||||
description: 'Hikes and other single-day tours that follow a route',
|
||||
type: 'trip',
|
||||
icon: 'Mountain',
|
||||
enabled: 0,
|
||||
sort_order: 8,
|
||||
},
|
||||
{
|
||||
id: 'journey',
|
||||
name: 'Journey',
|
||||
|
||||
@@ -280,6 +280,11 @@ export function applyGlobalMiddleware(
|
||||
// (#2180). routing.openstreetmap.de below is a different host.
|
||||
'https://tile.openstreetmap.de',
|
||||
'https://tiles.stadiamaps.com',
|
||||
// OpenTopoMap is the key-free raster layer offered only by the Tours
|
||||
// planner. Leaflet uses its a/b/c shards; the apex is named too so a
|
||||
// future unsharded template does not repeat the OSM CSP gap above.
|
||||
'https://tile.opentopomap.org',
|
||||
'https://*.tile.opentopomap.org',
|
||||
// The imagery host, for the same reason and one more. Leaflet fetches a tile
|
||||
// as an <img>, which img-src's blanket `https:` waves through, so the satellite
|
||||
// view worked on Leaflet with this host missing. A GL map reads the raster
|
||||
|
||||
@@ -76,6 +76,7 @@ import { StorageModule } from './storage/storage.module';
|
||||
import { SystemNoticesModule } from './system-notices/system-notices.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { TodoModule } from './todo/todo.module';
|
||||
import { ToursModule } from './tours/tours.module';
|
||||
import { TransitModule } from './transit/transit.module';
|
||||
import { TripInviteModule } from './trip-invite/trip-invite.module';
|
||||
import { TripsModule } from './trips/trips.module';
|
||||
@@ -129,6 +130,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||
AssignmentsModule,
|
||||
PlacesModule,
|
||||
RoadtripModule,
|
||||
ToursModule,
|
||||
TripsModule,
|
||||
CollabModule,
|
||||
FilesModule,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { JourneyDomainModule } from '../journey/journey-domain.module';
|
||||
@@ -30,7 +31,8 @@ import { Module } from '@nestjs/common';
|
||||
* added by Plan 3d Task 1 for AS20–AS23) — the entity classes only, not the
|
||||
* `DaysModule`/`PlacesModule`/`RoadtripModule` modules themselves, so the
|
||||
* loop this module's docstring already avoids stays avoided (`DaysModule`'s
|
||||
* own precedent for pulling in `Trips` the same way).
|
||||
* own precedent for pulling in `Trips` the same way). `Tours` backs the
|
||||
* one-Tour-per-day check create and move share.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,7 +40,16 @@ import { Module } from '@nestjs/common';
|
||||
QueryHelpersModule,
|
||||
JourneyDomainModule,
|
||||
RealtimeModule,
|
||||
MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days, Places, TripMembers, RoadtripVias, Trips]),
|
||||
MikroOrmModule.forFeature([
|
||||
DayAssignments,
|
||||
AssignmentParticipants,
|
||||
Days,
|
||||
Places,
|
||||
TripMembers,
|
||||
RoadtripVias,
|
||||
Trips,
|
||||
Tours,
|
||||
]),
|
||||
],
|
||||
providers: [AssignmentsService],
|
||||
exports: [AssignmentsService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { AssignmentParticipantsRepository } from '../../db/repositories/AssignmentParticipants.repository';
|
||||
@@ -10,6 +11,7 @@ import type { DayAssignmentsRepository, DayStopRow } from '../../db/repositories
|
||||
import type { DaysRepository } from '../../db/repositories/Days.repository';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { RoadtripViasRepository } from '../../db/repositories/RoadtripVias.repository';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
|
||||
import type { TripAccess } from '../../db/repositories/Trips.repository';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
@@ -22,7 +24,7 @@ import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
|
||||
import { isEmptyReanchoring, reanchorByStopOrder } from '@trek/shared/roadtrip';
|
||||
|
||||
@@ -112,6 +114,7 @@ export class AssignmentsService {
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
@InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository,
|
||||
@InjectRepository(RoadtripVias) private readonly roadtripViasRepo: RoadtripViasRepository,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
) {}
|
||||
|
||||
async verifyTripAccess(tripId: string | number, userId: number) {
|
||||
@@ -270,6 +273,7 @@ export class AssignmentsService {
|
||||
const placeIdNum = toRowId(placeId)!;
|
||||
|
||||
const insertedId = await this.uow.transactional(async () => {
|
||||
await this.assertTourFreeOnDay(dayIdNum, placeIdNum);
|
||||
// AS6 — the explicit null check (not `||`) is load-bearing: a stored 0
|
||||
// order_index must survive, which `maxOrder || -1` would not.
|
||||
const maxOrder = await this.dayAssignmentsRepo.maxOrderIndex(dayIdNum);
|
||||
@@ -367,13 +371,29 @@ export class AssignmentsService {
|
||||
// about (or race on) where the assignment was.
|
||||
const oldDayId = await this.uow.transactional(async () => {
|
||||
const dayId = await this.dayAssignmentsRepo.getDayId(idNum); // AS13
|
||||
await this.dayAssignmentsRepo.moveToDay(idNum, toRowId(newDayId)!, orderIndex ?? 0); // AS14
|
||||
const targetDayId = toRowId(newDayId)!;
|
||||
const placeId = await this.dayAssignmentsRepo.getPlaceId(idNum);
|
||||
if (placeId !== undefined) await this.assertTourFreeOnDay(targetDayId, placeId, idNum);
|
||||
await this.dayAssignmentsRepo.moveToDay(idNum, targetDayId, orderIndex ?? 0); // AS14
|
||||
return dayId;
|
||||
});
|
||||
const updated = await this.getAssignmentWithPlace(idNum);
|
||||
return { assignment: updated, oldDayId };
|
||||
}
|
||||
|
||||
/**
|
||||
* A Tour fills its day, so a day holds it at most once. Both ways a stop
|
||||
* lands on a day (create and move) call this inside the transaction that
|
||||
* writes it, so two concurrent requests cannot both pass the check. Move
|
||||
* passes the assignment it moves so a reorder within the same day does
|
||||
* not collide with itself. Ordinary places are never refused.
|
||||
*/
|
||||
private async assertTourFreeOnDay(dayId: number, placeId: number, exceptAssignmentId?: number): Promise<void> {
|
||||
if (await this.toursRepo.isOnDay(dayId, placeId, exceptAssignmentId)) {
|
||||
throw new ConflictException('Tour is already assigned to this day');
|
||||
}
|
||||
}
|
||||
|
||||
/** AS15 — `AssignmentParticipantsRepository.listWithDisplayName`, the same AS2/AS31 statement. */
|
||||
async getParticipants(assignmentId: string | number) {
|
||||
return await this.assignmentParticipantsRepo.listWithDisplayName(toRowId(assignmentId)!);
|
||||
|
||||
@@ -48,6 +48,8 @@ export function formatAssignmentWithPlace(
|
||||
accommodation_id: a.accommodation_id ?? null,
|
||||
participants: participants || [],
|
||||
created_at: a.created_at,
|
||||
tour_place_id: a.tour_place_id ?? null,
|
||||
tour_route_geometry: a.tour_route_geometry ?? null,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
name: a.place_name,
|
||||
|
||||
@@ -203,6 +203,8 @@ export class DaysService {
|
||||
// a booking wrote, and this copy is one of the paths that feed it.
|
||||
accommodation_id: a.accommodation_id ?? null,
|
||||
created_at: a.created_at,
|
||||
tour_place_id: a.tour_place_id ?? null,
|
||||
tour_route_geometry: a.tour_route_geometry ?? null,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
name: a.place_name,
|
||||
|
||||
@@ -352,7 +352,7 @@ export class PlacesController {
|
||||
await this.requireEdit(trip, user);
|
||||
const { ids } = body;
|
||||
if (ids.length === 0) {
|
||||
return { deleted: [], count: 0 };
|
||||
return { deleted: [], count: 0, tourPlaceIds: [] };
|
||||
}
|
||||
// Scope the ids to the trip before the hook: onPlaceDeleted keys on the
|
||||
// place id alone, so an id from another trip would detach that trip's
|
||||
@@ -363,7 +363,7 @@ export class PlacesController {
|
||||
for (const id of scoped) await this.places.onDeleted(id);
|
||||
// Read the linked expenses before the delete — afterwards the link is gone (#1298).
|
||||
const expenseIds = await this.places.linkedExpenseIds(tripId, scoped);
|
||||
const { deleted, cancelled } = await this.places.removeMany(tripId, ids);
|
||||
const { deleted, deletedTourPlaceIds = [], cancelled } = await this.places.removeMany(tripId, ids);
|
||||
for (const id of deleted) {
|
||||
this.places.broadcast(tripId, 'place:deleted', { placeId: id }, socketId);
|
||||
}
|
||||
@@ -381,7 +381,7 @@ export class PlacesController {
|
||||
for (const itemId of [...expenseIds, ...cancelled.budgetItemIds]) {
|
||||
this.places.broadcast(tripId, 'budget:deleted', { itemId }, undefined);
|
||||
}
|
||||
return { deleted, count: deleted.length };
|
||||
return { deleted, count: deleted.length, tourPlaceIds: deletedTourPlaceIds };
|
||||
}
|
||||
|
||||
@Post('bulk-update')
|
||||
@@ -575,7 +575,7 @@ export class PlacesController {
|
||||
}
|
||||
await this.places.onDeleted(Number(id));
|
||||
const expenseIds = await this.places.linkedExpenseIds(tripId, [id]);
|
||||
const { deleted, cancelled } = await this.places.remove(tripId, id);
|
||||
const { deleted, deletedTourPlaceIds = [], cancelled } = await this.places.remove(tripId, id);
|
||||
if (!deleted) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
}
|
||||
@@ -590,6 +590,6 @@ export class PlacesController {
|
||||
for (const itemId of [...expenseIds, ...cancelled.budgetItemIds]) {
|
||||
this.places.broadcast(tripId, 'budget:deleted', { itemId }, undefined);
|
||||
}
|
||||
return { success: true };
|
||||
return { success: true, tourPlaceIds: deletedTourPlaceIds };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,7 +578,7 @@ export class PlacesMcp {
|
||||
@Tool({
|
||||
name: 'delete_place',
|
||||
description:
|
||||
"Delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night's reservation and that reservation's expense. Warn the user before calling this on a hotel with a booking: it cannot be undone.",
|
||||
"Permanently delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night's reservation and that reservation's expense. If the place is a Tour, its route and editable waypoints are also permanently deleted. Warn the user before calling: this cannot be undone.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
@@ -843,7 +843,7 @@ export class PlacesMcp {
|
||||
@Tool({
|
||||
name: 'bulk_delete_places',
|
||||
description:
|
||||
"Delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place's linked expenses and any nights booked at it, with their reservations and expenses. Warn the user before calling this: it cannot be undone.",
|
||||
"Permanently delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place's linked expenses and any nights booked at it, with their reservations and expenses. Any Tours in the selection also lose their routes and editable waypoints. Warn the user before calling: this cannot be undone.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(200),
|
||||
|
||||
@@ -98,6 +98,15 @@ type Trip = TripAccess;
|
||||
|
||||
type ImportedPlace = { id: number; route_geometry?: string | null; route_color?: string | null };
|
||||
|
||||
/** One place parsed out of a GPX file, not yet persisted. */
|
||||
export interface PreparedGpxPlace {
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
description: string | null;
|
||||
routeGeometry?: string;
|
||||
}
|
||||
|
||||
/** Fields accepted when creating a place. */
|
||||
export interface PlaceCreateInput {
|
||||
name: string;
|
||||
@@ -728,12 +737,15 @@ export class PlacesService {
|
||||
}
|
||||
}
|
||||
|
||||
async remove(tripId: string, placeId: string): Promise<{ deleted: boolean; cancelled: CancelledStays }> {
|
||||
async remove(
|
||||
tripId: string,
|
||||
placeId: string,
|
||||
): Promise<{ deleted: boolean; deletedTourPlaceIds: number[]; cancelled: CancelledStays }> {
|
||||
const cancelled = noCancelledStays();
|
||||
// `toRowId` first (Task 3 review H1, absorbed here): the write below
|
||||
// (`deleteById`) must use the SAME id this gate's existence read used.
|
||||
const id = toRowId(placeId);
|
||||
if (id === null) return { deleted: false, cancelled };
|
||||
if (id === null) return { deleted: false, deletedTourPlaceIds: [], cancelled };
|
||||
// Rule 21 (H1): the trip id gets the same `toRowId` treatment, parsed
|
||||
// once here and reused for the gate AND every raw survivor below
|
||||
// (`cancelStaysAt`'s PL16, the PL18 `budget_items` DELETE) —
|
||||
@@ -743,14 +755,18 @@ export class PlacesService {
|
||||
// linked expense and stay orphaned (H1, live: verified with a linked
|
||||
// expense and a stay, both survived with `place_id: null`).
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) return { deleted: false, cancelled };
|
||||
if (tid === null) return { deleted: false, deletedTourPlaceIds: [], cancelled };
|
||||
// PL17 — the reclaim-candidate projection, read before the delete.
|
||||
const place = await this.placesRepo.reclaimInputs(id, tid);
|
||||
if (!place) return { deleted: false, cancelled };
|
||||
if (!place) return { deleted: false, deletedTourPlaceIds: [], cancelled };
|
||||
let wasTour = false;
|
||||
// The linked expense goes with the place, the same way a booking takes its
|
||||
// expense with it (#1298). One transaction, so a place can never survive
|
||||
// half-detached from its money.
|
||||
await this.uow.transactional(async () => {
|
||||
// A Tour's facet row and waypoints go with the place (ON DELETE
|
||||
// CASCADE); the caller still has to tell the client which ones did.
|
||||
wasTour = await this.placesRepo.isTour(id);
|
||||
await this.cancelStaysAt(tid, id, cancelled);
|
||||
// PL18 — Plan 3e Task 2, converted: `BudgetItemsRepository.deleteForPlace`.
|
||||
await this.budgetItemsRepo.deleteForPlace(tid, id);
|
||||
@@ -759,12 +775,15 @@ export class PlacesService {
|
||||
});
|
||||
await reclaimPhotoCache(this.photoCache, place.google_place_id, place.image_url);
|
||||
await this.reclaimPlaceImage(place.image_url);
|
||||
return { deleted: true, cancelled };
|
||||
return { deleted: true, deletedTourPlaceIds: wasTour ? [id] : [], cancelled };
|
||||
}
|
||||
|
||||
async removeMany(tripId: string, ids: number[]): Promise<{ deleted: number[]; cancelled: CancelledStays }> {
|
||||
async removeMany(
|
||||
tripId: string,
|
||||
ids: number[],
|
||||
): Promise<{ deleted: number[]; deletedTourPlaceIds: number[]; cancelled: CancelledStays }> {
|
||||
const cancelled = noCancelledStays();
|
||||
if (ids.length === 0) return { deleted: [], cancelled };
|
||||
if (ids.length === 0) return { deleted: [], deletedTourPlaceIds: [], cancelled };
|
||||
// Rule 21 (H1): the trip id gets the same `toRowId` treatment as
|
||||
// `remove()`'s, parsed once and reused for every id in the loop below.
|
||||
// Task 9 fix wave (A-8): the claim this comment used to make — "the
|
||||
@@ -778,8 +797,9 @@ export class PlacesService {
|
||||
// any non-canonical trip id before `removeMany` is ever reached, so this
|
||||
// `tid === null` branch only fires for a caller that skipped the gate.
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) return { deleted: [], cancelled };
|
||||
if (tid === null) return { deleted: [], deletedTourPlaceIds: [], cancelled };
|
||||
const deleted: number[] = [];
|
||||
const deletedTourPlaceIds: number[] = [];
|
||||
const reclaimable: { google_place_id: string | null; image_url: string | null }[] = [];
|
||||
await this.uow.transactional(async () => {
|
||||
for (const id of ids) {
|
||||
@@ -789,6 +809,7 @@ export class PlacesService {
|
||||
// gate needed, the H1 class of bug is a route-string problem.
|
||||
const row = await this.placesRepo.reclaimInputs(id, tid);
|
||||
if (!row) continue;
|
||||
if (await this.placesRepo.isTour(id)) deletedTourPlaceIds.push(id);
|
||||
await this.cancelStaysAt(tid, id, cancelled);
|
||||
// PL22 — Plan 3e Task 2, converted: `BudgetItemsRepository.deleteForPlace`.
|
||||
await this.budgetItemsRepo.deleteForPlace(tid, id);
|
||||
@@ -803,7 +824,7 @@ export class PlacesService {
|
||||
await reclaimPhotoCache(this.photoCache, row.google_place_id, row.image_url);
|
||||
await this.reclaimPlaceImage(row.image_url);
|
||||
}
|
||||
return { deleted, cancelled };
|
||||
return { deleted, deletedTourPlaceIds, cancelled };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -993,11 +1014,22 @@ export class PlacesService {
|
||||
fileBuffer: Buffer,
|
||||
opts: GpxImportOptions = {},
|
||||
): Promise<GpxImportResult | null> {
|
||||
const rows = this.prepareGpxRows(fileBuffer, opts);
|
||||
if (!rows.length) return null;
|
||||
return await this.uow.transactional(() => this.persistGpxRows(tripId, rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a GPX file into the places an import would create, without writing
|
||||
* anything. The Tours import reuses it to build its own selection before
|
||||
* handing the rows to {@link importPreparedGpx}.
|
||||
*/
|
||||
prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}): PreparedGpxPlace[] {
|
||||
const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts;
|
||||
|
||||
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
||||
const gpx = parsed?.gpx;
|
||||
if (!gpx) return null;
|
||||
if (!gpx) return [];
|
||||
|
||||
const str = (v: unknown) => (v != null ? String(v).trim() : null);
|
||||
const num = (v: unknown) => {
|
||||
@@ -1019,8 +1051,7 @@ export class PlacesService {
|
||||
return geoSeq === 1 ? base : `${base} ${geoSeq}`;
|
||||
};
|
||||
|
||||
type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string };
|
||||
const waypoints: WaypointEntry[] = [];
|
||||
const waypoints: PreparedGpxPlace[] = [];
|
||||
|
||||
// 1) Parse <wpt> elements (named waypoints / POIs)
|
||||
if (importWaypoints) {
|
||||
@@ -1084,8 +1115,25 @@ export class PlacesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (waypoints.length === 0) return null;
|
||||
return waypoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists rows from {@link prepareGpxRows} and colours their tracks, both in
|
||||
* one transaction. Called from inside the Tours import's own transaction,
|
||||
* where this becomes a savepoint, so the places and their tour facets
|
||||
* commit or roll back together.
|
||||
*/
|
||||
async importPreparedGpx(tripId: string, rows: PreparedGpxPlace[]): Promise<GpxImportResult> {
|
||||
return await this.uow.transactional(async () => {
|
||||
const result = await this.persistGpxRows(tripId, rows);
|
||||
await this.colorizeImportedTracks(tripId, result);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Inserts prepared GPX rows, skipping duplicates. The caller owns the transaction. */
|
||||
private async persistGpxRows(tripId: string, waypoints: PreparedGpxPlace[]): Promise<GpxImportResult> {
|
||||
// Rule 21 / M1 (Task 9 fix wave): non-null asserted, not `?? -1` — the
|
||||
// controller's `requireTrip` (`verifyTripAccess`) already parsed and
|
||||
// gated this SAME `tripId` with `toRowId` before `importGpx` was ever
|
||||
@@ -1103,48 +1151,46 @@ export class PlacesService {
|
||||
// column the legacy INSERT omitted takes the same value the DB column
|
||||
// default would have produced (`duration_minutes: 60`, everything else
|
||||
// `null`) — parity by stored row, not by SQL text. Re-selected via
|
||||
// `findWithTagsAndRatings` INSIDE the same transaction the insert ran
|
||||
// in, so the read sees the still-uncommitted row.
|
||||
await this.uow.transactional(async () => {
|
||||
for (const wp of waypoints) {
|
||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const placeId = await this.placesRepo.insertPlace({
|
||||
trip_id: tid,
|
||||
name: wp.name,
|
||||
description: wp.description,
|
||||
lat: wp.lat,
|
||||
lng: wp.lng,
|
||||
address: null,
|
||||
category_id: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: 60,
|
||||
notes: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
osm_id: null,
|
||||
amap_poi_id: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: wp.routeGeometry || null,
|
||||
route_color: null,
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
email: null,
|
||||
opening_hours: null,
|
||||
});
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
||||
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
|
||||
// sees the still-uncommitted row.
|
||||
for (const wp of waypoints) {
|
||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
});
|
||||
const placeId = await this.placesRepo.insertPlace({
|
||||
trip_id: tid,
|
||||
name: wp.name,
|
||||
description: wp.description,
|
||||
lat: wp.lat,
|
||||
lng: wp.lng,
|
||||
address: null,
|
||||
category_id: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: 60,
|
||||
notes: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
osm_id: null,
|
||||
amap_poi_id: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: wp.routeGeometry || null,
|
||||
route_color: null,
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
email: null,
|
||||
opening_hours: null,
|
||||
});
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
||||
}
|
||||
|
||||
return { places: created, count: created.length, skipped };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { tourCreateRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TourCreateDto extends createZodDto(tourCreateRequestSchema) {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import type { User } from '../../types';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import {
|
||||
Controller,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
|
||||
import { memoryStorage } from 'multer';
|
||||
|
||||
const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } };
|
||||
|
||||
/**
|
||||
* POST /api/trips/:tripId/tours/import/gpx: the tours-mode GPX import.
|
||||
*
|
||||
* A controller of its own because it must not sit behind ToursController's
|
||||
* AddonGuard and TripAccessGuard: a guard answers before multer has read the
|
||||
* multipart body, and the client then sees ECONNRESET instead of the 404 or
|
||||
* 403. The handler runs the same checks in the same order once the upload is
|
||||
* drained, with the guards' own bodies: addon off 404, no trip access 404,
|
||||
* no 'place_edit' 403. PlacesController.importGpx is the sibling.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tours')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ToursImportController {
|
||||
constructor(
|
||||
private readonly tours: ToursService,
|
||||
private readonly places: PlacesService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Post('import/gpx')
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
async importGpx(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.TOURS))) {
|
||||
throw new HttpException({ error: 'Tours addon is not enabled' }, 404);
|
||||
}
|
||||
const trip = await this.places.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
if (!(await this.places.canEdit(trip, user))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
const result = await this.tours.importGpxAsTour(tripId, file.buffer, file.originalname, socketId);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'No track or route found in GPX file' }, 400);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourCreateDto } from './dto/tour-create.dto';
|
||||
import { ToursService } from './tours.service';
|
||||
import { Body, Controller, Get, Headers, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/tours: the Tours facet, read and route edits.
|
||||
* TripAccessGuard resolves
|
||||
* :tripId (404 for no access); writes require 'place_edit'. Assignments stay
|
||||
* on the existing day_edit endpoints. The multipart GPX import lives in
|
||||
* ToursImportController, which checks the same rules in its handler.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tours')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class ToursController {
|
||||
constructor(private readonly tours: ToursService) {}
|
||||
|
||||
@Get()
|
||||
async list(@Param('tripId') tripId: string) {
|
||||
return { tours: await this.tours.listTours(tripId) };
|
||||
}
|
||||
|
||||
@Get(':placeId')
|
||||
async detail(@Param('tripId') tripId: string, @Param('placeId') placeId: string) {
|
||||
return await this.tours.getTour(tripId, placeId);
|
||||
}
|
||||
|
||||
@RequirePermission('place_edit')
|
||||
@Post()
|
||||
async create(
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: TourCreateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return await this.tours.createTour(tripId, body, socketId);
|
||||
}
|
||||
|
||||
@RequirePermission('place_edit')
|
||||
@Put(':placeId')
|
||||
async update(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('placeId') placeId: string,
|
||||
@Body() body: TourCreateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return await this.tours.updateTour(tripId, placeId, body, socketId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Pure geometry-metrics helpers for the Tours domain, separate from GPX parsing.
|
||||
* PlacesService prepares and persists imported places; these helpers derive
|
||||
* metrics from the resulting [[lat,lng,ele?]] geometry.
|
||||
*/
|
||||
|
||||
/** One decoded `route_geometry` point: `[lat, lng]` or `[lat, lng, ele]`. */
|
||||
export type GeometryPoint = [number, number] | [number, number, number];
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
function toRad(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** Great-circle distance between two points, in km. */
|
||||
function haversineKm(a: GeometryPoint, b: GeometryPoint): number {
|
||||
const [lat1, lng1] = a;
|
||||
const [lat2, lng2] = b;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
export interface TourMetrics {
|
||||
/** Sum of segment haversine distances, in km. */
|
||||
distanceKm: number;
|
||||
/** Sum of positive elevation deltas, in metres. Null when the track carries no elevation. */
|
||||
elevationGainM: number | null;
|
||||
/** Sum of negative elevation deltas (as a positive number), in metres. Null when no elevation. */
|
||||
elevationLossM: number | null;
|
||||
/** Whether every point in the geometry carried a 3rd (elevation) value. */
|
||||
hasElevation: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives distance/elevation from a parsed `route_geometry` array. Never throws —
|
||||
* A track with fewer than 2 points, or with missing/mixed
|
||||
* elevation, still yields a usable (if partial) result rather than blocking import.
|
||||
*/
|
||||
export function computeTourMetrics(points: GeometryPoint[]): TourMetrics {
|
||||
if (points.length < 2) {
|
||||
return { distanceKm: 0, elevationGainM: null, elevationLossM: null, hasElevation: false };
|
||||
}
|
||||
const hasElevation = points.every((p) => p.length === 3 && Number.isFinite(p[2]));
|
||||
|
||||
let distanceKm = 0;
|
||||
let gain = 0;
|
||||
let loss = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
distanceKm += haversineKm(points[i - 1], points[i]);
|
||||
if (hasElevation) {
|
||||
const delta = (points[i][2] as number) - (points[i - 1][2] as number);
|
||||
if (delta > 0) gain += delta;
|
||||
else loss += -delta;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
distanceKm,
|
||||
elevationGainM: hasElevation ? gain : null,
|
||||
elevationLossM: hasElevation ? loss : null,
|
||||
hasElevation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a `places.route_geometry` JSON string into typed points. Returns an empty
|
||||
* array for null/malformed input rather than throwing — a place created by the
|
||||
* GPX importer without geometry (shouldn't happen for routes/tracks, but never
|
||||
* trust stored JSON blindly) simply yields a zero-metric tour.
|
||||
*/
|
||||
export function parseRouteGeometry(routeGeometry: string | null | undefined): GeometryPoint[] {
|
||||
if (!routeGeometry) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(routeGeometry);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(p): p is GeometryPoint =>
|
||||
Array.isArray(p) && (p.length === 2 || p.length === 3) && p.every((n: unknown) => typeof n === 'number'),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Confidence threshold: below this, the tour imports with a caution flag. */
|
||||
export const LOW_CONFIDENCE_THRESHOLD = 0.5;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { ToursImportController } from './tours-import.controller';
|
||||
import { ToursController } from './tours.controller';
|
||||
import { ToursService } from './tours.service';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Tours domain: an isolated bounded context that imports PlacesModule to reuse
|
||||
* GPX preparation and persistence through PlacesService. The tour SQL lives in
|
||||
* the Tours and TourWaypoints repositories; the owning place's route columns go
|
||||
* through PlacesRepository, registered here because forFeature only reaches
|
||||
* this module's own providers.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
PlacesModule,
|
||||
PermissionsModule,
|
||||
AuthModule,
|
||||
AddonsModule,
|
||||
MikroOrmModule.forFeature([Tours, TourWaypoints, Places]),
|
||||
],
|
||||
controllers: [ToursController, ToursImportController],
|
||||
providers: [ToursService],
|
||||
})
|
||||
export class ToursModule {}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type { TourListRow, ToursRepository, TourUpdate } from '../../db/repositories/Tours.repository';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { computeTourMetrics, parseRouteGeometry, LOW_CONFIDENCE_THRESHOLD, type GeometryPoint } from './tours.helpers';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
TourCreateRequest,
|
||||
TourCreateResponse,
|
||||
TourDetailResponse,
|
||||
TourListItem,
|
||||
TourWaypoint,
|
||||
} from '@trek/shared';
|
||||
|
||||
export interface ImportGpxAsTourResult {
|
||||
tours: TourListItem[];
|
||||
caution: boolean;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tours domain: the `tours` facet table (place_id PK/FK) carries tour-specific
|
||||
* metadata while existing places and day assignments are reused.
|
||||
* GPX parsing and persistence are reused through PlacesService.prepareGpxRows
|
||||
* and importPreparedGpx. Places and facet rows are persisted in one transaction,
|
||||
* with tour metrics derived from each place's route_geometry.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ToursService {
|
||||
private readonly logger = new Logger(ToursService.name);
|
||||
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly places: PlacesService,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
@InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
) {}
|
||||
|
||||
private toItem(r: TourListRow): TourListItem {
|
||||
return {
|
||||
place_id: r.place_id,
|
||||
name: r.name,
|
||||
tour_type: r.tour_type as TourListItem['tour_type'],
|
||||
distance: r.distance,
|
||||
elevation_gain: r.elevation_gain,
|
||||
elevation_loss: r.elevation_loss,
|
||||
duration: r.duration,
|
||||
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,
|
||||
has_waypoints: Boolean(r.has_waypoints),
|
||||
};
|
||||
}
|
||||
|
||||
/** The route a create or an update writes: metrics derived from the geometry, duration stored in minutes. */
|
||||
private routeFields(input: TourCreateRequest): TourUpdate {
|
||||
const metrics = computeTourMetrics(input.route_geometry as GeometryPoint[]);
|
||||
return {
|
||||
tour_type: input.tour_type,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
duration: input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60),
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
/** All tours (the facet + owning place) for a trip, newest first. */
|
||||
async listTours(tripId: string): Promise<TourListItem[]> {
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) return [];
|
||||
const rows = await this.toursRepo.listForTrip(tid);
|
||||
return rows.map((r) => this.toItem(r));
|
||||
}
|
||||
|
||||
/** One saved tour plus the persisted routing controls needed by the editor. */
|
||||
async getTour(tripId: string, placeId: string): Promise<TourDetailResponse> {
|
||||
const tid = toRowId(tripId);
|
||||
const pid = toRowId(placeId);
|
||||
const row = tid !== null && pid !== null ? await this.toursRepo.findInTrip(tid, pid) : undefined;
|
||||
if (!row) throw new NotFoundException('Tour not found');
|
||||
|
||||
let waypoints: TourWaypoint[] = await this.waypointsRepo.listForPlace(row.place_id);
|
||||
// GPX tours created before tour_waypoints existed still belong in this
|
||||
// all-tours rail. A read-only endpoint must not backfill the database, so
|
||||
// expose their saved geometry endpoints as controls; the first edit/save
|
||||
// replaces them with normal persisted tour_waypoints transactionally.
|
||||
if (waypoints.length < 2) {
|
||||
const place = await this.placesRepo.findInTrip(row.place_id, tid!);
|
||||
const geometry = parseRouteGeometry(place?.route_geometry);
|
||||
if (geometry.length >= 2) {
|
||||
const start = geometry[0];
|
||||
const end = geometry[geometry.length - 1];
|
||||
waypoints = [
|
||||
{ lat: start[0], lng: start[1], role: 'start', sequence: 0 },
|
||||
{ lat: end[0], lng: end[1], role: 'end', sequence: 1 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return { tour: this.toItem(row), waypoints };
|
||||
}
|
||||
|
||||
/** Create the owning Place, Tours facet, and ordered control points as one write. */
|
||||
async createTour(tripId: string, input: TourCreateRequest, socketId?: string): Promise<TourCreateResponse> {
|
||||
// The controller's TripAccessGuard already resolved this trip id.
|
||||
const tid = toRowId(tripId)!;
|
||||
const start = input.route_geometry[0];
|
||||
const fields = this.routeFields(input);
|
||||
|
||||
const placeId = await this.uow.transactional(async () => {
|
||||
const id = await this.placesRepo.insertTourPlace({
|
||||
trip_id: tid,
|
||||
name: input.name,
|
||||
lat: start[0],
|
||||
lng: start[1],
|
||||
route_geometry: JSON.stringify(input.route_geometry),
|
||||
});
|
||||
await this.toursRepo.insertTour({ place_id: id, ...fields });
|
||||
await this.waypointsRepo.insertForPlace(id, input.waypoints);
|
||||
return id;
|
||||
});
|
||||
|
||||
const row = await this.toursRepo.findInTrip(tid, placeId);
|
||||
const place = await this.placesRepo.findWithTagsAndRatings(placeId);
|
||||
if (!row || !place) throw new Error('Created tour could not be loaded');
|
||||
const waypoints = await this.waypointsRepo.listForPlace(placeId);
|
||||
|
||||
// Only announce after every row committed. The originating client reloads
|
||||
// explicitly because socket-id exclusion intentionally suppresses its echo.
|
||||
this.broadcastToursChanged(tripId, [placeId], socketId);
|
||||
try {
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
|
||||
}
|
||||
return { tour: this.toItem(row), waypoints };
|
||||
}
|
||||
|
||||
/** Replace a saved tour's derived route and routing controls as one write. */
|
||||
async updateTour(
|
||||
tripId: string,
|
||||
placeId: string,
|
||||
input: TourCreateRequest,
|
||||
socketId?: string,
|
||||
): Promise<TourDetailResponse> {
|
||||
const tid = toRowId(tripId);
|
||||
const pid = toRowId(placeId);
|
||||
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
|
||||
const start = input.route_geometry[0];
|
||||
const fields = this.routeFields(input);
|
||||
|
||||
await this.uow.transactional(async () => {
|
||||
// The trip predicate is the cross-trip boundary. It runs inside the write
|
||||
// so an id from another accessible trip cannot be moved into the current
|
||||
// one, and a place deleted meanwhile answers 404 instead of failing a
|
||||
// waypoint insert on its foreign key.
|
||||
if (!(await this.toursRepo.findInTrip(tid, pid))) throw new NotFoundException('Tour not found');
|
||||
const placeUpdated = await this.placesRepo.updateTourRoute(pid, tid, {
|
||||
name: input.name,
|
||||
lat: start[0],
|
||||
lng: start[1],
|
||||
route_geometry: JSON.stringify(input.route_geometry),
|
||||
});
|
||||
if (!placeUpdated) throw new NotFoundException('Tour not found');
|
||||
if (!(await this.toursRepo.updateInTrip(tid, pid, fields))) throw new NotFoundException('Tour not found');
|
||||
await this.waypointsRepo.deleteForPlace(pid);
|
||||
await this.waypointsRepo.insertForPlace(pid, input.waypoints);
|
||||
});
|
||||
|
||||
const result = await this.getTour(tripId, placeId);
|
||||
const place = await this.placesRepo.findWithTagsAndRatings(pid);
|
||||
if (!place) throw new Error('Updated tour could not be loaded');
|
||||
this.broadcastToursChanged(tripId, [pid], socketId);
|
||||
try {
|
||||
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private broadcastToursChanged(tripId: string, placeIds: number[], socketId?: string): void {
|
||||
try {
|
||||
this.places.broadcast(tripId, 'tours:changed', { placeIds }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed Tours change for trip ${tripId}: realtime invalidation failed`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tours-mode GPX import prepares rows through PlacesService.prepareGpxRows
|
||||
* with waypoints excluded. PlacesService.importPreparedGpx persists the places
|
||||
* in the same transaction as their `tours` facet rows, whose metrics are
|
||||
* derived from route_geometry.
|
||||
*
|
||||
* Missing or implausible metrics do not block import:
|
||||
* a track without elevation still imports, just flagged with a low
|
||||
* match_confidence so the client can surface a "with caution" toast.
|
||||
*/
|
||||
async importGpxAsTour(
|
||||
tripId: string,
|
||||
fileBuffer: Buffer,
|
||||
defaultName?: string,
|
||||
socketId?: string,
|
||||
): Promise<ImportGpxAsTourResult | null> {
|
||||
const rows = this.places.prepareGpxRows(fileBuffer, {
|
||||
importWaypoints: false,
|
||||
importRoutes: true,
|
||||
importTracks: true,
|
||||
defaultName,
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const tours: TourListItem[] = [];
|
||||
const result = await this.uow.transactional(async () => {
|
||||
// A nested transactional, so the places and their facets commit together.
|
||||
const imported = await this.places.importPreparedGpx(tripId, rows);
|
||||
for (const place of imported.places) {
|
||||
const metrics = computeTourMetrics(parseRouteGeometry(place.route_geometry));
|
||||
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: place.id,
|
||||
tour_type: 'hike',
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
duration: null,
|
||||
match_confidence: matchConfidence,
|
||||
max_hiking_difficulty: 2,
|
||||
});
|
||||
tours.push(
|
||||
this.toItem({
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'hike',
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: matchConfidence,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return imported;
|
||||
});
|
||||
|
||||
if (result.places.length === 0) return { tours: [], caution: false, skipped: result.skipped };
|
||||
this.broadcastToursChanged(
|
||||
tripId,
|
||||
result.places.map((place) => place.id),
|
||||
socketId,
|
||||
);
|
||||
for (const place of result.places) {
|
||||
try {
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`);
|
||||
}
|
||||
}
|
||||
return { tours, caution: tours.some((t) => t.caution), skipped: result.skipped };
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,11 @@ import { RoadtripPreferences } from '../../db/entities/RoadtripPreferences.entit
|
||||
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
|
||||
import { Tags } from '../../db/entities/Tags.entity';
|
||||
import { TodoItems } from '../../db/entities/TodoItems.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type { Trip, User } from '../../types';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { NotFoundError, ValidationError } from '../common/domain-errors';
|
||||
@@ -329,6 +332,16 @@ export class TripsService {
|
||||
return this.em.getRepository(TodoItems);
|
||||
}
|
||||
|
||||
// Tours (#2586): a Tour's facet row and its route waypoints, copied with
|
||||
// the place they hang off. Same `this.em.getRepository(...)` pattern.
|
||||
private get toursRepo() {
|
||||
return this.em.getRepository(Tours);
|
||||
}
|
||||
|
||||
private get tourWaypointsRepo() {
|
||||
return this.em.getRepository(TourWaypoints);
|
||||
}
|
||||
|
||||
async canAccessTrip(tripId: string | number, userId: number) {
|
||||
const access = await this.em.getRepository(Trips).findAccessible(tripId, userId);
|
||||
return access as { user_id: number } | null | undefined;
|
||||
@@ -686,8 +699,9 @@ export class TripsService {
|
||||
// ── Copy / duplicate ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Duplicates a trip (all days, places, assignments, accommodations, reservations,
|
||||
* budget, packing bags/items, day notes) into a new trip owned by `newOwnerId`.
|
||||
* Duplicates a trip (all days, places with their Tour facets and waypoints,
|
||||
* assignments, accommodations, reservations, budget, packing bags/items, day
|
||||
* notes) into a new trip owned by `newOwnerId`.
|
||||
* Cross-links are remapped to the copied rows (reservation↔budget item,
|
||||
* reservation↔accommodation) and split data travels with the copy
|
||||
* (budget_item_members/payers incl. paid flags, assignment_participants).
|
||||
@@ -804,6 +818,31 @@ export class TripsService {
|
||||
if (newPlaceId) await this.tagsRepo.insertIgnore(newPlaceId, [t.tag_id]); // TP47
|
||||
}
|
||||
|
||||
// A Tour is a place plus its `tours` facet and the waypoints its route was
|
||||
// planned through. Without them the copy holds an ordinary place with a
|
||||
// line on it: no longer listed as a Tour, and its route can't be edited
|
||||
// because the points it was drawn from are gone. The facet row goes first,
|
||||
// since the waypoints reference it.
|
||||
const oldTours = await this.toursRepo.listRowsForTrip(Number(sourceTripId));
|
||||
const copiedTours = new Set<number>();
|
||||
for (const { place_id, ...tour } of oldTours) {
|
||||
const newPlaceId = placeMap.get(place_id);
|
||||
if (!newPlaceId) continue;
|
||||
await this.toursRepo.insertCopy(newPlaceId, tour);
|
||||
copiedTours.add(place_id);
|
||||
}
|
||||
|
||||
const waypointsByPlace = new Map<number, TourWaypointRow[]>();
|
||||
for (const { place_id, ...waypoint } of await this.tourWaypointsRepo.listForTrip(Number(sourceTripId))) {
|
||||
if (!copiedTours.has(place_id)) continue;
|
||||
const list = waypointsByPlace.get(place_id) ?? [];
|
||||
list.push(waypoint);
|
||||
waypointsByPlace.set(place_id, list);
|
||||
}
|
||||
for (const [placeId, waypoints] of waypointsByPlace) {
|
||||
await this.tourWaypointsRepo.insertForPlace(placeMap.get(placeId)!, waypoints);
|
||||
}
|
||||
|
||||
const oldAssignments = await this.dayAssignmentsRepo.listAllForTrip(sourceTripId); // TP48
|
||||
const assignmentMap = new Map<number, number>();
|
||||
for (const a of oldAssignments) {
|
||||
|
||||
@@ -371,6 +371,8 @@ export interface AssignmentRow extends DayAssignment {
|
||||
stop_type: string | null;
|
||||
/** How full this stop fills the tank, 1-100; null follows the traveller's own setting. */
|
||||
fill_percent: number | null;
|
||||
tour_place_id?: number | null;
|
||||
tour_route_geometry?: string | null;
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
category_icon: string | null;
|
||||
|
||||
@@ -130,6 +130,61 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('200 list projects Tour facets once per assignment and leaves other tracks ordinary', async () => {
|
||||
const legacyGeometry = JSON.stringify([
|
||||
[48, 11, 600],
|
||||
[48.01, 11.02, 650],
|
||||
]);
|
||||
const tourGeometry = JSON.stringify([
|
||||
[48.02, 11.03, 700],
|
||||
[48.03, 11.04, 750],
|
||||
]);
|
||||
db.prepare('INSERT INTO places (id, trip_id, name, route_geometry) VALUES (3, 5, ?, ?), (4, 5, ?, ?)').run(
|
||||
'Legacy track',
|
||||
legacyGeometry,
|
||||
'Tour',
|
||||
tourGeometry,
|
||||
);
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (4, 'hike')").run();
|
||||
const assignmentIds = [
|
||||
seedAssignment(3, 2, 0),
|
||||
seedAssignment(3, 3, 1),
|
||||
seedAssignment(3, 4, 2),
|
||||
seedAssignment(3, 4, 3),
|
||||
];
|
||||
|
||||
const res = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const assignments = res.body.assignments as Array<{
|
||||
id: number;
|
||||
place_id: number;
|
||||
order_index: number;
|
||||
tour_place_id: number | null;
|
||||
tour_route_geometry: string | null;
|
||||
}>;
|
||||
expect(assignments).toHaveLength(4);
|
||||
expect(assignments.map((a) => a.id)).toEqual(assignmentIds);
|
||||
expect(assignments.map((a) => a.order_index)).toEqual([0, 1, 2, 3]);
|
||||
expect(assignments[0]).toMatchObject({ place_id: 2, tour_place_id: null, tour_route_geometry: null });
|
||||
expect(db.prepare('SELECT route_geometry FROM places WHERE id = 3').get()).toEqual({
|
||||
route_geometry: legacyGeometry,
|
||||
});
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 3').get()).toBeUndefined();
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 4').get()).toEqual({ place_id: 4 });
|
||||
expect(assignments[1]).toMatchObject({ place_id: 3, tour_place_id: null, tour_route_geometry: null });
|
||||
expect(
|
||||
assignments.slice(2).map((a) => ({
|
||||
place_id: a.place_id,
|
||||
tour_place_id: a.tour_place_id,
|
||||
tour_route_geometry: a.tour_route_geometry,
|
||||
})),
|
||||
).toEqual([
|
||||
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
||||
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
||||
]);
|
||||
});
|
||||
|
||||
it('201 create, 404 place', async () => {
|
||||
reconcileTripSkeletons.mockClear();
|
||||
const ok = await request(server)
|
||||
@@ -155,6 +210,49 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(miss.body).toEqual({ error: 'Place not found' });
|
||||
});
|
||||
|
||||
it('prevents duplicate Tour/day assignments through concurrent REST requests', async () => {
|
||||
const tourPlaceId = 20;
|
||||
db.prepare('INSERT INTO places (id, trip_id, name) VALUES (?, 5, ?)').run(tourPlaceId, 'Ridge walk');
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')").run(tourPlaceId);
|
||||
try {
|
||||
const createTourAssignment = () =>
|
||||
request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ place_id: tourPlaceId });
|
||||
const results = await Promise.all([createTourAssignment(), createTourAssignment()]);
|
||||
|
||||
expect(results.map((result) => result.status).sort()).toEqual([201, 409]);
|
||||
expect(
|
||||
db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = 3 AND place_id = ?').get(tourPlaceId),
|
||||
).toEqual({ n: 1 });
|
||||
|
||||
const otherDay = await request(server)
|
||||
.post('/api/trips/5/days/4/assignments')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ place_id: tourPlaceId });
|
||||
expect(otherDay.status).toBe(201);
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlaceId)).toEqual({
|
||||
n: 2,
|
||||
});
|
||||
|
||||
const firstOrdinary = await request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ place_id: 2 });
|
||||
const secondOrdinary = await request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ place_id: 2 });
|
||||
expect(firstOrdinary.status).toBe(201);
|
||||
expect(secondOrdinary.status).toBe(201);
|
||||
} finally {
|
||||
db.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(tourPlaceId);
|
||||
db.prepare('DELETE FROM tours WHERE place_id = ?').run(tourPlaceId);
|
||||
db.prepare('DELETE FROM places WHERE id = ?').run(tourPlaceId);
|
||||
}
|
||||
});
|
||||
|
||||
it('200 delete assignment reconciles journey skeletons', async () => {
|
||||
reconcileTripSkeletons.mockClear();
|
||||
const id = seedAssignment();
|
||||
@@ -224,6 +322,42 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined);
|
||||
});
|
||||
|
||||
it('409 moving a Tour onto a day that already holds it, the row stays put; a reorder within its day still works', async () => {
|
||||
const tourPlaceId = 21;
|
||||
db.prepare('INSERT INTO places (id, trip_id, name) VALUES (?, 5, ?)').run(tourPlaceId, 'Lake loop');
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')").run(tourPlaceId);
|
||||
try {
|
||||
seedAssignment(4, tourPlaceId, 0);
|
||||
const moving = seedAssignment(3, tourPlaceId, 0);
|
||||
const conflict = await request(server)
|
||||
.put(`/api/trips/5/assignments/${moving}/move`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ new_day_id: 4, order_index: 1 });
|
||||
expect(conflict.status).toBe(409);
|
||||
expect(conflict.body).toEqual({ error: 'Tour is already assigned to this day' });
|
||||
expect(db.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(moving)).toEqual({
|
||||
day_id: 3,
|
||||
order_index: 0,
|
||||
});
|
||||
|
||||
const reorder = await request(server)
|
||||
.put(`/api/trips/5/assignments/${moving}/move`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ new_day_id: 3, order_index: 2 });
|
||||
expect(reorder.status).toBe(200);
|
||||
expect(reorder.body.assignment).toMatchObject({
|
||||
id: moving,
|
||||
day_id: 3,
|
||||
order_index: 2,
|
||||
tour_place_id: tourPlaceId,
|
||||
});
|
||||
} finally {
|
||||
db.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(tourPlaceId);
|
||||
db.prepare('DELETE FROM tours WHERE place_id = ?').run(tourPlaceId);
|
||||
db.prepare('DELETE FROM places WHERE id = ?').run(tourPlaceId);
|
||||
}
|
||||
});
|
||||
|
||||
it('200 notes roundtrip: create with note, PUT edits it, GET list shows the new value (#2163)', async () => {
|
||||
const create = await request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', (
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare('DELETE FROM day_notes').run();
|
||||
db.prepare('DELETE FROM day_assignments').run();
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
@@ -104,6 +105,59 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', (
|
||||
expect(res.body.days[0]).toMatchObject({ id: 3, trip_id: 5, day_number: 1, assignments: [], notes_items: [] });
|
||||
});
|
||||
|
||||
it('200 list projects Tour facets once per assignment and leaves other tracks ordinary', async () => {
|
||||
const legacyGeometry = JSON.stringify([
|
||||
[48, 11, 600],
|
||||
[48.01, 11.02, 650],
|
||||
]);
|
||||
const tourGeometry = JSON.stringify([
|
||||
[48.02, 11.03, 700],
|
||||
[48.03, 11.04, 750],
|
||||
]);
|
||||
db.prepare(
|
||||
'INSERT INTO places (id, trip_id, name, route_geometry) VALUES (2, 5, ?, ?), (3, 5, ?, ?), (4, 5, ?, ?)',
|
||||
).run('Ordinary', null, 'Legacy track', legacyGeometry, 'Tour', tourGeometry);
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (4, 'hike')").run();
|
||||
const assignmentIds = [2, 3, 4, 4].map((placeId, orderIndex) =>
|
||||
Number(
|
||||
db
|
||||
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (3, ?, ?)')
|
||||
.run(placeId, orderIndex).lastInsertRowid,
|
||||
),
|
||||
);
|
||||
|
||||
const res = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const assignments = res.body.days[0].assignments as Array<{
|
||||
id: number;
|
||||
order_index: number;
|
||||
tour_place_id: number | null;
|
||||
tour_route_geometry: string | null;
|
||||
place: { id: number };
|
||||
}>;
|
||||
expect(assignments).toHaveLength(4);
|
||||
expect(assignments.map((a) => a.id)).toEqual(assignmentIds);
|
||||
expect(assignments.map((a) => a.order_index)).toEqual([0, 1, 2, 3]);
|
||||
expect(assignments[0]).toMatchObject({ place: { id: 2 }, tour_place_id: null, tour_route_geometry: null });
|
||||
expect(db.prepare('SELECT route_geometry FROM places WHERE id = 3').get()).toEqual({
|
||||
route_geometry: legacyGeometry,
|
||||
});
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 3').get()).toBeUndefined();
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 4').get()).toEqual({ place_id: 4 });
|
||||
expect(assignments[1]).toMatchObject({ place: { id: 3 }, tour_place_id: null, tour_route_geometry: null });
|
||||
expect(
|
||||
assignments.slice(2).map((a) => ({
|
||||
place_id: a.place.id,
|
||||
tour_place_id: a.tour_place_id,
|
||||
tour_route_geometry: a.tour_route_geometry,
|
||||
})),
|
||||
).toEqual([
|
||||
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
||||
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
||||
]);
|
||||
});
|
||||
|
||||
it('201 create day (real insert, auto day_number), 404 trip when not accessible', async () => {
|
||||
const ok = await request(server)
|
||||
.post('/api/trips/5/days')
|
||||
|
||||
@@ -125,6 +125,20 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.body.places[0]).toMatchObject({ id: 1, name: 'Spot', trip_id: 5, tags: [], ratings: [] });
|
||||
});
|
||||
|
||||
it('marks Tour-backed Places without changing ordinary Place rows', async () => {
|
||||
db.prepare("INSERT INTO places (id, trip_id, name) VALUES (1, 5, 'Plain'), (2, 5, 'Tour')").run();
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (2, 'hike')").run();
|
||||
|
||||
const all = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1));
|
||||
expect(all.status).toBe(200);
|
||||
expect(all.body.places).toHaveLength(2);
|
||||
const tourPlaceIdById = new Map(
|
||||
all.body.places.map((place: { id: number; tour_place_id: number | null }) => [place.id, place.tour_place_id]),
|
||||
);
|
||||
expect(tourPlaceIdById.get(1)).toBeNull();
|
||||
expect(tourPlaceIdById.get(2)).toBe(2);
|
||||
});
|
||||
|
||||
it('200 list scoped to the trip', async () => {
|
||||
db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Mine')").run();
|
||||
db.prepare("INSERT INTO places (trip_id, name) VALUES (6, 'Theirs')").run();
|
||||
@@ -162,7 +176,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ ids: [1, 2, 3] });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ deleted: [1, 2], count: 2 });
|
||||
expect(ok.body).toEqual({ deleted: [1, 2], count: 2, tourPlaceIds: [] });
|
||||
// The foreign trip's place is untouched.
|
||||
expect(db.prepare('SELECT id FROM places ORDER BY id').all()).toEqual([{ id: 3 }]);
|
||||
|
||||
@@ -331,7 +345,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
|
||||
const ok = await request(server).delete('/api/trips/5/places/9').set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ success: true });
|
||||
expect(ok.body).toEqual({ success: true, tourPlaceIds: [] });
|
||||
expect(db.prepare('SELECT id FROM places WHERE id = 9').get()).toBeUndefined();
|
||||
|
||||
const foreign = await request(server).delete('/api/trips/5/places/10').set('Cookie', sessionCookie(1));
|
||||
@@ -339,6 +353,25 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(foreign.body).toEqual({ error: 'Place not found' });
|
||||
});
|
||||
|
||||
it('DELETE :id and bulk-delete report the Tour place ids they removed', async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO places (id, trip_id, name) VALUES (20, 5, 'Tour A'), (21, 5, 'Tour B'), (22, 5, 'Plain')",
|
||||
).run();
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (20, 'hike'), (21, 'hike')").run();
|
||||
|
||||
const single = await request(server).delete('/api/trips/5/places/20').set('Cookie', sessionCookie(1));
|
||||
expect(single.status).toBe(200);
|
||||
expect(single.body).toEqual({ success: true, tourPlaceIds: [20] });
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 20').get()).toBeUndefined();
|
||||
|
||||
const bulk = await request(server)
|
||||
.post('/api/trips/5/places/bulk-delete')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ ids: [21, 22] });
|
||||
expect(bulk.status).toBe(200);
|
||||
expect(bulk.body).toEqual({ deleted: [21, 22], count: 2, tourPlaceIds: [21] });
|
||||
});
|
||||
|
||||
it('DELETE :id takes the expense linked to the place with it (#1298)', async () => {
|
||||
db.prepare("INSERT INTO places (id, trip_id, name) VALUES (12, 5, 'Louvre')").run();
|
||||
db.prepare(
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Tours module e2e: every /api/trips/:tripId/tours endpoint through the real
|
||||
* JwtAuthGuard, AddonGuard and TripAccessGuard against a real migrated and
|
||||
* seeded temp SQLite db (createSnapshotTestDb()). ToursService, PlacesService,
|
||||
* AssignmentsService and TripsService run their real SQL; only the permission
|
||||
* check (a container spy), the journey hooks and the WebSocket broadcast are
|
||||
* stubbed.
|
||||
*
|
||||
* Fixtures: user 1 owns trips 5 and 6, user 2 is a member of trip 5 whom the
|
||||
* permission spy denies 'place_edit', user 3 has no access to either trip.
|
||||
* AssignmentsModule and TripsModule are mounted next to ToursModule so the
|
||||
* one-tour-per-day 409 on move and the trip copy run through their real routes.
|
||||
*/
|
||||
import { db } from '../../src/db/database';
|
||||
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
|
||||
import { BudgetService } from '../../src/nest/budget/budget.service';
|
||||
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 { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { ToursModule } from '../../src/nest/tours/tours.module';
|
||||
import { TripsModule } from '../../src/nest/trips/trips.module';
|
||||
import { createTestMikroOrmModule } 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/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast }));
|
||||
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),
|
||||
},
|
||||
}));
|
||||
|
||||
let checkPermission: MockInstance;
|
||||
|
||||
const OWNER = 1;
|
||||
const VIEWER = 2;
|
||||
const OUTSIDER = 3;
|
||||
|
||||
const GEOMETRY = [
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 700],
|
||||
[47.02, 11.02, 650],
|
||||
];
|
||||
const tourBody = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'Ridge walk',
|
||||
route_geometry: GEOMETRY,
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 3,
|
||||
duration_seconds: 3600,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const TRACK_GPX = `<?xml version="1.0"?>
|
||||
<gpx version="1.1" creator="e2e"><trk><name>Alpine loop</name><trkseg>
|
||||
<trkpt lat="49" lon="12"><ele>100</ele></trkpt>
|
||||
<trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
|
||||
<trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt>
|
||||
</trkseg></trk></gpx>`;
|
||||
const FLAT_GPX = `<?xml version="1.0"?>
|
||||
<gpx version="1.1" creator="e2e"><trk><name>Flat path</name><trkseg>
|
||||
<trkpt lat="50" lon="13"/><trkpt lat="50.01" lon="13.01"/>
|
||||
</trkseg></trk></gpx>`;
|
||||
// Large enough that a guard rejecting before multer drained the body would
|
||||
// surface as a connection reset instead of a JSON response.
|
||||
const BIG_GPX = TRACK_GPX.replace('</gpx>', `<!-- ${'x'.repeat(512 * 1024)} --></gpx>`);
|
||||
|
||||
describe('Tours 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,
|
||||
AssignmentsModule,
|
||||
TripsModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(JourneyDomainService)
|
||||
.useValue(journeyHooks)
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const setAddon = (enabled: boolean) =>
|
||||
db.prepare("UPDATE addons SET enabled = ? WHERE id = 'tours'").run(enabled ? 1 : 0);
|
||||
|
||||
const createViaApi = async (tripId = 5, body: Record<string, unknown> = tourBody()) => {
|
||||
const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER)).send(body);
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.tour.place_id as number;
|
||||
};
|
||||
|
||||
const importGpx = (userId: number, xml: string | null, tripId = 5) => {
|
||||
const req = request(server).post(`/api/trips/${tripId}/tours/import/gpx`).set('Cookie', sessionCookie(userId));
|
||||
return xml === null
|
||||
? req.field('note', 'none')
|
||||
: req.attach('file', Buffer.from(xml), { filename: 'track.gpx', contentType: 'application/gpx+xml' });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES
|
||||
(1, 'owner', 'owner@example.test', 'x', 'user', 0),
|
||||
(2, 'viewer', 'viewer@example.test', 'x', 'user', 0),
|
||||
(3, 'outsider', 'outsider@example.test', 'x', 'user', 0)`,
|
||||
).run();
|
||||
db.prepare("INSERT INTO trips (id, user_id, title) VALUES (5, 1, 'Alps'), (6, 1, 'Other')").run();
|
||||
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run();
|
||||
db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1), (4, 5, 2)').run();
|
||||
app = await build();
|
||||
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
||||
vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockResolvedValue([]);
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// The viewer is a trip member whose 'place_edit' is withheld; every other
|
||||
// action and user passes, so the owner can create, move and copy.
|
||||
checkPermission.mockImplementation(
|
||||
(action: string, _role: string, _owner: number, userId: number) =>
|
||||
!(action === 'place_edit' && userId === VIEWER),
|
||||
);
|
||||
setAddon(true);
|
||||
broadcast.mockClear();
|
||||
db.prepare('DELETE FROM day_assignments').run();
|
||||
db.prepare('DELETE FROM places').run();
|
||||
db.prepare('DELETE FROM trips WHERE id NOT IN (5, 6)').run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('addon and access gates', () => {
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/tours')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('404 on every route while the addon is disabled', async () => {
|
||||
const placeId = await createViaApi();
|
||||
setAddon(false);
|
||||
const off = { error: 'Tours addon is not enabled' };
|
||||
const cookie = sessionCookie(OWNER);
|
||||
const responses = [
|
||||
await request(server).get('/api/trips/5/tours').set('Cookie', cookie),
|
||||
await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie),
|
||||
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
||||
await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ name: 'Renamed' })),
|
||||
await importGpx(OWNER, BIG_GPX),
|
||||
];
|
||||
for (const res of responses) {
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual(off);
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 });
|
||||
expect(db.prepare('SELECT name FROM places WHERE id = ?').get(placeId)).toEqual({ name: 'Ridge walk' });
|
||||
});
|
||||
|
||||
it('404 Trip not found for a user without access to the trip', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const cookie = sessionCookie(OUTSIDER);
|
||||
const responses = [
|
||||
await request(server).get('/api/trips/5/tours').set('Cookie', cookie),
|
||||
await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie),
|
||||
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
||||
await request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie).send(tourBody()),
|
||||
await importGpx(OUTSIDER, BIG_GPX),
|
||||
await request(server).get('/api/trips/999/tours').set('Cookie', sessionCookie(OWNER)),
|
||||
];
|
||||
for (const res of responses) {
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('a member without place_edit can read but gets 403 on every write', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const cookie = sessionCookie(VIEWER);
|
||||
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', cookie);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.tours).toHaveLength(1);
|
||||
expect((await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie)).status).toBe(200);
|
||||
|
||||
const writes = [
|
||||
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
||||
await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ name: 'Hijacked' })),
|
||||
await importGpx(VIEWER, BIG_GPX),
|
||||
];
|
||||
for (const res of writes) {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission' });
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 });
|
||||
expect(db.prepare('SELECT name FROM places WHERE id = ?').get(placeId)).toEqual({ name: 'Ridge walk' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST and GET', () => {
|
||||
it('201 creates the place, the facet and its waypoints, then lists and reads them back', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.set('X-Socket-Id', 'sock-1')
|
||||
.send(tourBody());
|
||||
expect(res.status).toBe(201);
|
||||
const placeId = res.body.tour.place_id as number;
|
||||
expect(res.body.tour).toMatchObject({
|
||||
place_id: placeId,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
duration: 60,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 3,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: true,
|
||||
});
|
||||
expect(res.body.tour.distance).toBeGreaterThan(0);
|
||||
expect(res.body.tour.elevation_gain).toBe(100);
|
||||
expect(res.body.tour.elevation_loss).toBe(50);
|
||||
expect(res.body.waypoints).toEqual(tourBody().waypoints);
|
||||
|
||||
expect(
|
||||
db.prepare('SELECT trip_id, lat, lng, transport_mode, route_geometry FROM places WHERE id = ?').get(placeId),
|
||||
).toEqual({
|
||||
trip_id: 5,
|
||||
lat: 47,
|
||||
lng: 11,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: JSON.stringify(GEOMETRY),
|
||||
});
|
||||
const events = broadcast.mock.calls.map((c) => c[1]);
|
||||
expect(events).toEqual(expect.arrayContaining(['tours:changed', 'place:created']));
|
||||
expect(broadcast.mock.calls.every((c) => c[3] === 'sock-1')).toBe(true);
|
||||
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.tours).toEqual([res.body.tour]);
|
||||
|
||||
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body).toEqual(res.body);
|
||||
});
|
||||
|
||||
it('lists newest first and reports planned once a day holds the tour', async () => {
|
||||
const first = await createViaApi(5, tourBody({ name: 'First' }));
|
||||
const second = await createViaApi(5, tourBody({ name: 'Second' }));
|
||||
db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (3, ?, 0)').run(first);
|
||||
const res = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
||||
expect(res.body.tours.map((t: { place_id: number; planned: boolean }) => [t.place_id, t.planned])).toEqual([
|
||||
[second, false],
|
||||
[first, true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('400 for an invalid body, nothing written', async () => {
|
||||
const cookie = sessionCookie(OWNER);
|
||||
const badRole = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', cookie)
|
||||
.send(
|
||||
tourBody({
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'via', sequence: 0 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(badRole.status).toBe(400);
|
||||
const shortRoute = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ route_geometry: [[47, 11, 600]] }));
|
||||
expect(shortRoute.status).toBe(400);
|
||||
const badDifficulty = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ max_hiking_difficulty: 7 }));
|
||||
expect(badDifficulty.status).toBe(400);
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM places').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('GET 404 for a tour of another trip, a plain place and a non-numeric id', async () => {
|
||||
const foreign = await createViaApi(6);
|
||||
const plain = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Cafe')").run().lastInsertRowid);
|
||||
for (const id of [foreign, plain, 'abc', 999999]) {
|
||||
const res = await request(server).get(`/api/trips/5/tours/${id}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Tour not found' });
|
||||
}
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
||||
expect(list.body.tours).toEqual([]);
|
||||
});
|
||||
|
||||
it('GET falls back to the geometry endpoints for a tour without saved waypoints', async () => {
|
||||
const res = await importGpx(OWNER, TRACK_GPX);
|
||||
expect(res.status).toBe(201);
|
||||
const placeId = res.body.tours[0].place_id as number;
|
||||
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.tour).toMatchObject({ place_id: placeId, has_waypoints: false });
|
||||
expect(detail.body.waypoints).toEqual([
|
||||
{ lat: 49, lng: 12, role: 'start', sequence: 0 },
|
||||
{ lat: 49.02, lng: 12.02, role: 'end', sequence: 1 },
|
||||
]);
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
it('200 replaces the route, metrics and waypoints', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const geometry = [
|
||||
[46, 10, 1000],
|
||||
[46.01, 10.01, 1200],
|
||||
[46.02, 10.02, 1100],
|
||||
];
|
||||
const waypoints = [
|
||||
{ lat: 46, lng: 10, role: 'start', sequence: 0 },
|
||||
{ lat: 46.01, lng: 10.01, role: 'via', sequence: 1 },
|
||||
{ lat: 46.02, lng: 10.02, role: 'end', sequence: 2 },
|
||||
];
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(
|
||||
tourBody({
|
||||
name: 'Summit',
|
||||
route_geometry: geometry,
|
||||
waypoints,
|
||||
max_hiking_difficulty: 5,
|
||||
duration_seconds: null,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tour).toMatchObject({
|
||||
place_id: placeId,
|
||||
name: 'Summit',
|
||||
elevation_gain: 200,
|
||||
elevation_loss: 100,
|
||||
duration: null,
|
||||
max_hiking_difficulty: 5,
|
||||
});
|
||||
expect(res.body.waypoints).toEqual(waypoints);
|
||||
expect(db.prepare('SELECT name, lat, lng, route_geometry FROM places WHERE id = ?').get(placeId)).toEqual({
|
||||
name: 'Summit',
|
||||
lat: 46,
|
||||
lng: 10,
|
||||
route_geometry: JSON.stringify(geometry),
|
||||
});
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 3 });
|
||||
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
|
||||
});
|
||||
|
||||
it('404 for a tour of another trip, a plain place and a non-numeric id, nothing changes', async () => {
|
||||
const foreign = await createViaApi(6);
|
||||
const plain = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Cafe')").run().lastInsertRowid);
|
||||
for (const id of [foreign, plain, 'abc']) {
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/5/tours/${id}`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(tourBody({ name: 'Moved' }));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Tour not found' });
|
||||
}
|
||||
expect(db.prepare('SELECT trip_id, name FROM places WHERE id = ?').get(foreign)).toEqual({
|
||||
trip_id: 6,
|
||||
name: 'Ridge walk',
|
||||
});
|
||||
expect(db.prepare('SELECT name, route_geometry FROM places WHERE id = ?').get(plain)).toEqual({
|
||||
name: 'Cafe',
|
||||
route_geometry: null,
|
||||
});
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours WHERE place_id = ?').get(plain)).toEqual({ n: 0 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(foreign)).toEqual({ n: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST import/gpx', () => {
|
||||
it('201 imports each track as a hike tour', async () => {
|
||||
const res = await importGpx(OWNER, TRACK_GPX);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({ caution: false, skipped: 0 });
|
||||
expect(res.body.tours).toHaveLength(1);
|
||||
const tour = res.body.tours[0];
|
||||
expect(tour).toMatchObject({
|
||||
name: 'Alpine loop',
|
||||
tour_type: 'hike',
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
has_waypoints: false,
|
||||
});
|
||||
expect(db.prepare('SELECT trip_id FROM places WHERE id = ?').get(tour.place_id)).toEqual({ trip_id: 5 });
|
||||
expect(db.prepare('SELECT tour_type FROM tours WHERE place_id = ?').get(tour.place_id)).toEqual({
|
||||
tour_type: 'hike',
|
||||
});
|
||||
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:created']));
|
||||
});
|
||||
|
||||
it('flags a track without elevation with caution', async () => {
|
||||
const res = await importGpx(OWNER, FLAT_GPX);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.caution).toBe(true);
|
||||
expect(res.body.tours[0]).toMatchObject({ match_confidence: 0.3, caution: true });
|
||||
});
|
||||
|
||||
it('a second import of the same track is skipped as a duplicate', async () => {
|
||||
expect((await importGpx(OWNER, TRACK_GPX)).status).toBe(201);
|
||||
const again = await importGpx(OWNER, TRACK_GPX);
|
||||
expect(again.status).toBe(201);
|
||||
expect(again.body).toEqual({ tours: [], caution: false, skipped: 1 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('400 without a file, and for a file with no track or route', async () => {
|
||||
const missing = await importGpx(OWNER, null);
|
||||
expect(missing.status).toBe(400);
|
||||
expect(missing.body).toEqual({ error: 'No file uploaded' });
|
||||
for (const xml of ['<not-gpx/>', '<gpx/>', '<gpx><wpt lat="1" lon="2"/></gpx>', 'plain text']) {
|
||||
const res = await importGpx(OWNER, xml);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'No track or route found in GPX file' });
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM places').get()).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('other domains', () => {
|
||||
it('409 moving a tour onto a day that already holds it, on create and on move', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const cookie = sessionCookie(OWNER);
|
||||
const onDay4 = await request(server)
|
||||
.post('/api/trips/5/days/4/assignments')
|
||||
.set('Cookie', cookie)
|
||||
.send({ place_id: placeId });
|
||||
expect(onDay4.status).toBe(201);
|
||||
const onDay3 = await request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
.set('Cookie', cookie)
|
||||
.send({ place_id: placeId });
|
||||
expect(onDay3.status).toBe(201);
|
||||
|
||||
const twice = await request(server)
|
||||
.post('/api/trips/5/days/4/assignments')
|
||||
.set('Cookie', cookie)
|
||||
.send({ place_id: placeId });
|
||||
expect(twice.status).toBe(409);
|
||||
expect(twice.body).toEqual({ error: 'Tour is already assigned to this day' });
|
||||
|
||||
const moving = onDay3.body.assignment.id as number;
|
||||
const move = await request(server)
|
||||
.put(`/api/trips/5/assignments/${moving}/move`)
|
||||
.set('Cookie', cookie)
|
||||
.send({ new_day_id: 4, order_index: 1 });
|
||||
expect(move.status).toBe(409);
|
||||
expect(move.body).toEqual({ error: 'Tour is already assigned to this day' });
|
||||
expect(db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(moving)).toEqual({ day_id: 3 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(placeId)).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it('copying the trip carries its tours and waypoints onto the new places', async () => {
|
||||
const placeId = await createViaApi(
|
||||
5,
|
||||
tourBody({
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/copy')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ title: 'Alps again' });
|
||||
expect(res.status).toBe(201);
|
||||
const copyId = res.body.trip.id as number;
|
||||
|
||||
const list = await request(server).get(`/api/trips/${copyId}/tours`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.tours).toHaveLength(1);
|
||||
const copied = list.body.tours[0];
|
||||
expect(copied.place_id).not.toBe(placeId);
|
||||
const source = (await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)))
|
||||
.body;
|
||||
const { place_id: _sourceId, ...sourceRest } = source.tour;
|
||||
const { place_id: _copyId, ...copyRest } = copied;
|
||||
expect(copyRest).toEqual(sourceRest);
|
||||
|
||||
const detail = await request(server)
|
||||
.get(`/api/trips/${copyId}/tours/${copied.place_id}`)
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body.waypoints).toEqual(source.waypoints);
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 3 });
|
||||
});
|
||||
|
||||
it('deleting the place removes its tour and waypoints and reports the tour id', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const res = await request(server).delete(`/api/trips/5/places/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, tourPlaceIds: [placeId] });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours WHERE place_id = ?').get(placeId)).toEqual({ n: 0 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
sharedTestOrm,
|
||||
createTestTripsRepo,
|
||||
} from './test-uow';
|
||||
import { createTestToursRepo } from './tours-repos';
|
||||
|
||||
import type { Database } from 'better-sqlite3';
|
||||
|
||||
@@ -94,6 +95,7 @@ export async function accommodationsOver(conn: Database): Promise<Accommodations
|
||||
await createTestPlacesRepo(conn),
|
||||
await createTestTripMembersRepo(conn),
|
||||
await createTestRoadtripViasRepo(conn),
|
||||
await createTestToursRepo(conn),
|
||||
);
|
||||
return new AccommodationsService(
|
||||
permissions,
|
||||
|
||||
@@ -221,6 +221,7 @@ import {
|
||||
sharedTestOrm,
|
||||
} from './test-uow';
|
||||
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from './todo-repos';
|
||||
import { createTestToursRepo } from './tours-repos';
|
||||
import {
|
||||
createTestVacayPlansRepo,
|
||||
createTestVacayPlanMembersRepo,
|
||||
@@ -432,6 +433,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
await createTestPlacesRepo(db),
|
||||
await createTestTripMembersRepo(db),
|
||||
await createTestRoadtripViasRepo(db),
|
||||
await createTestToursRepo(db),
|
||||
);
|
||||
const accommodationsService = new AccommodationsService(
|
||||
permissionsService,
|
||||
|
||||
@@ -165,6 +165,7 @@ import {
|
||||
createTestRoadtripDayBoundariesRepo,
|
||||
} from './test-uow';
|
||||
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from './todo-repos';
|
||||
import { createTestToursRepo } from './tours-repos';
|
||||
import {
|
||||
createTestVacayPlansRepo,
|
||||
createTestVacayPlanMembersRepo,
|
||||
@@ -389,6 +390,7 @@ export async function createPluginRpcHostFactory(db: Database.Database): Promise
|
||||
await createTestPlacesRepo(db),
|
||||
await createTestTripMembersRepo(db),
|
||||
await createTestRoadtripViasRepo(db),
|
||||
await createTestToursRepo(db),
|
||||
);
|
||||
const membership = new TripMembershipService(await createTestTripsRepo(db), await createTestTripMembersRepo(db));
|
||||
const notifications = await makeNotificationsService(db, realtime);
|
||||
|
||||
@@ -51,6 +51,9 @@ const KEEP_TABLES = new Set([
|
||||
'photo_provider_fields',
|
||||
'document_providers',
|
||||
'document_provider_fields',
|
||||
// Reference data the migration that creates it writes (`tours.tour_type`
|
||||
// has an FK on it); nothing re-seeds it, so a reset must keep it.
|
||||
'tour_types',
|
||||
// Migration bookkeeping: clearing these would make the next boot replay the
|
||||
// whole history over a schema that already has it.
|
||||
'schema_version',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../src/db/entities/Tours.entity';
|
||||
import type { TourWaypointsRepository } from '../../src/db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../src/db/repositories/Tours.repository';
|
||||
import { sharedTestOrm } from './test-uow';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Tours test-only repository factories, bound to a suite's own better-sqlite3
|
||||
* handle through the same memoised `sharedTestOrm` `test-uow.ts` exports, so
|
||||
* a repository built here and a `UnitOfWork` from `createTestUnitOfWork`
|
||||
* resolve the same `EntityManager` (the `todo-repos.ts` precedent).
|
||||
*/
|
||||
export function createTestToursRepo(db: Database.Database): Promise<ToursRepository> {
|
||||
return sharedTestOrm(db).then((t) => t.repo(Tours));
|
||||
}
|
||||
|
||||
export function createTestTourWaypointsRepo(db: Database.Database): Promise<TourWaypointsRepository> {
|
||||
return sharedTestOrm(db).then((t) => t.repo(TourWaypoints));
|
||||
}
|
||||
|
||||
/** A `tours` row for an existing place, with the defaults a GPX import writes. */
|
||||
export function createTour(
|
||||
db: Database.Database,
|
||||
placeId: number,
|
||||
overrides: Partial<{
|
||||
distance: number | null;
|
||||
match_confidence: number | null;
|
||||
max_hiking_difficulty: number;
|
||||
created_at: string;
|
||||
}> = {},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO tours (place_id, tour_type, distance, match_confidence, max_hiking_difficulty, created_at)
|
||||
VALUES (?, 'hike', ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))`,
|
||||
).run(
|
||||
placeId,
|
||||
overrides.distance ?? null,
|
||||
overrides.match_confidence ?? null,
|
||||
overrides.max_hiking_difficulty ?? 2,
|
||||
overrides.created_at ?? null,
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade',
|
||||
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(res.body).toEqual({ success: true, tourPlaceIds: [] });
|
||||
|
||||
// The place is gone.
|
||||
expect(testDb.prepare('SELECT 1 FROM places WHERE id = ?').get(place.id)).toBeUndefined();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
absDifference,
|
||||
caseWhenEquals,
|
||||
caseWhenNotNull,
|
||||
castInteger,
|
||||
castIntegerKysely,
|
||||
coalesce,
|
||||
@@ -704,6 +705,32 @@ describe('sql-functions (sqlite)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Tours (#2586): the assignment projection's `tour_route_geometry` column.
|
||||
it('SQLF-098: caseWhenNotNull yields the THEN column only where the test column is not null', async () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Shown', a.id);
|
||||
testDb.prepare('UPDATE users SET display_name = NULL WHERE id = ?').run(b.id);
|
||||
const platform = t.em.getPlatform();
|
||||
|
||||
const rows = await t.em
|
||||
.createQueryBuilder(Users, 'u')
|
||||
.select(['u.id', caseWhenNotNull(platform, 'u.display_name', 'u.username').as('picked')])
|
||||
.where({ id: { $in: [a.id, b.id] } })
|
||||
.orderBy({ id: 'asc' })
|
||||
.execute('all', false);
|
||||
expect(rows).toEqual([
|
||||
{ id: a.id, picked: a.username },
|
||||
{ id: b.id, picked: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('SQLF-099: an unknown platform fails closed for caseWhenNotNull', () => {
|
||||
class FakePlatform extends Platform {}
|
||||
const foreign = new FakePlatform();
|
||||
expect(() => caseWhenNotNull(foreign, 'u.a', 'u.b')).toThrow(/no implementation for platform FakePlatform/);
|
||||
});
|
||||
|
||||
// Task 1 fix review M1 (`PlaceShadowPicksRepository.countBySource`, PS5):
|
||||
// `countAll()`'s alias cannot be ordered by (`.orderBy({ count: 'desc' })`
|
||||
// throws "not existing property" — verified in SQLF-024's comment above);
|
||||
|
||||
@@ -30,16 +30,17 @@ describe('ALL_ENTITIES', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Plan 2 Task 3: every one of the 127 entities the migrated schema
|
||||
* Plan 2 Task 3: every one of the 130 entities the migrated schema
|
||||
* describes (120 rewritten + the 5 document-sync tables added this task,
|
||||
* + push_subscriptions and google_api_usage from legacy steps 245 and 254)
|
||||
* + push_subscriptions and google_api_usage from legacy steps 245 and 254,
|
||||
* + tour_types, tours and tour_waypoints from the Tours migration)
|
||||
* is registered, and no two share a class name (a silent duplicate would
|
||||
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
|
||||
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
|
||||
* which de-duplicates by class name).
|
||||
*/
|
||||
it('ENT-003: exactly 127 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(127);
|
||||
it('ENT-003: exactly 130 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(130);
|
||||
const names = ALL_ENTITIES.map((s) => s.meta.className);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
@@ -15,17 +15,18 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
* `scripts/generate-entities.ts`'s `RULE1d_fixNaNNumericDefaults` and
|
||||
* `tests/unit/db/generate-entities.test.ts`'s `RULE1D-*` fixture tests for
|
||||
* the mechanism). This file pins the OUTPUT of that rule against the real,
|
||||
* committed entities — every one of the 127, not a sample — and reproduces
|
||||
* committed entities — every one of the 130, not a sample — and reproduces
|
||||
* the reviewer's own insert probe so a future regenerate/hand-edit that
|
||||
* reintroduces a `NaN` initialiser fails loudly here, not silently at a
|
||||
* caller's first `em.create()`.
|
||||
*/
|
||||
/** NOT NULL scalars with neither a column default nor a class-field initialiser (legitimate: the caller must supply them). */
|
||||
// 424 + push_subscriptions (legacy step 245): id, user_id, endpoint, p256dh, auth, vapid_public_key.
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 430;
|
||||
// + Tours: tour_types label_key, icon, color; tours.tour_type; tour_waypoints id, place_id, lat, lng, role, sequence.
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 440;
|
||||
|
||||
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 127 entities', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 130 entities', () => {
|
||||
const failures: string[] = [];
|
||||
for (const schema of ALL_ENTITIES) {
|
||||
const Ctor = schema.class as new () => Record<string, unknown>;
|
||||
|
||||
@@ -28,7 +28,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
const MIGRATIONS = path.join(__dirname, '../../../src/db/migrations');
|
||||
const BASELINE = 'Migration20200101000000_baseline_schema';
|
||||
/** The migrations that carry no legacy step: the baseline, and the post-legacy ones every upgrade runs. */
|
||||
const UNNUMBERED = [BASELINE, 'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null'];
|
||||
const UNNUMBERED = [
|
||||
BASELINE,
|
||||
'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null',
|
||||
'Migration20200101042000_tours',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
const LEGACY_FINAL_STEP = 258;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../../helpers/factories';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTour } from '../../../helpers/tours-repos';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -52,9 +53,11 @@ function legacyProjectionRow(id: number): unknown {
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent,
|
||||
t.place_id AS tour_place_id, CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN tours t ON t.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.id = ?
|
||||
`,
|
||||
@@ -116,6 +119,36 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => {
|
||||
expect(await assignments.findWithPlaceAndCategory(999999)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASSIGNPLACEREPO-009: the Tour facet columns are set for a Tour and null for a plain place, even one with a route', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const tourPlace = createPlace(testDb, trip.id, { name: 'Ridge walk' });
|
||||
const trackPlace = createPlace(testDb, trip.id, { name: 'Imported track' });
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[47,11],[47.1,11.1]]', tourPlace.id);
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', trackPlace.id);
|
||||
createTour(testDb, tourPlace.id);
|
||||
const tour = createDayAssignment(testDb, day.id, tourPlace.id, { order_index: 0 });
|
||||
const track = createDayAssignment(testDb, day.id, trackPlace.id, { order_index: 1 });
|
||||
|
||||
const one = await assignments.findWithPlaceAndCategory(tour.id);
|
||||
expect(one).toStrictEqual(legacyProjectionRow(tour.id));
|
||||
expect(one).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.1,11.1]]' });
|
||||
expect(await assignments.findWithPlaceAndCategory(track.id)).toMatchObject({
|
||||
tour_place_id: null,
|
||||
tour_route_geometry: null,
|
||||
});
|
||||
|
||||
const facets = (rows: { tour_place_id: number | null; tour_route_geometry: string | null }[]) =>
|
||||
rows.map((r) => [r.tour_place_id, r.tour_route_geometry]);
|
||||
const expected = [
|
||||
[tourPlace.id, '[[47,11],[47.1,11.1]]'],
|
||||
[null, null],
|
||||
];
|
||||
expect(facets(await assignments.listForDay(day.id))).toEqual(expected);
|
||||
expect(facets(await assignments.listWithPlaceAndCategory([day.id]))).toEqual(expected);
|
||||
});
|
||||
|
||||
it("ASSIGNPLACEREPO-004: place_time/end_time COALESCE — the assignment's own time wins, the place's time is the fallback", async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -426,6 +459,16 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order
|
||||
expect(await assignments.getDayId(999999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ASSIGNREPO-042 (getPlaceId): the place_id of the assignment, undefined for a missing id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
expect(await assignments.getPlaceId(a.id)).toBe(place.id);
|
||||
expect(await assignments.getPlaceId(999999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ASSIGNREPO-011 (AS14, moveToDay): writes day_id and order_index together', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../../helpers/factories';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTour } from '../../../helpers/tours-repos';
|
||||
import { TRACK_COLORS } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
@@ -835,9 +836,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil
|
||||
},
|
||||
): unknown[] {
|
||||
let query = `
|
||||
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon,
|
||||
SELECT DISTINCT p.*, t.place_id AS tour_place_id, c.name as category_name, c.color as category_color, c.icon as category_icon,
|
||||
pr.country_code as country_code, pr.region_name as region_name
|
||||
FROM places p
|
||||
LEFT JOIN tours t ON t.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
LEFT JOIN place_regions pr ON pr.place_id = p.id
|
||||
WHERE p.trip_id = ?
|
||||
@@ -887,8 +889,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil
|
||||
const p3 = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(p3.id, tag.id);
|
||||
createDayAssignment(testDb, day.id, p3.id);
|
||||
// p4: no category, no tag, unassigned, does NOT match the search term.
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
// p4: no category, no tag, unassigned, does NOT match the search term,
|
||||
// and a Tour, so tour_place_id is non-null on exactly one row.
|
||||
const p4 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
createTour(testDb, p4.id);
|
||||
// Another trip's place, named to collide with the search term — must
|
||||
// never appear in either statement's output (both scope by trip_id).
|
||||
createPlace(testDb, otherTrip.id, { name: 'Central Perk' });
|
||||
@@ -1860,3 +1864,77 @@ describe('PlacesRepository — share.service.ts SH12 read', () => {
|
||||
expect(await places.listPublicForShare(trip.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlacesRepository.insertTourPlace (TO10) / updateTourRoute (TO11)', () => {
|
||||
it('PLACEREPO-TOUR-001: insertTourPlace writes the route place as a walking place and leaves the rest at its defaults', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = await places.insertTourPlace({
|
||||
trip_id: trip.id,
|
||||
name: 'Ridge walk',
|
||||
lat: 47,
|
||||
lng: 11,
|
||||
route_geometry: '[[47,11],[47.1,11.1]]',
|
||||
});
|
||||
|
||||
expect(await places.findInTrip(id, trip.id)).toMatchObject({
|
||||
trip_id: trip.id,
|
||||
name: 'Ridge walk',
|
||||
lat: 47,
|
||||
lng: 11,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: '[[47,11],[47.1,11.1]]',
|
||||
duration_minutes: 60,
|
||||
reservation_status: 'none',
|
||||
category_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('PLACEREPO-TOUR-002: updateTourRoute rewrites the route of a place of that trip, stamps updated_at, and refuses another trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const other = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Old', lat: 1, lng: 1 });
|
||||
testDb.prepare("UPDATE places SET transport_mode = 'driving', updated_at = NULL WHERE id = ?").run(place.id);
|
||||
const route = { name: 'New', lat: 47, lng: 11, route_geometry: '[[47,11],[47.2,11.2]]' };
|
||||
|
||||
expect(await places.updateTourRoute(place.id, other.id, route)).toBe(false);
|
||||
expect(await places.findInTrip(place.id, trip.id)).toMatchObject({
|
||||
name: 'Old',
|
||||
transport_mode: 'driving',
|
||||
updated_at: null,
|
||||
});
|
||||
|
||||
expect(await places.updateTourRoute(place.id, trip.id, route)).toBe(true);
|
||||
const row = await places.findInTrip(place.id, trip.id);
|
||||
expect(row).toMatchObject({ ...route, transport_mode: 'walking' });
|
||||
expect(row?.updated_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlacesRepository.isTour / listForTrip tour_place_id', () => {
|
||||
it('PLACEREPO-TOUR-003: isTour is true only for a place with a tours row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' });
|
||||
const plain = createPlace(testDb, trip.id, { name: 'Cafe' });
|
||||
createTour(testDb, tour.id);
|
||||
|
||||
expect(await places.isTour(tour.id)).toBe(true);
|
||||
expect(await places.isTour(plain.id)).toBe(false);
|
||||
expect(await places.isTour(999_999)).toBe(false);
|
||||
});
|
||||
|
||||
it('PLACEREPO-TOUR-004: listForTrip carries the place id as tour_place_id for Tours and null otherwise', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' });
|
||||
const plain = createPlace(testDb, trip.id, { name: 'Cafe' });
|
||||
createTour(testDb, tour.id);
|
||||
|
||||
const rows = await places.listForTrip(String(trip.id), {});
|
||||
const byId = new Map(rows.map((r) => [r.id, r.tour_place_id]));
|
||||
expect(byId.get(tour.id)).toBe(tour.id);
|
||||
expect(byId.get(plain.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* `TourWaypointsRepository`: TW1 (`listForPlace`), TW2 (`listForTrip`), TW3
|
||||
* (`insertForPlace`) and TW4 (`deleteForPlace`).
|
||||
*/
|
||||
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
|
||||
import type {
|
||||
TourWaypointRow,
|
||||
TourWaypointsRepository,
|
||||
} from '../../../../src/db/repositories/TourWaypoints.repository';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createPlace, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTour } from '../../../helpers/tours-repos';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let repo: TourWaypointsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
repo = t.repo(TourWaypoints);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
const ROUTE: TourWaypointRow[] = [
|
||||
{ lat: 47.1, lng: 11.1, role: 'end', sequence: 2 },
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.05, lng: 11.05, role: 'via', sequence: 1 },
|
||||
];
|
||||
|
||||
function tourInTrip() {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createTour(testDb, place.id);
|
||||
return { trip, place };
|
||||
}
|
||||
|
||||
describe('TourWaypointsRepository', () => {
|
||||
it('TWREPO-001: insertForPlace writes every point and listForPlace reads them back in route order', async () => {
|
||||
const { place } = tourInTrip();
|
||||
await repo.insertForPlace(place.id, ROUTE);
|
||||
expect(await repo.listForPlace(place.id)).toEqual([...ROUTE].sort((a, b) => a.sequence - b.sequence));
|
||||
});
|
||||
|
||||
it('TWREPO-002: insertForPlace with no points writes nothing', async () => {
|
||||
const { place } = tourInTrip();
|
||||
await repo.insertForPlace(place.id, []);
|
||||
expect(await repo.listForPlace(place.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('TWREPO-003: deleteForPlace clears one tour and leaves another alone', async () => {
|
||||
const first = tourInTrip();
|
||||
const second = tourInTrip();
|
||||
await repo.insertForPlace(first.place.id, ROUTE);
|
||||
await repo.insertForPlace(second.place.id, ROUTE);
|
||||
|
||||
await repo.deleteForPlace(first.place.id);
|
||||
expect(await repo.listForPlace(first.place.id)).toEqual([]);
|
||||
expect(await repo.listForPlace(second.place.id)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('TWREPO-004: listForTrip returns every waypoint of the trip with its place, ordered by place and sequence', async () => {
|
||||
const { trip, place } = tourInTrip();
|
||||
const other = tourInTrip();
|
||||
const second = createPlace(testDb, trip.id);
|
||||
createTour(testDb, second.id);
|
||||
await repo.insertForPlace(second.id, ROUTE.slice(0, 2));
|
||||
await repo.insertForPlace(place.id, ROUTE);
|
||||
await repo.insertForPlace(other.place.id, ROUTE);
|
||||
|
||||
const rows = await repo.listForTrip(trip.id);
|
||||
expect(rows.map((r) => [r.place_id, r.sequence])).toEqual([
|
||||
[place.id, 0],
|
||||
[place.id, 1],
|
||||
[place.id, 2],
|
||||
[second.id, 0],
|
||||
[second.id, 2],
|
||||
]);
|
||||
expect(rows[0]).toEqual({ place_id: place.id, lat: 47, lng: 11, role: 'start', sequence: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* `ToursRepository`: the list/detail projection (TO1/TO2), the facet probes
|
||||
* places and assignments ask (TO3/TO4/TO5), the trip copy read and write
|
||||
* (TO6/TO8), and the create/edit writes (TO7/TO9).
|
||||
*/
|
||||
import { Tours } from '../../../../src/db/entities/Tours.entity';
|
||||
import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTour } from '../../../helpers/tours-repos';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let repo: ToursRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
repo = t.repo(Tours);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const other = createTrip(testDb, user.id);
|
||||
return { trip, other };
|
||||
}
|
||||
|
||||
function addWaypoint(placeId: number, sequence: number): void {
|
||||
testDb
|
||||
.prepare("INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 47, 11, 'via', ?)")
|
||||
.run(placeId, sequence);
|
||||
}
|
||||
|
||||
const METRICS = {
|
||||
tour_type: 'hike',
|
||||
distance: 8.25,
|
||||
elevation_gain: 420,
|
||||
elevation_loss: 380,
|
||||
duration: 150,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 3,
|
||||
};
|
||||
|
||||
describe('ToursRepository reads', () => {
|
||||
it('TOURREPO-001: listForTrip projects the facet with its place name and both flags, newest first, never another trip', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const older = createPlace(testDb, trip.id, { name: 'Older' });
|
||||
const newer = createPlace(testDb, trip.id, { name: 'Newer' });
|
||||
const plain = createPlace(testDb, trip.id, { name: 'Not a tour' });
|
||||
const foreign = createPlace(testDb, other.id, { name: 'Foreign' });
|
||||
createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', distance: 5, match_confidence: 0.3 });
|
||||
createTour(testDb, newer.id, { created_at: '2026-01-02 10:00:00', max_hiking_difficulty: 5 });
|
||||
createTour(testDb, foreign.id);
|
||||
createDayAssignment(testDb, createDay(testDb, trip.id).id, older.id);
|
||||
addWaypoint(newer.id, 0);
|
||||
|
||||
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,
|
||||
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,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: 1,
|
||||
has_waypoints: 0,
|
||||
},
|
||||
]);
|
||||
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
|
||||
});
|
||||
|
||||
it('TOURREPO-002: listForTrip breaks a created_at tie by the newer place', async () => {
|
||||
const { trip } = fixture();
|
||||
const a = createPlace(testDb, trip.id);
|
||||
const b = createPlace(testDb, trip.id);
|
||||
createTour(testDb, a.id, { created_at: '2026-01-01 10:00:00' });
|
||||
createTour(testDb, b.id, { created_at: '2026-01-01 10:00:00' });
|
||||
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).toEqual([b.id, a.id]);
|
||||
});
|
||||
|
||||
it('TOURREPO-003: findInTrip returns the row for its own trip and undefined across trips or for a plain place', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Ridge' });
|
||||
const plain = createPlace(testDb, trip.id);
|
||||
createTour(testDb, tour.id);
|
||||
|
||||
expect(await repo.findInTrip(trip.id, tour.id)).toMatchObject({
|
||||
place_id: tour.id,
|
||||
name: 'Ridge',
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
});
|
||||
expect(await repo.findInTrip(other.id, tour.id)).toBeUndefined();
|
||||
expect(await repo.findInTrip(trip.id, plain.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TOURREPO-004: existsForPlace and listTourPlaceIds tell tours from plain places', async () => {
|
||||
const { trip } = fixture();
|
||||
const tour = createPlace(testDb, trip.id);
|
||||
const plain = createPlace(testDb, trip.id);
|
||||
createTour(testDb, tour.id);
|
||||
|
||||
expect(await repo.existsForPlace(tour.id)).toBe(true);
|
||||
expect(await repo.existsForPlace(plain.id)).toBe(false);
|
||||
expect(await repo.listTourPlaceIds([plain.id, tour.id])).toEqual([tour.id]);
|
||||
expect(await repo.listTourPlaceIds([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('TOURREPO-005: isOnDay is true only for a tour already on that day, and ignores the assignment being moved', async () => {
|
||||
const { trip } = fixture();
|
||||
const day1 = createDay(testDb, trip.id);
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const tour = createPlace(testDb, trip.id);
|
||||
const plain = createPlace(testDb, trip.id);
|
||||
createTour(testDb, tour.id);
|
||||
const onDay1 = createDayAssignment(testDb, day1.id, tour.id);
|
||||
createDayAssignment(testDb, day1.id, plain.id);
|
||||
|
||||
expect(await repo.isOnDay(day1.id, tour.id)).toBe(true);
|
||||
expect(await repo.isOnDay(day2.id, tour.id)).toBe(false);
|
||||
expect(await repo.isOnDay(day1.id, plain.id)).toBe(false);
|
||||
expect(await repo.isOnDay(day1.id, tour.id, onDay1.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursRepository writes', () => {
|
||||
it('TOURREPO-006: insertTour writes the metrics and leaves the unset metadata NULL', async () => {
|
||||
const { trip } = fixture();
|
||||
const place = createPlace(testDb, trip.id, { name: 'Drawn' });
|
||||
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,
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('TOURREPO-007: updateInTrip replaces the metrics of a tour of that trip and refuses one of another trip', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createTour(testDb, place.id);
|
||||
|
||||
expect(await repo.updateInTrip(other.id, place.id, METRICS)).toBe(false);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ distance: null, max_hiking_difficulty: 2 });
|
||||
|
||||
expect(await repo.updateInTrip(trip.id, place.id, METRICS)).toBe(true);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject(METRICS);
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
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',
|
||||
max_hiking_difficulty: 4,
|
||||
},
|
||||
]);
|
||||
expect(await repo.listRowsForTrip(other.id)).toEqual([]);
|
||||
|
||||
const copy = createPlace(testDb, other.id);
|
||||
const { place_id: _sourceId, ...columns } = rows[0];
|
||||
await repo.insertCopy(copy.id, columns);
|
||||
expect(await repo.listRowsForTrip(other.id)).toEqual([{ ...rows[0], place_id: copy.id }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* `Migration20200101042000_tours`: the Tours tables on a fresh install, and on
|
||||
* a test instance that already carries them from the pre-ORM Tours branch.
|
||||
*
|
||||
* Migrate to the step immediately before it, optionally lay down the
|
||||
* branch's own tables with rows in them, apply just this migration, assert.
|
||||
*/
|
||||
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
|
||||
import type { MikroORM } from '@mikro-orm/sqlite';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const TARGET = 'Migration20200101042000_tours';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function indexNames(orm: MikroORM, table: string): Promise<string[]> {
|
||||
const rows = await rawQuery<{ name: string }>(orm, `SELECT name FROM pragma_index_list('${table}') ORDER BY name`);
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
async function seedTripWithPlace(orm: MikroORM): Promise<void> {
|
||||
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')");
|
||||
}
|
||||
|
||||
describe('Tours migration', () => {
|
||||
it('TOURMIG-001: a fresh install gets the three tables, the hike type and no redundant waypoint index', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(
|
||||
await rawQuery(
|
||||
orm,
|
||||
'SELECT key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order FROM tour_types',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
key: 'hike',
|
||||
label_key: 'tourTypes.hike',
|
||||
icon: 'Mountain',
|
||||
color: '#16a34a',
|
||||
routing_profile: 'pedestrian',
|
||||
is_sport: 1,
|
||||
enabled: 1,
|
||||
sort_order: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const tourColumns = await rawQuery<{ name: string; notnull: number; dflt_value: string | null; pk: number }>(
|
||||
orm,
|
||||
'SELECT name, "notnull", dflt_value, pk FROM pragma_table_info(\'tours\') ORDER BY cid',
|
||||
);
|
||||
expect(tourColumns.map((c) => c.name)).toEqual([
|
||||
'place_id',
|
||||
'tour_type',
|
||||
'distance',
|
||||
'elevation_gain',
|
||||
'elevation_loss',
|
||||
'duration',
|
||||
'difficulty',
|
||||
'wanderer_ref',
|
||||
'match_confidence',
|
||||
'tour_group_id',
|
||||
'created_at',
|
||||
'max_hiking_difficulty',
|
||||
]);
|
||||
expect(tourColumns.find((c) => c.name === 'max_hiking_difficulty')).toMatchObject({
|
||||
notnull: 1,
|
||||
dflt_value: '2',
|
||||
});
|
||||
expect(tourColumns.find((c) => c.name === 'place_id')).toMatchObject({ pk: 1 });
|
||||
|
||||
expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type');
|
||||
expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place');
|
||||
|
||||
const fks = await rawQuery<{ table: string; from: string; to: string; on_delete: string }>(
|
||||
orm,
|
||||
'SELECT "table", "from", "to", on_delete FROM pragma_foreign_key_list(\'tours\') UNION ALL SELECT "table", "from", "to", on_delete FROM pragma_foreign_key_list(\'tour_waypoints\') ORDER BY 1, 2',
|
||||
);
|
||||
expect(fks).toEqual([
|
||||
{ table: 'places', from: 'place_id', to: 'id', on_delete: 'CASCADE' },
|
||||
{ table: 'tour_types', from: 'tour_type', to: 'key', on_delete: 'NO ACTION' },
|
||||
{ table: 'tours', from: 'place_id', to: 'place_id', on_delete: 'CASCADE' },
|
||||
]);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('TOURMIG-002: the CHECKs refuse out-of-range values and a place delete cascades through tours to its waypoints', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
await migrateTo(orm, TARGET);
|
||||
await rawExec(orm, 'PRAGMA foreign_keys = ON');
|
||||
await seedTripWithPlace(orm);
|
||||
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO tours (place_id, tour_type, max_hiking_difficulty) VALUES (100, 'hike', 7)"),
|
||||
).rejects.toThrow(/CHECK/);
|
||||
await expect(rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'kayak')")).rejects.toThrow(
|
||||
/FOREIGN KEY/,
|
||||
);
|
||||
await rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'hike')");
|
||||
expect(await rawQuery(orm, 'SELECT max_hiking_difficulty FROM tours')).toEqual([{ max_hiking_difficulty: 2 }]);
|
||||
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 91, 0, 'start', 0)"),
|
||||
).rejects.toThrow(/CHECK/);
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 0, 0, 'detour', 0)"),
|
||||
).rejects.toThrow(/CHECK/);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)",
|
||||
);
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'via', 1)"),
|
||||
).rejects.toThrow(/UNIQUE/);
|
||||
|
||||
await rawExec(orm, 'DELETE FROM places WHERE id = 100');
|
||||
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tours')).toEqual([{ n: 0 }]);
|
||||
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tour_waypoints')).toEqual([{ n: 0 }]);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('TOURMIG-003: on an instance that already has the pre-ORM Tours tables it keeps every row and drops only the redundant index', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
// The pre-ORM branch's final shape, rows and all, with its custom hike row.
|
||||
await rawExec(
|
||||
orm,
|
||||
`CREATE TABLE tour_types (
|
||||
key TEXT PRIMARY KEY, label_key TEXT NOT NULL, icon TEXT NOT NULL, color TEXT NOT NULL, routing_profile TEXT,
|
||||
is_sport INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0)`,
|
||||
);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tour_types (key, label_key, icon, color, routing_profile) VALUES ('hike', 'tourTypes.hike', 'Mountain', '#000000', 'pedestrian')",
|
||||
);
|
||||
await rawExec(
|
||||
orm,
|
||||
`CREATE TABLE tours (
|
||||
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
|
||||
tour_type TEXT NOT NULL REFERENCES tour_types(key), distance REAL, elevation_gain REAL, elevation_loss REAL,
|
||||
duration REAL, difficulty TEXT, wanderer_ref TEXT, match_confidence REAL, tour_group_id INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6))`,
|
||||
);
|
||||
await rawExec(orm, 'CREATE INDEX idx_tours_tour_type ON tours(tour_type)');
|
||||
await rawExec(
|
||||
orm,
|
||||
`CREATE TABLE tour_waypoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE,
|
||||
lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90), lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180),
|
||||
role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), sequence INTEGER NOT NULL CHECK(sequence >= 0),
|
||||
UNIQUE(place_id, sequence))`,
|
||||
);
|
||||
await rawExec(orm, 'CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)');
|
||||
await seedTripWithPlace(orm);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tours (place_id, tour_type, distance, max_hiking_difficulty) VALUES (100, 'hike', 12.5, 4)",
|
||||
);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)",
|
||||
);
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(await rawQuery(orm, 'SELECT key, color FROM tour_types')).toEqual([{ key: 'hike', color: '#000000' }]);
|
||||
expect(await rawQuery(orm, 'SELECT place_id, tour_type, distance, max_hiking_difficulty FROM tours')).toEqual([
|
||||
{ place_id: 100, tour_type: 'hike', distance: 12.5, max_hiking_difficulty: 4 },
|
||||
]);
|
||||
expect(await rawQuery(orm, 'SELECT role, sequence FROM tour_waypoints ORDER BY sequence')).toEqual([
|
||||
{ role: 'start', sequence: 0 },
|
||||
{ role: 'end', sequence: 1 },
|
||||
]);
|
||||
expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type');
|
||||
expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place');
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -46,6 +46,12 @@ describe('global CSP: the other shipped raster presets (#2180)', () => {
|
||||
expect(await connectSrcSources()).toContain('https://tiles.stadiamaps.com');
|
||||
});
|
||||
|
||||
it('allows the OpenTopoMap apex and tile shards used by the Tours planner', async () => {
|
||||
const sources = await connectSrcSources();
|
||||
expect(sources).toContain('https://tile.opentopomap.org');
|
||||
expect(sources).toContain('https://*.tile.opentopomap.org');
|
||||
});
|
||||
|
||||
it('keeps the routing host, which is a different host and covers nothing here', async () => {
|
||||
// routing.openstreetmap.de was on the list all along and looks close enough
|
||||
// to hide the gap: a CSP source matches a host, not a suffix of one.
|
||||
@@ -139,8 +145,8 @@ describe('forced-HTTPS redirect', () => {
|
||||
|
||||
it('redirects to the configured APP_URL host, not the Host header the caller sent', async () => {
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
process.env.APP_URL = 'https://trip.pakulat.org';
|
||||
expect(await redirectLocation()).toBe('https://trip.pakulat.org/trips');
|
||||
process.env.APP_URL = 'https://trip.example.invalid';
|
||||
expect(await redirectLocation()).toBe('https://trip.example.invalid/trips');
|
||||
});
|
||||
|
||||
it('falls back to the request host when APP_URL is unset', async () => {
|
||||
@@ -159,7 +165,7 @@ describe('forced-HTTPS redirect', () => {
|
||||
|
||||
it('leaves an already-secure request alone, and never redirects the health probe', async () => {
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
process.env.APP_URL = 'https://trip.pakulat.org';
|
||||
process.env.APP_URL = 'https://trip.example.invalid';
|
||||
const app = express();
|
||||
applyGlobalMiddleware(app);
|
||||
app.get('/trips', (_req, res) => res.json({ ok: true }));
|
||||
@@ -184,7 +190,7 @@ describe('routingCspOrigins', () => {
|
||||
});
|
||||
|
||||
it('CSP-ROUTING-002: a port belongs to the origin and is kept', () => {
|
||||
expect(routingCspOrigins(['http://192.168.178.72:5000'])).toEqual(['http://192.168.178.72:5000']);
|
||||
expect(routingCspOrigins(['http://192.0.2.1:5000'])).toEqual(['http://192.0.2.1:5000']);
|
||||
});
|
||||
|
||||
it('CSP-ROUTING-003: anything that is not an http(s) URL widens nothing', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from '../../../src/nest/assignments/assignments.controller';
|
||||
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { ConflictException, HttpException } from '@nestjs/common';
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
@@ -73,6 +73,22 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock');
|
||||
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
|
||||
});
|
||||
|
||||
it('409 duplicate Tour assignment propagates without announcing a new assignment', async () => {
|
||||
const broadcast = vi.fn();
|
||||
const reconcile = vi.fn();
|
||||
const createAssignment = vi.fn().mockRejectedValue(new ConflictException('Tour is already assigned to this day'));
|
||||
const controller = new DayAssignmentsController(
|
||||
svc({ createAssignment, broadcast, reconcile } as Partial<AssignmentsService>),
|
||||
);
|
||||
|
||||
expect(await thrownAsync(() => controller.create(user, '5', '3', { place_id: 42 }))).toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /reorder 404 day, else reorders + broadcasts', async () => {
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
sharedTestOrm,
|
||||
createTestTripsRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTestToursRepo, createTour } from '../../helpers/tours-repos';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -128,6 +130,7 @@ beforeAll(async () => {
|
||||
await createTestPlacesRepo(testDb),
|
||||
await createTestTripMembersRepo(testDb),
|
||||
await createTestRoadtripViasRepo(testDb),
|
||||
await createTestToursRepo(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -157,6 +160,12 @@ function fixture() {
|
||||
return { user, trip, day, place };
|
||||
}
|
||||
|
||||
/** Makes `placeId` a Tour: a `tours` facet row plus the route it was planned along. */
|
||||
function addTourFacet(placeId: number, routeGeometry = '[[48.1,11.5],[48.2,11.6]]') {
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run(routeGeometry, placeId);
|
||||
createTour(testDb, placeId);
|
||||
}
|
||||
|
||||
// ── verifyTripAccess / canEdit ────────────────────────────────────────────────
|
||||
|
||||
describe('verifyTripAccess', () => {
|
||||
@@ -307,6 +316,68 @@ describe('createAssignment', () => {
|
||||
]);
|
||||
void trip;
|
||||
});
|
||||
|
||||
it('ASG-SVC-031: rejects a duplicate Tour assignment on the same day but allows another day and ordinary Place duplicates', async () => {
|
||||
const { trip, day, place: tourPlace } = fixture();
|
||||
const secondDay = createDay(testDb, trip.id);
|
||||
const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' });
|
||||
addTourFacet(tourPlace.id);
|
||||
|
||||
const firstTourAssignment = await svc.createAssignment(day.id, tourPlace.id);
|
||||
await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow(ConflictException);
|
||||
await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow('Tour is already assigned to this day');
|
||||
const otherDayTourAssignment = await svc.createAssignment(secondDay.id, tourPlace.id);
|
||||
expect(firstTourAssignment?.day_id).toBe(day.id);
|
||||
expect(otherDayTourAssignment?.day_id).toBe(secondDay.id);
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlace.id)).toEqual({
|
||||
n: 2,
|
||||
});
|
||||
|
||||
await svc.createAssignment(day.id, ordinaryPlace.id);
|
||||
await svc.createAssignment(day.id, ordinaryPlace.id);
|
||||
expect(
|
||||
testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(ordinaryPlace.id),
|
||||
).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it('ASG-SVC-032: near-concurrent Tour assignments serialize to one row and one conflict', async () => {
|
||||
const { day, place } = fixture();
|
||||
addTourFacet(place.id);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
svc.createAssignment(day.id, place.id),
|
||||
svc.createAssignment(day.id, place.id),
|
||||
]);
|
||||
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect(rejected[0].reason).toBeInstanceOf(ConflictException);
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ? AND place_id = ?')
|
||||
.get(day.id, place.id),
|
||||
).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('ASG-SVC-048: a Tour stop carries its facet id and route geometry, an ordinary stop nulls both', async () => {
|
||||
const { trip, day, place: tourPlace } = fixture();
|
||||
const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' });
|
||||
// Geometry on a plain place (an imported track) must not leak into the Tour fields.
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', ordinaryPlace.id);
|
||||
addTourFacet(tourPlace.id, '[[47,11],[47.5,11.5]]');
|
||||
|
||||
const tour = await svc.createAssignment(day.id, tourPlace.id);
|
||||
const ordinary = await svc.createAssignment(day.id, ordinaryPlace.id);
|
||||
expect(tour).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.5,11.5]]' });
|
||||
expect(ordinary).toMatchObject({ tour_place_id: null, tour_route_geometry: null });
|
||||
|
||||
const list = await svc.listDayAssignments(day.id);
|
||||
expect(list.map((a) => [a.tour_place_id, a.tour_route_geometry])).toEqual([
|
||||
[tourPlace.id, '[[47,11],[47.5,11.5]]'],
|
||||
[null, null],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listDayAssignments ────────────────────────────────────────────────────────
|
||||
@@ -416,6 +487,33 @@ describe('moveAssignment', () => {
|
||||
expect((await svc.moveAssignment(a.id, day.id, null)).assignment!.order_index).toBe(0);
|
||||
expect((await svc.moveAssignment(a.id, target.id, 0)).assignment!.order_index).toBe(0);
|
||||
});
|
||||
|
||||
it('ASG-SVC-049: refuses to move a Tour onto a day that already holds it, and leaves the row where it was', async () => {
|
||||
const { trip, day, place } = fixture();
|
||||
const target = createDay(testDb, trip.id);
|
||||
addTourFacet(place.id);
|
||||
createDayAssignment(testDb, target.id, place.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 });
|
||||
|
||||
await expect(svc.moveAssignment(a.id, target.id, 0)).rejects.toThrow(ConflictException);
|
||||
expect(testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id)).toEqual({
|
||||
day_id: day.id,
|
||||
order_index: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('ASG-SVC-050: a Tour may be reordered within its own day, and an ordinary place may join a day that already holds it', async () => {
|
||||
const { trip, day, place } = fixture();
|
||||
const target = createDay(testDb, trip.id);
|
||||
addTourFacet(place.id);
|
||||
const tour = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
expect((await svc.moveAssignment(tour.id, day.id, 2)).assignment).toMatchObject({ day_id: day.id, order_index: 2 });
|
||||
|
||||
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' });
|
||||
createDayAssignment(testDb, target.id, ordinary.id);
|
||||
const second = createDayAssignment(testDb, day.id, ordinary.id);
|
||||
expect((await svc.moveAssignment(second.id, target.id, 1)).assignment).toMatchObject({ day_id: target.id });
|
||||
});
|
||||
});
|
||||
|
||||
// ── participants ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +54,8 @@ function makeRow(overrides: Partial<AssignmentWithPlaceRow> = {}): AssignmentWit
|
||||
phone: '+33 1 2345 6789',
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
tour_place_id: null,
|
||||
tour_route_geometry: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -106,6 +108,16 @@ describe('formatAssignmentWithPlace', () => {
|
||||
expect(result.place.category).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the Tour facet fields at the assignment level, null for an ordinary place', () => {
|
||||
expect(formatAssignmentWithPlace(makeRow(), [], [])).toMatchObject({
|
||||
tour_place_id: null,
|
||||
tour_route_geometry: null,
|
||||
});
|
||||
const geometry = '[[48.1,11.5],[48.2,11.6]]';
|
||||
const tour = formatAssignmentWithPlace(makeRow({ tour_place_id: 100, tour_route_geometry: geometry }), [], []);
|
||||
expect(tour).toMatchObject({ tour_place_id: 100, tour_route_geometry: geometry });
|
||||
});
|
||||
|
||||
it('passes tags and participants through untouched', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
|
||||
expect(result.place.tags).toEqual(sampleTags);
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
sharedTestOrm,
|
||||
createTestTripsRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTestToursRepo } from '../../helpers/tours-repos';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
|
||||
@@ -161,6 +162,7 @@ beforeAll(async () => {
|
||||
await createTestPlacesRepo(testDb),
|
||||
await createTestTripMembersRepo(testDb),
|
||||
await createTestRoadtripViasRepo(testDb),
|
||||
await createTestToursRepo(testDb),
|
||||
);
|
||||
accommodations = new AccommodationsService(
|
||||
permissions,
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
createTestRoadtripViasRepo,
|
||||
createTestRoadtripDayBoundariesRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTour } from '../../helpers/tours-repos';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -166,6 +167,27 @@ describe('verifyTripAccess', () => {
|
||||
// ── getAssignmentsForDay ──────────────────────────────────────────────────────
|
||||
|
||||
describe('getAssignmentsForDay', () => {
|
||||
it('DAY-SVC-TOUR-001: projects only facet-backed Tour geometry in both day read paths', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Hike', lat: 48.1, lng: 11.1 });
|
||||
const track = createPlace(testDb, trip.id, { name: 'Legacy track', lat: 48.2, lng: 11.2 });
|
||||
const geometry = JSON.stringify([
|
||||
[48.1, 11.1],
|
||||
[48.3, 11.3],
|
||||
]);
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id IN (?, ?)').run(geometry, tour.id, track.id);
|
||||
createTour(testDb, tour.id);
|
||||
createDayAssignment(testDb, day.id, tour.id, { order_index: 0 });
|
||||
createDayAssignment(testDb, day.id, track.id, { order_index: 1 });
|
||||
|
||||
for (const assignments of [await svc.getAssignmentsForDay(day.id), (await svc.list(trip.id)).days[0].assignments]) {
|
||||
expect(assignments?.[0]).toMatchObject({ tour_place_id: tour.id, tour_route_geometry: geometry });
|
||||
expect(assignments?.[1]).toMatchObject({ tour_place_id: null, tour_route_geometry: null });
|
||||
}
|
||||
});
|
||||
|
||||
it('DAY-SVC-003 — returns empty array when day has no assignments', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import { PlacesController } from '../../../src/nest/places/places.controller';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
@@ -7,6 +8,26 @@ import { HttpException } from '@nestjs/common';
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService;
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
@@ -43,6 +64,10 @@ async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number
|
||||
beforeEach(() => vi.spyOn(console, 'error').mockImplementation(() => {}));
|
||||
|
||||
describe('PlacesController (parity with the legacy /api/trips/:tripId/places route)', () => {
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The trip 404 for this handler is TripAccessGuard's now (see
|
||||
// trip-access.guard.test.ts and the places e2e), so it is no longer reachable
|
||||
// by calling the method directly.
|
||||
@@ -412,13 +437,27 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
new RuntimeEnvService(),
|
||||
storageStub,
|
||||
).bulkDelete(user, '5', { ids: [] }),
|
||||
).toEqual({ deleted: [], count: 0 });
|
||||
).toEqual({ deleted: [], count: 0, tourPlaceIds: [] });
|
||||
expect(removeMany).not.toHaveBeenCalled();
|
||||
});
|
||||
it('requires place_edit before a bulk deletion can reach the service', async () => {
|
||||
const removeMany = vi.fn();
|
||||
const s = svc({ canEdit: vi.fn().mockReturnValue(false), removeMany } as Partial<PlacesService>);
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1] }),
|
||||
),
|
||||
).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(removeMany).not.toHaveBeenCalled();
|
||||
});
|
||||
it('deletes, fires hooks + broadcasts per deleted id', async () => {
|
||||
const removeMany = vi
|
||||
.fn()
|
||||
.mockReturnValue({ deleted: [1, 2], cancelled: { reservationIds: [], budgetItemIds: [] } });
|
||||
.mockReturnValue({
|
||||
deleted: [1, 2],
|
||||
deletedTourPlaceIds: [2],
|
||||
cancelled: { reservationIds: [], budgetItemIds: [] },
|
||||
});
|
||||
const onDeleted = vi.fn();
|
||||
const broadcast = vi.fn();
|
||||
const scopedIds = vi.fn().mockReturnValue([1, 2]);
|
||||
@@ -430,7 +469,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
{ ids: [1, 2] },
|
||||
'sock',
|
||||
),
|
||||
).toEqual({ deleted: [1, 2], count: 2 });
|
||||
).toEqual({ deleted: [1, 2], count: 2, tourPlaceIds: [2] });
|
||||
expect(onDeleted).toHaveBeenCalledTimes(2);
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -464,7 +503,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
const removeMany = vi.fn(async () => {
|
||||
order.push('delete');
|
||||
return { deleted: [1], cancelled: { reservationIds: [], budgetItemIds: [] } };
|
||||
return { deleted: [1], deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } };
|
||||
});
|
||||
const s = svc({
|
||||
removeMany,
|
||||
@@ -827,14 +866,25 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
expect(onDeleted).toHaveBeenCalledWith(9);
|
||||
expect(onDeleted.mock.invocationCallOrder[0]).toBeLessThan(remove.mock.invocationCallOrder[0]);
|
||||
const s = svc({
|
||||
remove: vi.fn().mockReturnValue({ deleted: true, cancelled: { reservationIds: [], budgetItemIds: [] } }),
|
||||
remove: vi
|
||||
.fn()
|
||||
.mockReturnValue({
|
||||
deleted: true,
|
||||
deletedTourPlaceIds: [9],
|
||||
cancelled: { reservationIds: [], budgetItemIds: [] },
|
||||
}),
|
||||
broadcast: vi.fn(),
|
||||
} as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9')).toEqual({
|
||||
success: true,
|
||||
tourPlaceIds: [9],
|
||||
});
|
||||
});
|
||||
|
||||
it('requires place_edit for single Place/Tour deletion', () => {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, PlacesController.prototype.remove)).toBe('place_edit');
|
||||
});
|
||||
|
||||
// invocationCallOrder in the case above only proves the hook was CALLED
|
||||
// first — a synchronously-resolving mock can't tell an awaited hook from a
|
||||
// detached (fire-and-forget) one. A hook stubbed with a real macrotask hop,
|
||||
@@ -848,7 +898,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
const remove = vi.fn(async () => {
|
||||
order.push('delete');
|
||||
return { deleted: true, cancelled: { reservationIds: [], budgetItemIds: [] } };
|
||||
return { deleted: true, deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } };
|
||||
});
|
||||
const s = svc({ remove, onDeleted, broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9');
|
||||
@@ -991,7 +1041,6 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/image/from-file (#1242)', () => {
|
||||
it('maps every refusal to its status and broadcasts a success', async () => {
|
||||
const make = (result: unknown, extra: Partial<PlacesService> = {}) =>
|
||||
|
||||
@@ -235,6 +235,19 @@ describe('list', () => {
|
||||
expect(places).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-TOURS-002 — identifies Tours by their facet in the ordinary Place read model', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Facet-backed tour' }) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
|
||||
testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(tour.id);
|
||||
|
||||
const byId = new Map(((await svc.list(String(trip.id), {})) as any[]).map((item) => [item.id, item]));
|
||||
|
||||
expect(byId.get(tour.id)).toMatchObject({ tour_place_id: tour.id });
|
||||
expect(byId.get(place.id)).toMatchObject({ tour_place_id: null });
|
||||
});
|
||||
|
||||
it('PLACE-SVC-003 — does not return places from other trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const t1 = createTrip(testDb, user.id);
|
||||
@@ -711,6 +724,66 @@ describe('remove', () => {
|
||||
expect(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(day.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-TOURS-001 — reports Tour deletion and cascades only its facet, waypoints, assignments, and links', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any;
|
||||
const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any;
|
||||
const keptPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
|
||||
for (const place of [tour, keptTour]) {
|
||||
testDb
|
||||
.prepare(`INSERT INTO tours (place_id, tour_type, max_hiking_difficulty) VALUES (?, 'hike', 4)`)
|
||||
.run(place.id);
|
||||
testDb
|
||||
.prepare(`INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 48, 11, 'start', 0)`)
|
||||
.run(place.id);
|
||||
testDb
|
||||
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, 0)')
|
||||
.run(day.id, place.id);
|
||||
}
|
||||
const tourFileId = Number(
|
||||
testDb
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO trip_files (trip_id, place_id, filename, original_name) VALUES (?, ?, 'tour.gpx', 'tour.gpx')
|
||||
`,
|
||||
)
|
||||
.run(trip.id, tour.id).lastInsertRowid,
|
||||
);
|
||||
const keptFileId = Number(
|
||||
testDb
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO trip_files (trip_id, place_id, filename, original_name) VALUES (?, ?, 'kept.gpx', 'kept.gpx')
|
||||
`,
|
||||
)
|
||||
.run(trip.id, keptTour.id).lastInsertRowid,
|
||||
);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(tourFileId, tour.id);
|
||||
|
||||
const result = await svc.remove(String(trip.id), String(tour.id));
|
||||
|
||||
expect(result).toMatchObject({ deleted: true, deletedTourPlaceIds: [tour.id] });
|
||||
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(tour.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(tour.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM tour_waypoints WHERE place_id = ?').all(tour.id)).toEqual([]);
|
||||
expect(testDb.prepare('SELECT id FROM day_assignments WHERE place_id = ?').all(tour.id)).toEqual([]);
|
||||
expect(testDb.prepare('SELECT id, place_id FROM trip_files WHERE id = ?').get(tourFileId)).toMatchObject({
|
||||
id: tourFileId,
|
||||
place_id: null,
|
||||
});
|
||||
expect(testDb.prepare('SELECT id FROM file_links WHERE file_id = ?').all(tourFileId)).toEqual([]);
|
||||
expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(keptTour.id)).toBeTruthy();
|
||||
expect(testDb.prepare('SELECT id FROM tour_waypoints WHERE place_id = ?').all(keptTour.id)).toHaveLength(1);
|
||||
expect(testDb.prepare('SELECT id FROM day_assignments WHERE place_id = ?').all(keptTour.id)).toHaveLength(1);
|
||||
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(keptPlace.id)).toBeTruthy();
|
||||
expect(testDb.prepare('SELECT id, place_id FROM trip_files WHERE id = ?').get(keptFileId)).toMatchObject({
|
||||
id: keptFileId,
|
||||
place_id: keptTour.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('PLACE-SVC-019e — a place with no booking is untouched by that', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -837,6 +910,24 @@ describe('remove', () => {
|
||||
// ── removeMany ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('removeMany', () => {
|
||||
it('PLACE-SVC-TOURS-003 — mixed bulk deletion reports only the deleted Tour facets', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any;
|
||||
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
|
||||
const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any;
|
||||
for (const place of [tour, keptTour]) {
|
||||
testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(place.id);
|
||||
}
|
||||
|
||||
const result = await svc.removeMany(String(trip.id), [ordinary.id, tour.id]);
|
||||
|
||||
expect(result.deleted).toEqual([ordinary.id, tour.id]);
|
||||
expect(result.deletedTourPlaceIds).toEqual([tour.id]);
|
||||
expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(keptTour.id)).toBeTruthy();
|
||||
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(keptTour.id)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-056 — deletes the trip-scoped ids in one transaction and reports them', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* ToursController and ToursImportController: route metadata, delegation to
|
||||
* ToursService, and the GPX import's handler-level checks. The import is not
|
||||
* guarded (a guard answers before multer drains the body and the client sees
|
||||
* ECONNRESET), so its handler has to reproduce the guards' answers itself, in
|
||||
* the same order and with the same bodies (parity with AddonGuard and
|
||||
* TripAccessGuard).
|
||||
*/
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursImportController } from '../../../src/nest/tours/tours-import.controller';
|
||||
import { ToursController } from '../../../src/nest/tours/tours.controller';
|
||||
import type { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { HttpException, RequestMethod } from '@nestjs/common';
|
||||
import {
|
||||
GUARDS_METADATA,
|
||||
HTTP_CODE_METADATA,
|
||||
INTERCEPTORS_METADATA,
|
||||
METHOD_METADATA,
|
||||
PATH_METADATA,
|
||||
} from '@nestjs/common/constants';
|
||||
import type { TourCreateRequest } from '@trek/shared';
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
|
||||
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
|
||||
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
||||
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
|
||||
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
||||
|
||||
const request: TourCreateRequest = {
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike' as const,
|
||||
route_geometry: [
|
||||
[48, 11, 600],
|
||||
[48.02, 11.04, 630],
|
||||
] as [number, number, number][],
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end' as const, sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 2,
|
||||
duration_seconds: 3600,
|
||||
};
|
||||
|
||||
const user = { id: 2, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { id: 7, user_id: 1 };
|
||||
const file = { buffer: Buffer.from('<gpx/>'), originalname: 'ridge.gpx' } as Express.Multer.File;
|
||||
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
function importFixture(
|
||||
o: { addon?: boolean; trip?: typeof trip | undefined; canEdit?: boolean; result?: unknown } = {},
|
||||
) {
|
||||
const addons = { isAddonEnabled: vi.fn(async () => o.addon ?? true) };
|
||||
const places = {
|
||||
verifyTripAccess: vi.fn(async () => ('trip' in o ? o.trip : trip)),
|
||||
canEdit: vi.fn(async () => o.canEdit ?? true),
|
||||
};
|
||||
const result = 'result' in o ? o.result : { tours: [{ place_id: 42 }], caution: false, skipped: 0 };
|
||||
const tours = { importGpxAsTour: vi.fn(async () => result) };
|
||||
const controller = new ToursImportController(
|
||||
tours as unknown as ToursService,
|
||||
places as unknown as PlacesService,
|
||||
addons as unknown as AddonsService,
|
||||
);
|
||||
return { addons, places, tours, controller, result };
|
||||
}
|
||||
|
||||
describe('ToursController planner routes', () => {
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-CTL-001: hides every Tours route behind the addon guard before auth', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, ToursController)).toBe('api/trips/:tripId/tours');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, ToursController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
||||
});
|
||||
|
||||
it('TOURS-CTL-001b: routes, methods and write permissions', () => {
|
||||
const routes = [
|
||||
['list', RequestMethod.GET, '/', undefined],
|
||||
['detail', RequestMethod.GET, ':placeId', undefined],
|
||||
['create', RequestMethod.POST, '/', 'place_edit'],
|
||||
['update', RequestMethod.PUT, ':placeId', 'place_edit'],
|
||||
] as const;
|
||||
for (const [handler, method, path, permission] of routes) {
|
||||
const fn = ToursController.prototype[handler];
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path);
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBe(permission);
|
||||
}
|
||||
// POST keeps Nest's default 201, as the PR's route answered.
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, ToursController.prototype.create)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TOURS-CTL-002: POST /tours delegates the validated payload and socket id', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
||||
const service = { createTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
||||
const controller = new ToursController(service);
|
||||
|
||||
expect(await controller.create('7', request, 'socket-1')).toBe(result);
|
||||
expect(service.createTour).toHaveBeenCalledWith('7', request, 'socket-1');
|
||||
});
|
||||
|
||||
it('TOURS-CTL-003: GET /tours/:placeId delegates within the guarded trip', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
||||
const service = { getTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
||||
const controller = new ToursController(service);
|
||||
|
||||
expect(await controller.detail('7', '1')).toBe(result);
|
||||
expect(service.getTour).toHaveBeenCalledWith('7', '1');
|
||||
});
|
||||
|
||||
it('TOURS-CTL-004: PUT /tours/:placeId delegates the validated payload and socket id', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
||||
const service = { updateTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
||||
const controller = new ToursController(service);
|
||||
|
||||
expect(await controller.update('7', '1', request, 'socket-2')).toBe(result);
|
||||
expect(service.updateTour).toHaveBeenCalledWith('7', '1', request, 'socket-2');
|
||||
});
|
||||
|
||||
it('TOURS-CTL-005: GET /tours wraps the list in { tours }', async () => {
|
||||
const tours = [{ place_id: 1 }];
|
||||
const service = { listTours: vi.fn().mockResolvedValue(tours) } as unknown as ToursService;
|
||||
|
||||
expect(await new ToursController(service).list('7')).toEqual({ tours });
|
||||
expect(service.listTours).toHaveBeenCalledWith('7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursImportController (POST /api/trips/:tripId/tours/import/gpx)', () => {
|
||||
it('TOURS-IMP-001: the multipart route carries only the JWT guard and checks the rest in the handler', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, ToursImportController)).toBe('api/trips/:tripId/tours');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, ToursImportController)).toBeUndefined();
|
||||
const handler = ToursImportController.prototype.importGpx;
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('import/gpx');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(INTERCEPTORS_METADATA, handler)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('TOURS-IMP-002: delegates the route trip, file and socket id and returns the service result', async () => {
|
||||
const { controller, tours, places, result } = importFixture();
|
||||
|
||||
expect(await controller.importGpx(user, '7', file, 'socket')).toBe(result);
|
||||
expect(places.verifyTripAccess).toHaveBeenCalledWith('7', user.id);
|
||||
expect(places.canEdit).toHaveBeenCalledWith(trip, user);
|
||||
expect(tours.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket');
|
||||
});
|
||||
|
||||
it('TOURS-IMP-003: a duplicate-only import is a successful skipped result', async () => {
|
||||
const allSkipped = { tours: [], caution: false, skipped: 2 };
|
||||
const { controller } = importFixture({ result: allSkipped });
|
||||
expect(await controller.importGpx(user, '7', file)).toBe(allSkipped);
|
||||
});
|
||||
|
||||
it('TOURS-IMP-004: addon off is the AddonGuard 404 before any trip lookup', async () => {
|
||||
const { controller, places, tours, addons } = importFixture({ addon: false });
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({
|
||||
status: 404,
|
||||
body: { error: 'Tours addon is not enabled' },
|
||||
});
|
||||
expect(addons.isAddonEnabled).toHaveBeenCalledWith('tours');
|
||||
expect(places.verifyTripAccess).not.toHaveBeenCalled();
|
||||
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-IMP-005: an inaccessible trip is the TripAccessGuard 404 before any permission check', async () => {
|
||||
const { controller, places, tours } = importFixture({ trip: undefined });
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '8', file))).toEqual({
|
||||
status: 404,
|
||||
body: { error: 'Trip not found' },
|
||||
});
|
||||
expect(places.verifyTripAccess).toHaveBeenCalledWith('8', user.id);
|
||||
expect(places.canEdit).not.toHaveBeenCalled();
|
||||
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-IMP-006: without place_edit it is the TripAccessGuard 403, checked before the file', async () => {
|
||||
const { controller, tours } = importFixture({ canEdit: false });
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({
|
||||
status: 403,
|
||||
body: { error: 'No permission' },
|
||||
});
|
||||
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-IMP-007: 400 without a file', async () => {
|
||||
const { controller, tours } = importFixture();
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'No file uploaded' },
|
||||
});
|
||||
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-IMP-008: 400 when the GPX has no track or route', async () => {
|
||||
const { controller } = importFixture({ result: null });
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'No track or route found in GPX file' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* The tours-mode GPX import end to end through the real PlacesService and
|
||||
* ToursService over the migrated per-worker DB: the places, their track
|
||||
* colours and their tours facets commit in one transaction (PlacesService's
|
||||
* importPreparedGpx nests in ToursService's as a savepoint), any failure on
|
||||
* the way rolls all of it back, and the realtime events go out only after the
|
||||
* commit. Failures are injected with SQLite triggers on the real tables.
|
||||
*/
|
||||
import { db as testDb } from '../../../src/db/database';
|
||||
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
||||
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { gpxParser } from '../../../src/nest/places/places.helpers';
|
||||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||||
import { accommodationsOver } from '../../helpers/accommodations-service';
|
||||
import { createTrip, createUser } from '../../helpers/factories';
|
||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||||
import {
|
||||
createTestJourneysRepo,
|
||||
createTestJourneyContributorsRepo,
|
||||
createTestJourneyTripsRepo,
|
||||
createTestJourneyEntriesRepo,
|
||||
createTestJourneyPhotosRepo,
|
||||
createTestJourneyEntryPhotosRepo,
|
||||
} from '../../helpers/journey-repos';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import {
|
||||
createTestUnitOfWork,
|
||||
createTestAppSettingsRepo,
|
||||
createTestUsersRepo,
|
||||
createTestTagsRepo,
|
||||
createTestPlaceRatingsRepo,
|
||||
createTestAssignmentParticipantsRepo,
|
||||
createTestPlacesRepo,
|
||||
createTestTripMembersRepo,
|
||||
createTestDayAssignmentsRepo,
|
||||
createTestCategoriesRepo,
|
||||
createTestTripsRepo,
|
||||
createTestCollectionPlacesRepo,
|
||||
sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTestToursRepo, createTestTourWaypointsRepo } from '../../helpers/tours-repos';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const mixedGpx = Buffer.from(`<gpx>
|
||||
<wpt lat="1" lon="2"><name>Ignored POI</name></wpt>
|
||||
<rte><desc>Route description</desc><rtept lat="48" lon="11"/><rtept lat="48.01" lon="11.01"/></rte>
|
||||
<trk><name>Ridge</name><desc>Track description</desc><trkseg>
|
||||
<trkpt lat="49" lon="12"><ele>100</ele></trkpt>
|
||||
<trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
|
||||
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk>
|
||||
</gpx>`);
|
||||
|
||||
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color'];
|
||||
|
||||
/** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */
|
||||
async function makePlacesService(): Promise<PlacesService> {
|
||||
const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService;
|
||||
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
|
||||
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
|
||||
const storage = makeStorageFixture('').storage;
|
||||
const orm = await sharedTestOrm(testDb);
|
||||
return new PlacesService(
|
||||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||||
new RealtimeService(),
|
||||
new MapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota),
|
||||
new QueryHelpersService(
|
||||
await createTestTagsRepo(testDb),
|
||||
await createTestPlaceRatingsRepo(testDb),
|
||||
await createTestAssignmentParticipantsRepo(testDb),
|
||||
),
|
||||
new UnsplashService(
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
await createTestUsersRepo(testDb),
|
||||
new RuntimeEnvService(),
|
||||
storage,
|
||||
),
|
||||
photoCacheStub,
|
||||
new JourneyDomainService(
|
||||
new RealtimeService(),
|
||||
new TrekPhotoRegistrationService(
|
||||
orm.repo(TrekPhotos),
|
||||
orm.repo(TripPhotos),
|
||||
await createTestJourneyPhotosRepo(testDb),
|
||||
),
|
||||
await createTestUnitOfWork(testDb),
|
||||
await createTestJourneysRepo(testDb),
|
||||
await createTestJourneyContributorsRepo(testDb),
|
||||
await createTestJourneyTripsRepo(testDb),
|
||||
await createTestJourneyEntriesRepo(testDb),
|
||||
await createTestTripsRepo(testDb),
|
||||
await createTestJourneyPhotosRepo(testDb),
|
||||
await createTestJourneyEntryPhotosRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
),
|
||||
storage,
|
||||
await accommodationsOver(testDb),
|
||||
await createTestUnitOfWork(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
await createTestTagsRepo(testDb),
|
||||
await createTestPlaceRatingsRepo(testDb),
|
||||
await createTestTripMembersRepo(testDb),
|
||||
await createTestDayAssignmentsRepo(testDb),
|
||||
await createTestCategoriesRepo(testDb),
|
||||
await createTestTripsRepo(testDb),
|
||||
await createTestBudgetItemsRepo(testDb),
|
||||
await createTestCollectionPlacesRepo(testDb),
|
||||
);
|
||||
}
|
||||
|
||||
let uow: UnitOfWork;
|
||||
let places: PlacesService;
|
||||
let tours: ToursService;
|
||||
let tripId: string;
|
||||
let broadcast: MockInstance<PlacesService['broadcast']>;
|
||||
|
||||
const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
|
||||
|
||||
beforeAll(async () => {
|
||||
uow = await createTestUnitOfWork(testDb);
|
||||
places = await makePlacesService();
|
||||
tours = new ToursService(
|
||||
uow,
|
||||
places,
|
||||
await createTestToursRepo(testDb),
|
||||
await createTestTourWaypointsRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
(await sharedTestOrm(testDb)).clear();
|
||||
const { user } = createUser(testDb);
|
||||
tripId = String(createTrip(testDb, user.id).id);
|
||||
broadcast = vi.spyOn(places, 'broadcast').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of TRIGGERS) testDb.exec(`DROP TRIGGER IF EXISTS ${name}`);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
function expectEmpty() {
|
||||
for (const table of ['places', 'tours', 'tour_waypoints']) expect(count(table)).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it('commits routes, tracks, colors and facets once before publishing, without Planner waypoints', async () => {
|
||||
const transactional = vi.spyOn(uow, 'transactional');
|
||||
const seen: { inTransaction: boolean; tours: number }[] = [];
|
||||
broadcast.mockImplementation(() => {
|
||||
seen.push({ inTransaction: testDb.inTransaction, tours: count('tours') });
|
||||
});
|
||||
|
||||
const result = (await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket'))!;
|
||||
|
||||
// The tours transaction, with importPreparedGpx and its colouring nested inside as savepoints.
|
||||
expect(transactional.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.tours).toHaveLength(2);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(result.caution).toBe(true);
|
||||
expect(result.tours[0]).toMatchObject({
|
||||
name: 'walk',
|
||||
tour_type: 'hike',
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
has_waypoints: false,
|
||||
caution: true,
|
||||
match_confidence: 0.3,
|
||||
});
|
||||
expect(result.tours[1]).toMatchObject({
|
||||
name: 'Ridge',
|
||||
elevation_gain: 50,
|
||||
elevation_loss: 30,
|
||||
caution: false,
|
||||
match_confidence: 1,
|
||||
});
|
||||
|
||||
const rows = testDb.prepare('SELECT * FROM places ORDER BY id').all() as {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
description: string;
|
||||
route_geometry: string;
|
||||
route_color: string;
|
||||
}[];
|
||||
expect(rows.map((row) => row.trip_id)).toEqual([Number(tripId), Number(tripId)]);
|
||||
expect(rows.map((row) => row.description)).toEqual(['Route description', 'Track description']);
|
||||
expect(JSON.parse(rows[0].route_geometry)).toEqual([
|
||||
[48, 11],
|
||||
[48.01, 11.01],
|
||||
]);
|
||||
expect(JSON.parse(rows[1].route_geometry)).toEqual([
|
||||
[49, 12, 100],
|
||||
[49.01, 12.01, 150],
|
||||
[49.02, 12.02, 120],
|
||||
]);
|
||||
expect(new Set(rows.map((row) => row.route_color)).size).toBe(2);
|
||||
expect(testDb.prepare('SELECT place_id, tour_type FROM tours ORDER BY place_id').all()).toEqual(
|
||||
rows.map((row) => ({ place_id: row.id, tour_type: 'hike' })),
|
||||
);
|
||||
expect(count('tour_waypoints')).toBe(0);
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ inTransaction: false, tours: 2 },
|
||||
{ inTransaction: false, tours: 2 },
|
||||
{ inTransaction: false, tours: 2 },
|
||||
]);
|
||||
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']);
|
||||
expect(broadcast.mock.calls[0]).toEqual([
|
||||
tripId,
|
||||
'tours:changed',
|
||||
{ placeIds: rows.map((row) => row.id) },
|
||||
'socket',
|
||||
]);
|
||||
for (const call of broadcast.mock.calls.slice(1)) {
|
||||
expect(call[3]).toBe('socket');
|
||||
expect((call[2] as { place: { route_color: string } }).place.route_color).toBeTruthy();
|
||||
}
|
||||
expect(broadcast.mock.calls.slice(1).map((call) => (call[2] as { place: { id: number } }).place.id)).toEqual(
|
||||
result.tours.map((tour) => tour.place_id),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, 2])('rolls back every carrier and facet when facet %i fails', async (ordinal) => {
|
||||
testDb.exec(`CREATE TRIGGER fail_facet BEFORE INSERT ON tours
|
||||
WHEN (SELECT COUNT(*) FROM tours) = ${ordinal - 1}
|
||||
BEGIN SELECT RAISE(ABORT, 'facet failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('facet failure');
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it('rolls back earlier carriers when the later Place insert fails', async () => {
|
||||
testDb.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge'
|
||||
BEGIN SELECT RAISE(ABORT, 'place failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('place failure');
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it('rolls back carriers and earlier colors when later coloring fails', async () => {
|
||||
testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
|
||||
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('color failure');
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<not-gpx/>',
|
||||
'<gpx/>',
|
||||
'<gpx><wpt lat="1" lon="2"/></gpx>',
|
||||
'<gpx><trk><trkseg><trkpt/></trkseg></trk></gpx>',
|
||||
])('does not write or publish unusable input %s', async (xml) => {
|
||||
const transactional = vi.spyOn(uow, 'transactional');
|
||||
expect(await tours.importGpxAsTour(tripId, Buffer.from(xml))).toBeNull();
|
||||
expect(transactional).not.toHaveBeenCalled();
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it('propagates parser failures before entering the transaction', async () => {
|
||||
const transactional = vi.spyOn(uow, 'transactional');
|
||||
vi.spyOn(gpxParser, 'parse').mockImplementationOnce(() => {
|
||||
throw new Error('parser failure');
|
||||
});
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('parser failure');
|
||||
expect(transactional).not.toHaveBeenCalled();
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it('keeps a committed response and attempts remaining events if publication throws', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementationOnce(() => {
|
||||
throw new Error('transport unavailable');
|
||||
});
|
||||
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
|
||||
expect(count('tours')).toBe(2);
|
||||
expect(broadcast).toHaveBeenCalledTimes(3);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps committed rows and success response if a place event fails', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementation((_tripId, event) => {
|
||||
if (event === 'place:created') throw new Error('transport unavailable');
|
||||
});
|
||||
|
||||
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
|
||||
expect(count('places')).toBe(2);
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps committed rows and success response if the Tours invalidation event fails', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementation((_tripId, event) => {
|
||||
if (event === 'tours:changed') throw new Error('invalidation unavailable');
|
||||
});
|
||||
|
||||
const result = await tours.importGpxAsTour(tripId, mixedGpx);
|
||||
|
||||
expect(result?.tours).toHaveLength(2);
|
||||
expect(count('places')).toBe(2);
|
||||
expect(count('tours')).toBe(2);
|
||||
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns a successful skipped result for duplicate-only imports without publishing events', async () => {
|
||||
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
|
||||
const rowsBefore = testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all();
|
||||
const facetsBefore = testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all();
|
||||
broadcast.mockClear();
|
||||
|
||||
expect(await tours.importGpxAsTour(tripId, mixedGpx)).toEqual({ tours: [], caution: false, skipped: 2 });
|
||||
expect(testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all()).toEqual(rowsBefore);
|
||||
expect(testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all()).toEqual(facetsBefore);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('imports only the new tracks and publishes only committed rows in a mixed result', async () => {
|
||||
const existing = Buffer.from(`<gpx><trk><name>Ridge</name><trkseg>
|
||||
<trkpt lat="49" lon="12"><ele>100</ele></trkpt><trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
|
||||
</trkseg></trk></gpx>`);
|
||||
expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1);
|
||||
broadcast.mockClear();
|
||||
|
||||
const result = await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket');
|
||||
|
||||
expect(result?.tours).toHaveLength(1);
|
||||
expect(result?.tours[0].name).toBe('walk');
|
||||
expect(result?.skipped).toBe(1);
|
||||
expect(count('places')).toBe(2);
|
||||
expect(count('tours')).toBe(2);
|
||||
const walkId = result!.tours[0].place_id;
|
||||
expect(broadcast.mock.calls.map((call) => [call[1], call[2]])).toEqual([
|
||||
['tours:changed', { placeIds: [walkId] }],
|
||||
['place:created', expect.objectContaining({ place: expect.objectContaining({ id: walkId, name: 'walk' }) })],
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves single-track response and trip scoping', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const otherTripId = String(createTrip(testDb, user.id).id);
|
||||
const single = Buffer.from(
|
||||
'<gpx><trk><trkseg><trkpt lat="48" lon="11"/><trkpt lat="48.01" lon="11.01"/></trkseg></trk></gpx>',
|
||||
);
|
||||
|
||||
const result = (await tours.importGpxAsTour(otherTripId, single, 'single.gpx'))!;
|
||||
|
||||
expect(result.tours).toHaveLength(1);
|
||||
expect(result.tours[0].name).toBe('single');
|
||||
expect(result.caution).toBe(true);
|
||||
expect(testDb.prepare('SELECT trip_id FROM places').all()).toEqual([{ trip_id: Number(otherTripId) }]);
|
||||
expect((await tours.listTours(otherTripId)).map((t) => t.place_id)).toEqual([result.tours[0].place_id]);
|
||||
expect(await tours.listTours(tripId)).toEqual([]);
|
||||
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Tours reuse the ordinary place and day-assignment endpoints for deletion and
|
||||
* planning, so their permissions are those endpoints' permissions: deleting a
|
||||
* tour needs 'place_edit' only, assigning or unassigning one needs 'day_edit'
|
||||
* only. Run through the real TripAccessGuard with mocked collaborators (no DB).
|
||||
* The last block pins ToursService's trip boundary on update against stubbed
|
||||
* repositories.
|
||||
*/
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
|
||||
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { DayAssignmentsController } from '../../../src/nest/assignments/assignments.controller';
|
||||
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { TripAccessGuard, TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import { PlacesController } from '../../../src/nest/places/places.controller';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import type { EntityManager } from '@mikro-orm/core';
|
||||
import { HttpException, NotFoundException, RequestMethod, type ExecutionContext } from '@nestjs/common';
|
||||
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { TourCreateRequest } from '@trek/shared';
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
|
||||
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
||||
vi.mock('../../../src/nest/assignments/assignments.service', () => ({ AssignmentsService: class {} }));
|
||||
vi.mock('../../../src/nest/app-config/runtime-env.service', () => ({ RuntimeEnvService: class {} }));
|
||||
vi.mock('../../../src/nest/storage/storage.service', () => ({ StorageService: class {} }));
|
||||
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
||||
|
||||
const matrix = [
|
||||
[false, false],
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
] as const;
|
||||
|
||||
function authorization(
|
||||
controller: typeof PlacesController | typeof DayAssignmentsController,
|
||||
handler: 'create' | 'remove',
|
||||
canEdit: boolean,
|
||||
canAssign: boolean,
|
||||
tripId = '7',
|
||||
) {
|
||||
const request = { params: { tripId }, user: { id: 2, role: 'user' } as User };
|
||||
const trip = { id: 7, user_id: 1 };
|
||||
const access = vi.fn(async (id: number, userId: number) => (id === 7 && userId === 2 ? trip : undefined));
|
||||
const permission = vi.fn(async (action: string) =>
|
||||
action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign,
|
||||
);
|
||||
const context = {
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
getHandler: () => controller.prototype[handler],
|
||||
getClass: () => controller,
|
||||
} as unknown as ExecutionContext;
|
||||
const em = { getRepository: vi.fn(() => ({ findAccessible: access })) } as unknown as EntityManager;
|
||||
const guard = new TripAccessGuard(
|
||||
em,
|
||||
{ checkPermission: permission } as unknown as PermissionsService,
|
||||
new Reflector(),
|
||||
);
|
||||
return { request, access, permission, context, guard };
|
||||
}
|
||||
|
||||
async function rejected(action: () => Promise<unknown>, status: number) {
|
||||
await expect(action()).rejects.toMatchObject({ status });
|
||||
}
|
||||
|
||||
function placesFixture() {
|
||||
const places = {
|
||||
get: vi.fn(async () => ({ id: 42, trip_id: 7 })),
|
||||
onDeleted: vi.fn(async () => {}),
|
||||
linkedExpenseIds: vi.fn(async () => []),
|
||||
remove: vi.fn(async () => ({
|
||||
deleted: true,
|
||||
deletedTourPlaceIds: [42],
|
||||
cancelled: { reservationIds: [], budgetItemIds: [] },
|
||||
})),
|
||||
broadcast: vi.fn(),
|
||||
};
|
||||
const controller = new PlacesController(
|
||||
places as unknown as PlacesService,
|
||||
{} as RuntimeEnvService,
|
||||
{} as StorageService,
|
||||
);
|
||||
return { places, controller };
|
||||
}
|
||||
|
||||
function assignmentsFixture() {
|
||||
const assignment = { id: 51, place_id: 42, day_id: 11 };
|
||||
const assignments = {
|
||||
dayExists: vi.fn(async () => true),
|
||||
placeExists: vi.fn(async () => true),
|
||||
assignmentExistsInDay: vi.fn(async () => true),
|
||||
createAssignment: vi.fn(async () => assignment),
|
||||
deleteAssignment: vi.fn(async () => {}),
|
||||
broadcast: vi.fn(),
|
||||
reconcile: vi.fn(async () => {}),
|
||||
};
|
||||
return {
|
||||
assignment,
|
||||
assignments,
|
||||
controller: new DayAssignmentsController(assignments as unknown as AssignmentsService),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Tours deletion and assignment authorization (mock-only)', () => {
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps existing Places deletion and day assignment endpoints wired to their exact permissions', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, PlacesController)).toBe('api/trips/:tripId/places');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, PlacesController)).toEqual([JwtAuthGuard]);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, PlacesController.prototype.remove)).toEqual([TripAccessGuard]);
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, PlacesController.prototype.remove)).toBe('place_edit');
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, PlacesController.prototype.remove)).toBe(RequestMethod.DELETE);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, PlacesController.prototype.remove)).toBe(':id');
|
||||
expect(Reflect.getMetadata(PATH_METADATA, DayAssignmentsController)).toBe(
|
||||
'api/trips/:tripId/days/:dayId/assignments',
|
||||
);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, DayAssignmentsController)).toEqual([JwtAuthGuard, TripAccessGuard]);
|
||||
for (const handler of ['create', 'remove'] as const) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, DayAssignmentsController.prototype[handler])).toBe('day_edit');
|
||||
}
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, DayAssignmentsController.prototype.create)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, DayAssignmentsController.prototype.remove)).toBe(RequestMethod.DELETE);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, DayAssignmentsController.prototype.remove)).toBe(':id');
|
||||
});
|
||||
|
||||
it.each(matrix)('edit=%s assign=%s: tour deletion requires only place_edit', async (canEdit, canAssign) => {
|
||||
const auth = authorization(PlacesController, 'remove', canEdit, canAssign);
|
||||
const { places, controller } = placesFixture();
|
||||
const invoke = async () => {
|
||||
await auth.guard.canActivate(auth.context);
|
||||
return await controller.remove(auth.request.user, '7', '42', 'socket');
|
||||
};
|
||||
if (canEdit) {
|
||||
await expect(invoke()).resolves.toEqual({ success: true, tourPlaceIds: [42] });
|
||||
expect(places.get).toHaveBeenCalledWith('7', '42');
|
||||
expect(places.onDeleted).toHaveBeenCalledWith(42);
|
||||
expect(places.remove).toHaveBeenCalledWith('7', '42');
|
||||
expect(places.broadcast).toHaveBeenCalledWith('7', 'place:deleted', { placeId: 42 }, 'socket');
|
||||
} else {
|
||||
await rejected(invoke, 403);
|
||||
for (const method of Object.values(places)) expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(auth.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
|
||||
});
|
||||
|
||||
describe.each(['create', 'remove'] as const)('assignment %s', (handler) => {
|
||||
it.each(matrix)('edit=%s assign=%s: requires only day_edit', async (canEdit, canAssign) => {
|
||||
const auth = authorization(DayAssignmentsController, handler, canEdit, canAssign);
|
||||
const { assignment, assignments, controller } = assignmentsFixture();
|
||||
const invoke = async () => {
|
||||
await auth.guard.canActivate(auth.context);
|
||||
return handler === 'create'
|
||||
? await controller.create(auth.request.user, '7', '11', { place_id: 42, notes: 'Tour' }, 'socket')
|
||||
: await controller.remove(auth.request.user, '7', '11', '51', 'socket');
|
||||
};
|
||||
if (canAssign) {
|
||||
expect(await invoke()).toEqual(handler === 'create' ? { assignment } : { success: true });
|
||||
if (handler === 'create') {
|
||||
expect(assignments.dayExists).toHaveBeenCalledWith('11', '7');
|
||||
expect(assignments.placeExists).toHaveBeenCalledWith(42, '7');
|
||||
expect(assignments.createAssignment).toHaveBeenCalledWith('11', 42, 'Tour');
|
||||
expect(assignments.deleteAssignment).not.toHaveBeenCalled();
|
||||
expect(assignments.broadcast).toHaveBeenCalledWith('7', 'assignment:created', { assignment }, 'socket');
|
||||
} else {
|
||||
expect(assignments.assignmentExistsInDay).toHaveBeenCalledWith('51', '11', '7');
|
||||
expect(assignments.deleteAssignment).toHaveBeenCalledWith('51');
|
||||
expect(assignments.createAssignment).not.toHaveBeenCalled();
|
||||
expect(assignments.broadcast).toHaveBeenCalledWith(
|
||||
'7',
|
||||
'assignment:deleted',
|
||||
{ assignmentId: 51, dayId: 11 },
|
||||
'socket',
|
||||
);
|
||||
}
|
||||
expect(assignments.reconcile).toHaveBeenCalledWith('7', 'socket');
|
||||
} else {
|
||||
await rejected(invoke, 403);
|
||||
for (const method of Object.values(assignments)) expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(auth.permission).toHaveBeenCalledExactlyOnceWith('day_edit', 'user', 1, 2, true);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[PlacesController, 'remove'],
|
||||
[DayAssignmentsController, 'create'],
|
||||
[DayAssignmentsController, 'remove'],
|
||||
] as const)('%s.%s rejects an inaccessible route trip before checking permissions', async (owner, handler) => {
|
||||
const auth = authorization(owner, handler, true, true, '8');
|
||||
await rejected(() => auth.guard.canActivate(auth.context), 404);
|
||||
expect(auth.access).toHaveBeenCalledExactlyOnceWith(8, 2);
|
||||
expect(auth.permission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const input: TourCreateRequest = {
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
max_hiking_difficulty: 2,
|
||||
route_geometry: [
|
||||
[48, 11, 600],
|
||||
[48.02, 11.04, 630],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
/** Tour 42 belongs to `ownerTripId`; findInTrip answers only for that trip. */
|
||||
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,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: 0,
|
||||
has_waypoints: 1,
|
||||
};
|
||||
const uow = { transactional: vi.fn(async <T>(fn: () => Promise<T>): Promise<T> => await fn()) };
|
||||
const toursRepo = {
|
||||
findInTrip: vi.fn(async (tripId: number, placeId: number) =>
|
||||
tripId === ownerTripId && placeId === 42 ? row : undefined,
|
||||
),
|
||||
updateInTrip: vi.fn(async () => true),
|
||||
};
|
||||
const waypointsRepo = {
|
||||
listForPlace: vi.fn(async () => input.waypoints),
|
||||
deleteForPlace: vi.fn(async () => {}),
|
||||
insertForPlace: vi.fn(async () => {}),
|
||||
};
|
||||
const placesRepo = {
|
||||
updateTourRoute: vi.fn(async () => true),
|
||||
findInTrip: vi.fn(async () => undefined),
|
||||
findWithTagsAndRatings: vi.fn(async () => ({ id: 42, trip_id: ownerTripId })),
|
||||
};
|
||||
const places = { broadcast: vi.fn() };
|
||||
const service = new ToursService(
|
||||
uow as unknown as UnitOfWork,
|
||||
places as unknown as PlacesService,
|
||||
toursRepo as unknown as ToursRepository,
|
||||
waypointsRepo as unknown as TourWaypointsRepository,
|
||||
placesRepo as unknown as PlacesRepository,
|
||||
);
|
||||
return { uow, toursRepo, waypointsRepo, placesRepo, places, service };
|
||||
}
|
||||
|
||||
describe('Real ToursService update trip boundary (stubbed repositories)', () => {
|
||||
it('rejects a placeId belonging to another trip before any write', async () => {
|
||||
const f = tourRepositoryFixture(8);
|
||||
expect((await f.service.getTour('8', '42')).tour.place_id).toBe(42);
|
||||
f.toursRepo.findInTrip.mockClear();
|
||||
f.waypointsRepo.listForPlace.mockClear();
|
||||
|
||||
const error: unknown = await f.service.updateTour('7', '42', input, 'socket').catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(NotFoundException);
|
||||
expect((error as HttpException).getStatus()).toBe(404);
|
||||
expect((error as Error).message).toBe('Tour not found');
|
||||
expect(f.toursRepo.findInTrip).toHaveBeenCalledExactlyOnceWith(7, 42);
|
||||
for (const method of [
|
||||
f.placesRepo.updateTourRoute,
|
||||
f.toursRepo.updateInTrip,
|
||||
f.waypointsRepo.deleteForPlace,
|
||||
f.waypointsRepo.insertForPlace,
|
||||
f.waypointsRepo.listForPlace,
|
||||
f.placesRepo.findWithTagsAndRatings,
|
||||
f.places.broadcast,
|
||||
]) {
|
||||
expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows a same-trip update through the transaction with trip-scoped writes', async () => {
|
||||
const f = tourRepositoryFixture(7);
|
||||
|
||||
expect((await f.service.updateTour('7', '42', input, 'socket')).tour.place_id).toBe(42);
|
||||
|
||||
expect(f.uow.transactional).toHaveBeenCalledOnce();
|
||||
// Once inside the write, once for the response.
|
||||
expect(f.toursRepo.findInTrip).toHaveBeenCalledTimes(2);
|
||||
expect(f.placesRepo.updateTourRoute).toHaveBeenCalledExactlyOnceWith(42, 7, {
|
||||
name: input.name,
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
route_geometry: JSON.stringify(input.route_geometry),
|
||||
});
|
||||
expect(f.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(
|
||||
7,
|
||||
42,
|
||||
expect.objectContaining({ tour_type: 'hike', duration: null }),
|
||||
);
|
||||
expect(f.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(f.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, input.waypoints);
|
||||
expect(f.placesRepo.findWithTagsAndRatings).toHaveBeenCalledWith(42);
|
||||
expect(f.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* The Tours permission contract, run through the real guards with mocked
|
||||
* collaborators (no DB): reads need trip access only, writes need
|
||||
* 'place_edit' and never 'day_edit', an inaccessible trip is a 404 before any
|
||||
* permission check, and the addon and JWT refusals stop a request before the
|
||||
* trip is resolved. The GPX import answers the same matrix from its handler.
|
||||
*/
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import { extractToken, verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import {
|
||||
TripAccessGuard,
|
||||
TRIP_PERMISSION_KEY,
|
||||
TRIP_REQUEST_KEY,
|
||||
} from '../../../src/nest/permissions/trip-access.guard';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursImportController } from '../../../src/nest/tours/tours-import.controller';
|
||||
import { ToursController } from '../../../src/nest/tours/tours.controller';
|
||||
import type { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import type { EntityManager } from '@mikro-orm/core';
|
||||
import { HttpException, type ExecutionContext } from '@nestjs/common';
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
|
||||
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
|
||||
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
||||
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
|
||||
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
||||
|
||||
const matrix = [
|
||||
[false, false],
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
] as const;
|
||||
const user = { id: 2, role: 'user' } as User;
|
||||
const trip = { id: 7, user_id: 1 };
|
||||
|
||||
/** Trip 7 is reachable for user 2; it belongs to user 1, so every check is a shared-trip check. */
|
||||
function fixture(handler: keyof ToursController, canEdit = false, canAssign = false, tripId = '7') {
|
||||
const request = { params: { tripId }, user };
|
||||
const access = vi.fn(async (id: number, userId: number) => (id === 7 && userId === 2 ? trip : undefined));
|
||||
const permission = vi.fn(async (action: string) =>
|
||||
action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign,
|
||||
);
|
||||
const context = {
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
getHandler: () => ToursController.prototype[handler],
|
||||
getClass: () => ToursController,
|
||||
} as unknown as ExecutionContext;
|
||||
const em = { getRepository: vi.fn(() => ({ findAccessible: access })) } as unknown as EntityManager;
|
||||
const guard = new TripAccessGuard(
|
||||
em,
|
||||
{ checkPermission: permission } as unknown as PermissionsService,
|
||||
new Reflector(),
|
||||
);
|
||||
return { request, access, permission, context, guard };
|
||||
}
|
||||
|
||||
/** The import handler's own checks, with the same access and permission doubles. */
|
||||
function importFixture(canEdit: boolean, canAssign: boolean) {
|
||||
const permission = vi.fn(async (action: string, ..._context: unknown[]) =>
|
||||
action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign,
|
||||
);
|
||||
const access = vi.fn(async (id: string, userId: number) => (id === '7' && userId === 2 ? trip : undefined));
|
||||
const places = {
|
||||
verifyTripAccess: access,
|
||||
canEdit: vi.fn(async (t: typeof trip, u: User) =>
|
||||
permission('place_edit', u.role, t.user_id, u.id, t.user_id !== u.id),
|
||||
),
|
||||
};
|
||||
const importGpxAsTour = vi.fn(async () => ({ tours: [{ place_id: 42 }], caution: false, skipped: 0 }));
|
||||
const controller = new ToursImportController(
|
||||
{ importGpxAsTour } as unknown as ToursService,
|
||||
places as unknown as PlacesService,
|
||||
{ isAddonEnabled: vi.fn(async () => true) } as unknown as AddonsService,
|
||||
);
|
||||
return { controller, access, permission, importGpxAsTour };
|
||||
}
|
||||
|
||||
async function expectStatus(action: () => Promise<unknown>, status: number) {
|
||||
try {
|
||||
await action();
|
||||
expect.fail('Expected rejection');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpException);
|
||||
expect((error as HttpException).getStatus()).toBe(status);
|
||||
}
|
||||
}
|
||||
|
||||
const file = { buffer: Buffer.from('gpx'), originalname: 'ridge.gpx' } as Express.Multer.File;
|
||||
|
||||
describe('Tours permission contract (mock-only, no DB)', () => {
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains addon/JWT/trip guard order and exact write metadata', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]);
|
||||
for (const handler of ['create', 'update'] as const) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBe('place_edit');
|
||||
}
|
||||
for (const handler of ['list', 'detail'] as const) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(matrix)('edit=%s assign=%s permits reads and only place-edit writes', async (canEdit, canAssign) => {
|
||||
for (const handler of ['list', 'detail'] as const) {
|
||||
const test = fixture(handler, canEdit, canAssign);
|
||||
expect(await test.guard.canActivate(test.context)).toBe(true);
|
||||
expect(test.permission).not.toHaveBeenCalled();
|
||||
expect(test.request[TRIP_REQUEST_KEY as keyof typeof test.request]).toEqual(trip);
|
||||
}
|
||||
for (const handler of ['create', 'update'] as const) {
|
||||
const test = fixture(handler, canEdit, canAssign);
|
||||
if (canEdit) expect(await test.guard.canActivate(test.context)).toBe(true);
|
||||
else await expectStatus(() => test.guard.canActivate(test.context), 403);
|
||||
expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(matrix)('edit=%s assign=%s: the GPX import handler requires only place_edit', async (canEdit, canAssign) => {
|
||||
const test = importFixture(canEdit, canAssign);
|
||||
if (canEdit) {
|
||||
expect(await test.controller.importGpx(user, '7', file, 'socket')).toMatchObject({ tours: [{ place_id: 42 }] });
|
||||
expect(test.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket');
|
||||
} else {
|
||||
await expectStatus(() => test.controller.importGpx(user, '7', file), 403);
|
||||
expect(test.importGpxAsTour).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
|
||||
});
|
||||
|
||||
it.each(['list', 'detail', 'create', 'update'] as const)(
|
||||
'%s rejects inaccessible route trips before any write permission check',
|
||||
async (handler) => {
|
||||
const test = fixture(handler, true, true, '8');
|
||||
await expectStatus(() => test.guard.canActivate(test.context), 404);
|
||||
expect(test.access).toHaveBeenCalledWith(8, 2);
|
||||
expect(test.permission).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('importGpx rejects an inaccessible route trip before any permission check', async () => {
|
||||
const test = importFixture(true, true);
|
||||
await expectStatus(() => test.controller.importGpx(user, '8', file), 404);
|
||||
expect(test.access).toHaveBeenCalledWith('8', 2);
|
||||
expect(test.permission).not.toHaveBeenCalled();
|
||||
expect(test.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves addon and JWT refusal without reaching trip resolution', async () => {
|
||||
const test = fixture('create', true, true);
|
||||
const addon = new AddonGuard(
|
||||
{ isAddonEnabled: vi.fn(async () => false) } as unknown as AddonsService,
|
||||
new Reflector(),
|
||||
);
|
||||
await expectStatus(() => addon.canActivate(test.context), 404);
|
||||
const jwt = new JwtAuthGuard({ getRepository: vi.fn() } as unknown as EntityManager);
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
await expectStatus(() => jwt.canActivate(test.context), 401);
|
||||
vi.mocked(extractToken).mockReturnValue('token');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null);
|
||||
await expectStatus(() => jwt.canActivate(test.context), 401);
|
||||
expect(test.access).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* ToursService write contracts against stubbed repositories and a stub
|
||||
* UnitOfWork (the budget.service.test.ts pattern): which repository call runs
|
||||
* with which arguments, and that every write runs inside the transaction while
|
||||
* the read-back and the broadcasts run after it. Rollback against a real
|
||||
* SQLite database is covered by tours.service.test.ts.
|
||||
*/
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
||||
|
||||
const request: TourCreateRequest = {
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
route_geometry: [
|
||||
[48, 11, 600],
|
||||
[48.01, 11.02, 650],
|
||||
[48.02, 11.04, 630],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 2,
|
||||
duration_seconds: 3600,
|
||||
};
|
||||
|
||||
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,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: 0,
|
||||
has_waypoints: 1,
|
||||
};
|
||||
const place = { id: 42, trip_id: 7 };
|
||||
let inTransaction = false;
|
||||
/** Records whether each write ran inside the transaction. */
|
||||
const inTx = <T>(value: T) =>
|
||||
vi.fn(async (..._args: unknown[]) => {
|
||||
expect(inTransaction).toBe(true);
|
||||
return value;
|
||||
});
|
||||
|
||||
const uow = {
|
||||
transactional: vi.fn(async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
inTransaction = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
inTransaction = false;
|
||||
}
|
||||
}),
|
||||
};
|
||||
const toursRepo = {
|
||||
findInTrip: vi.fn(async (): Promise<typeof row | undefined> => row),
|
||||
insertTour: inTx(undefined),
|
||||
updateInTrip: inTx(true),
|
||||
};
|
||||
const waypointsRepo = {
|
||||
listForPlace: vi.fn(async () => request.waypoints),
|
||||
insertForPlace: inTx(undefined),
|
||||
deleteForPlace: inTx(undefined),
|
||||
};
|
||||
const placesRepo = {
|
||||
insertTourPlace: inTx(42),
|
||||
updateTourRoute: inTx(true),
|
||||
findInTrip: vi.fn(async (): Promise<{ route_geometry: string | null } | undefined> => undefined),
|
||||
findWithTagsAndRatings: vi.fn(async () => {
|
||||
expect(inTransaction).toBe(false);
|
||||
return place;
|
||||
}),
|
||||
};
|
||||
const places = {
|
||||
broadcast: vi.fn(() => {
|
||||
expect(inTransaction).toBe(false);
|
||||
}),
|
||||
};
|
||||
const service = new ToursService(
|
||||
uow as unknown as UnitOfWork,
|
||||
places as unknown as PlacesService,
|
||||
toursRepo as unknown as ToursRepository,
|
||||
waypointsRepo as unknown as TourWaypointsRepository,
|
||||
placesRepo as unknown as PlacesRepository,
|
||||
);
|
||||
return { uow, toursRepo, waypointsRepo, placesRepo, places, place, service };
|
||||
}
|
||||
|
||||
describe('ToursService planner contracts (mock-only; SQLite rollback semantics are covered in tours.service.test.ts)', () => {
|
||||
let setup: ReturnType<typeof makeService>;
|
||||
beforeEach(() => {
|
||||
setup = makeService();
|
||||
});
|
||||
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-001: sends geometry, metrics, and ordered controls through one transaction', async () => {
|
||||
const result = await setup.service.createTour('7', request, 'socket-1');
|
||||
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.placesRepo.insertTourPlace).toHaveBeenCalledExactlyOnceWith({
|
||||
trip_id: 7,
|
||||
name: request.name,
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
route_geometry: JSON.stringify(request.route_geometry),
|
||||
});
|
||||
expect(setup.toursRepo.insertTour).toHaveBeenCalledExactlyOnceWith({
|
||||
place_id: 42,
|
||||
tour_type: 'hike',
|
||||
distance: expect.any(Number),
|
||||
elevation_gain: 50,
|
||||
elevation_loss: 20,
|
||||
duration: 60,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
});
|
||||
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints);
|
||||
expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42);
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true });
|
||||
expect(result.waypoints).toEqual(request.waypoints);
|
||||
expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [42] }, 'socket-1');
|
||||
expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, '7', 'place:created', { place: setup.place }, 'socket-1');
|
||||
});
|
||||
|
||||
it('TOURS-SVC-002: propagates a waypoint failure without publishing or reading an uncommitted create', async () => {
|
||||
const failure = new Error('Injected waypoint failure');
|
||||
setup.waypointsRepo.insertForPlace.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(setup.service.createTour('7', request)).rejects.toBe(failure);
|
||||
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.toursRepo.findInTrip).not.toHaveBeenCalled();
|
||||
expect(setup.placesRepo.findWithTagsAndRatings).not.toHaveBeenCalled();
|
||||
expect(setup.places.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-003: validates and rounds a fractional routed duration before the create write', async () => {
|
||||
const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 });
|
||||
const result = await setup.service.createTour('7', validated);
|
||||
expect(setup.toursRepo.insertTour).toHaveBeenCalledWith(expect.objectContaining({ place_id: 42, duration: 60 }));
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false });
|
||||
});
|
||||
|
||||
it('TOURS-SVC-004: scopes detail reads to the trip and derives legacy GPX controls without writes', async () => {
|
||||
expect((await setup.service.getTour('7', '42')).waypoints).toEqual(request.waypoints);
|
||||
expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42);
|
||||
expect(setup.waypointsRepo.listForPlace).toHaveBeenCalledWith(42);
|
||||
|
||||
setup.toursRepo.findInTrip.mockResolvedValueOnce(undefined);
|
||||
setup.waypointsRepo.listForPlace.mockClear();
|
||||
await expect(setup.service.getTour('8', '42')).rejects.toThrow('Tour not found');
|
||||
expect(setup.waypointsRepo.listForPlace).not.toHaveBeenCalled();
|
||||
|
||||
setup.waypointsRepo.listForPlace.mockResolvedValueOnce([]);
|
||||
setup.placesRepo.findInTrip.mockResolvedValueOnce({ route_geometry: JSON.stringify(request.route_geometry) });
|
||||
const legacyGpx = await setup.service.getTour('7', '42');
|
||||
expect(setup.placesRepo.findInTrip).toHaveBeenCalledWith(42, 7);
|
||||
expect(legacyGpx.waypoints).toEqual([
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
]);
|
||||
for (const method of [
|
||||
setup.uow.transactional,
|
||||
setup.placesRepo.insertTourPlace,
|
||||
setup.placesRepo.updateTourRoute,
|
||||
setup.toursRepo.insertTour,
|
||||
setup.toursRepo.updateInTrip,
|
||||
setup.waypointsRepo.insertForPlace,
|
||||
setup.waypointsRepo.deleteForPlace,
|
||||
setup.places.broadcast,
|
||||
]) {
|
||||
expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('TOURS-SVC-005: updates route data and replaces controls through one transaction', async () => {
|
||||
const update: TourCreateRequest = {
|
||||
...request,
|
||||
name: 'Updated ridge walk',
|
||||
route_geometry: [
|
||||
[49, 12, 700],
|
||||
[49.02, 12.04, 760],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 49, lng: 12, role: 'start', sequence: 0 },
|
||||
{ lat: 49.01, lng: 12.02, role: 'via', sequence: 1 },
|
||||
{ lat: 49.02, lng: 12.04, role: 'end', sequence: 2 },
|
||||
],
|
||||
duration_seconds: 2700.5,
|
||||
};
|
||||
|
||||
await setup.service.updateTour('7', '42', update, 'socket-2');
|
||||
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.placesRepo.updateTourRoute).toHaveBeenCalledExactlyOnceWith(42, 7, {
|
||||
name: update.name,
|
||||
lat: 49,
|
||||
lng: 12,
|
||||
route_geometry: JSON.stringify(update.route_geometry),
|
||||
});
|
||||
expect(setup.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(7, 42, {
|
||||
tour_type: 'hike',
|
||||
distance: expect.any(Number),
|
||||
elevation_gain: 60,
|
||||
elevation_loss: 0,
|
||||
duration: 45,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
});
|
||||
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints);
|
||||
expect(setup.waypointsRepo.deleteForPlace.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
setup.waypointsRepo.insertForPlace.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(setup.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: setup.place }, 'socket-2');
|
||||
});
|
||||
|
||||
it('TOURS-SVC-006: propagates a replacement failure without publishing or reloading', async () => {
|
||||
const failure = new Error('Injected replacement failure');
|
||||
setup.waypointsRepo.insertForPlace.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(setup.service.updateTour('7', '42', request)).rejects.toBe(failure);
|
||||
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.toursRepo.findInTrip).toHaveBeenCalledTimes(1);
|
||||
expect(setup.placesRepo.findWithTagsAndRatings).not.toHaveBeenCalled();
|
||||
expect(setup.places.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the tour lookup', 'findInTrip'],
|
||||
['the trip-scoped place update', 'updateTourRoute'],
|
||||
['the trip-scoped tour update', 'updateInTrip'],
|
||||
] as const)('TOURS-SVC-010: a miss in %s is a 404 that stops the write', async (_label, step) => {
|
||||
if (step === 'findInTrip') setup.toursRepo.findInTrip.mockResolvedValueOnce(undefined);
|
||||
if (step === 'updateTourRoute') setup.placesRepo.updateTourRoute.mockResolvedValueOnce(false);
|
||||
if (step === 'updateInTrip') setup.toursRepo.updateInTrip.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(setup.service.updateTour('7', '42', request)).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
// The existence check runs inside the write's own transaction (F44).
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.waypointsRepo.deleteForPlace).not.toHaveBeenCalled();
|
||||
expect(setup.waypointsRepo.insertForPlace).not.toHaveBeenCalled();
|
||||
expect(setup.places.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* ToursService over the real migrated per-worker DB: the tours facet, its
|
||||
* ordered control points and the owning place are written through the Tours,
|
||||
* TourWaypoints and Places repositories inside one UnitOfWork transaction, and
|
||||
* realtime events go out only after that transaction committed.
|
||||
*
|
||||
* PlacesService is a broadcast stub here; the GPX import path, which needs the
|
||||
* real PlacesService, is covered by tours.gpx.atomic.test.ts.
|
||||
*/
|
||||
import { db as testDb } from '../../../src/db/database';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestToursRepo, createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
|
||||
import { Logger, NotFoundException } from '@nestjs/common';
|
||||
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const request: TourCreateRequest = {
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
route_geometry: [
|
||||
[48, 11, 600],
|
||||
[48.01, 11.02, 650],
|
||||
[48.02, 11.04, 630],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 2,
|
||||
duration_seconds: 3600,
|
||||
};
|
||||
|
||||
/** Two waypoints on the same sequence: the UNIQUE(place_id, sequence) index rejects the second one. */
|
||||
const duplicateSequence = {
|
||||
...request,
|
||||
waypoints: [request.waypoints[0], { ...request.waypoints[1], sequence: 0 }],
|
||||
} as TourCreateRequest;
|
||||
|
||||
const broadcast = vi.fn();
|
||||
let service: ToursService;
|
||||
let tripId: string;
|
||||
let otherTripId: string;
|
||||
|
||||
const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
|
||||
|
||||
beforeAll(async () => {
|
||||
service = new ToursService(
|
||||
await createTestUnitOfWork(testDb),
|
||||
{ broadcast } as unknown as PlacesService,
|
||||
await createTestToursRepo(testDb),
|
||||
await createTestTourWaypointsRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
(await sharedTestOrm(testDb)).clear();
|
||||
broadcast.mockReset();
|
||||
const { user } = createUser(testDb);
|
||||
tripId = String(createTrip(testDb, user.id).id);
|
||||
otherTripId = String(createTrip(testDb, user.id).id);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('ToursService planner creation', () => {
|
||||
it('TOURS-SVC-001: atomically stores full geometry, derived metrics, and ordered control points', async () => {
|
||||
const writeStates: boolean[] = [];
|
||||
broadcast.mockImplementation(() => {
|
||||
writeStates.push(testDb.inTransaction);
|
||||
});
|
||||
|
||||
const result = await service.createTour(tripId, request, 'socket-1');
|
||||
|
||||
const placeId = result.tour.place_id;
|
||||
const place = testDb.prepare('SELECT * FROM places WHERE id = ?').get(placeId) as Record<string, unknown>;
|
||||
const tour = testDb.prepare('SELECT * FROM tours WHERE place_id = ?').get(placeId) as Record<string, unknown>;
|
||||
const points = testDb
|
||||
.prepare('SELECT role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence')
|
||||
.all(placeId);
|
||||
|
||||
expect(place).toMatchObject({
|
||||
trip_id: Number(tripId),
|
||||
name: 'Ridge walk',
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
transport_mode: 'walking',
|
||||
});
|
||||
expect(JSON.parse(String(place.route_geometry))).toEqual(request.route_geometry);
|
||||
expect(tour).toMatchObject({ tour_type: 'hike', match_confidence: 1, max_hiking_difficulty: 2 });
|
||||
expect(tour.distance).toBeGreaterThan(0);
|
||||
expect(tour.elevation_gain).toBe(50);
|
||||
expect(tour.elevation_loss).toBe(20);
|
||||
expect(tour.duration).toBe(60);
|
||||
expect(points).toEqual([
|
||||
{ role: 'start', sequence: 0 },
|
||||
{ role: 'end', sequence: 1 },
|
||||
]);
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true, caution: false });
|
||||
expect(result.waypoints).toEqual(request.waypoints);
|
||||
expect(broadcast).toHaveBeenNthCalledWith(1, tripId, 'tours:changed', { placeIds: [placeId] }, 'socket-1');
|
||||
expect(broadcast).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
tripId,
|
||||
'place:created',
|
||||
{ place: expect.objectContaining({ id: placeId }) },
|
||||
'socket-1',
|
||||
);
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
// Both events go out after the commit, never from inside the write.
|
||||
expect(writeStates).toEqual([false, false]);
|
||||
});
|
||||
|
||||
it('TOURS-SVC-002: rolls the owning place and facet back when a waypoint insert fails', async () => {
|
||||
await expect(service.createTour(tripId, duplicateSequence)).rejects.toThrow();
|
||||
|
||||
expect(count('places')).toBe(0);
|
||||
expect(count('tours')).toBe(0);
|
||||
expect(count('tour_waypoints')).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-003: validates a fractional routed duration before the atomic create', async () => {
|
||||
const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 });
|
||||
|
||||
const result = await service.createTour(tripId, validated);
|
||||
|
||||
expect(count('places')).toBe(1);
|
||||
expect(count('tours')).toBe(1);
|
||||
expect(count('tour_waypoints')).toBe(2);
|
||||
expect(testDb.prepare('SELECT duration FROM tours').get()).toEqual({ duration: 60 });
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false });
|
||||
});
|
||||
|
||||
it('TOURS-SVC-003b: stores no duration when the client sends none', async () => {
|
||||
const { duration_seconds: _omitted, ...withoutDuration } = request;
|
||||
const result = await service.createTour(tripId, withoutDuration);
|
||||
expect(result.tour.duration).toBeNull();
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (event === 'tours:changed') throw new Error('transport unavailable');
|
||||
});
|
||||
|
||||
const result = await service.createTour(tripId, request);
|
||||
|
||||
expect(result.tour.name).toBe('Ridge walk');
|
||||
expect(count('places')).toBe(1);
|
||||
expect(count('tours')).toBe(1);
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps a committed create successful when the place notification fails', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementation((_tripId: string, event: string) => {
|
||||
if (event === 'place:created') throw new Error('transport unavailable');
|
||||
});
|
||||
|
||||
await expect(service.createTour(tripId, request)).resolves.toMatchObject({ tour: { name: 'Ridge walk' } });
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursService reads', () => {
|
||||
it('TOURS-SVC-004: loads only a trip-owned tour with its ordered control points', async () => {
|
||||
const created = await service.createTour(tripId, request);
|
||||
const placeId = String(created.tour.place_id);
|
||||
|
||||
expect(await service.getTour(tripId, placeId)).toEqual(created);
|
||||
await expect(service.getTour(otherTripId, placeId)).rejects.toThrow('Tour not found');
|
||||
|
||||
testDb.prepare('DELETE FROM tour_waypoints WHERE place_id = ?').run(created.tour.place_id);
|
||||
const legacyGpx = await service.getTour(tripId, placeId);
|
||||
expect(legacyGpx.tour.has_waypoints).toBe(false);
|
||||
expect(legacyGpx.waypoints).toEqual([
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
]);
|
||||
// The fallback is read-only: nothing was backfilled.
|
||||
expect(count('tour_waypoints')).toBe(0);
|
||||
});
|
||||
|
||||
it('TOURS-SVC-004b: a plain place, a malformed id and a geometry-less tour', async () => {
|
||||
const plain = createPlace(testDb, Number(tripId), { name: 'Not a tour' });
|
||||
await expect(service.getTour(tripId, String(plain.id))).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.getTour(tripId, 'abc')).rejects.toThrow('Tour not found');
|
||||
await expect(service.getTour('abc', String(plain.id))).rejects.toThrow('Tour not found');
|
||||
|
||||
const bare = createPlace(testDb, Number(tripId), { name: 'Bare import' });
|
||||
createTour(testDb, bare.id);
|
||||
const detail = await service.getTour(tripId, String(bare.id));
|
||||
expect(detail.tour).toMatchObject({ place_id: bare.id, has_waypoints: false });
|
||||
expect(detail.waypoints).toEqual([]);
|
||||
});
|
||||
|
||||
it("TOURS-SVC-007: lists only this trip's tours, newest first, with the caution flag", async () => {
|
||||
const older = createPlace(testDb, Number(tripId), { name: 'Older' });
|
||||
const newer = createPlace(testDb, Number(tripId), { name: 'Newer' });
|
||||
const foreign = createPlace(testDb, Number(otherTripId), { name: 'Foreign' });
|
||||
createPlace(testDb, Number(tripId), { name: 'Plain place' });
|
||||
createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', match_confidence: 0.3 });
|
||||
createTour(testDb, newer.id, { created_at: '2026-02-01 10:00:00', match_confidence: 1 });
|
||||
createTour(testDb, foreign.id);
|
||||
|
||||
const list = await service.listTours(tripId);
|
||||
|
||||
expect(list.map((t) => t.name)).toEqual(['Newer', 'Older']);
|
||||
expect(list[0]).toMatchObject({ caution: false, planned: false, has_waypoints: false, tour_type: 'hike' });
|
||||
expect(list[1]).toMatchObject({ caution: true, match_confidence: 0.3 });
|
||||
expect(await service.listTours('not-a-number')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursService updates', () => {
|
||||
const update: TourCreateRequest = {
|
||||
...request,
|
||||
name: 'Updated ridge walk',
|
||||
route_geometry: [
|
||||
[49, 12, 700],
|
||||
[49.02, 12.04, 760],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 49, lng: 12, role: 'start', sequence: 0 },
|
||||
{ lat: 49.01, lng: 12.02, role: 'via', sequence: 1 },
|
||||
{ lat: 49.02, lng: 12.04, role: 'end', sequence: 2 },
|
||||
],
|
||||
duration_seconds: 2700.5,
|
||||
};
|
||||
|
||||
it('TOURS-SVC-005: atomically updates route data and replaces persisted control points', async () => {
|
||||
const created = await service.createTour(tripId, request);
|
||||
broadcast.mockReset();
|
||||
const writeStates: boolean[] = [];
|
||||
broadcast.mockImplementation(() => {
|
||||
writeStates.push(testDb.inTransaction);
|
||||
});
|
||||
|
||||
const result = await service.updateTour(tripId, String(created.tour.place_id), update, 'socket-2');
|
||||
|
||||
expect(result.tour).toMatchObject({ place_id: created.tour.place_id, name: 'Updated ridge walk' });
|
||||
expect(result.waypoints).toEqual(update.waypoints);
|
||||
expect(testDb.prepare('SELECT name, lat, lng, transport_mode FROM places').get()).toEqual({
|
||||
name: 'Updated ridge walk',
|
||||
lat: 49,
|
||||
lng: 12,
|
||||
transport_mode: 'walking',
|
||||
});
|
||||
expect(testDb.prepare('SELECT duration, elevation_gain, elevation_loss FROM tours').get()).toEqual({
|
||||
duration: 45,
|
||||
elevation_gain: 60,
|
||||
elevation_loss: 0,
|
||||
});
|
||||
expect(broadcast).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
tripId,
|
||||
'tours:changed',
|
||||
{ placeIds: [created.tour.place_id] },
|
||||
'socket-2',
|
||||
);
|
||||
expect(broadcast).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
tripId,
|
||||
'place:updated',
|
||||
expect.objectContaining({ place: expect.objectContaining({ id: created.tour.place_id }) }),
|
||||
'socket-2',
|
||||
);
|
||||
expect(writeStates).toEqual([false, false]);
|
||||
});
|
||||
|
||||
it('TOURS-SVC-006: rolls an invalid waypoint replacement back to the previous saved tour', async () => {
|
||||
const created = await service.createTour(tripId, request);
|
||||
broadcast.mockReset();
|
||||
|
||||
await expect(
|
||||
service.updateTour(tripId, String(created.tour.place_id), { ...duplicateSequence, name: 'Must roll back' }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(testDb.prepare('SELECT name FROM places').get()).toEqual({ name: 'Ridge walk' });
|
||||
expect((await service.getTour(tripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-008: a tour of another trip is a 404 and stays untouched', async () => {
|
||||
const created = await service.createTour(otherTripId, request);
|
||||
broadcast.mockReset();
|
||||
|
||||
await expect(service.updateTour(tripId, String(created.tour.place_id), update, 'socket')).rejects.toThrow(
|
||||
'Tour not found',
|
||||
);
|
||||
|
||||
expect(testDb.prepare('SELECT trip_id, name FROM places WHERE id = ?').get(created.tour.place_id)).toEqual({
|
||||
trip_id: Number(otherTripId),
|
||||
name: 'Ridge walk',
|
||||
});
|
||||
expect((await service.getTour(otherTripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-009: a plain place or a malformed id is a 404 without a write', async () => {
|
||||
const plain = createPlace(testDb, Number(tripId), { name: 'Plain' });
|
||||
|
||||
await expect(service.updateTour(tripId, String(plain.id), update)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.updateTour(tripId, 'abc', update)).rejects.toThrow('Tour not found');
|
||||
await expect(service.updateTour('abc', String(plain.id), update)).rejects.toThrow('Tour not found');
|
||||
|
||||
expect(testDb.prepare('SELECT name FROM places WHERE id = ?').get(plain.id)).toEqual({ name: 'Plain' });
|
||||
expect(count('tours')).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
|
||||
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from '../../helpers/todo-repos';
|
||||
import { createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
|
||||
import {
|
||||
createTestVacayPlansRepo,
|
||||
createTestVacayPlanMembersRepo,
|
||||
@@ -2609,6 +2610,97 @@ describe('copy — whole-trip parity (Task 8)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy: Tours (#2586)', () => {
|
||||
/** A Tour on `tripId`: the place, its `tours` facet and three route waypoints. */
|
||||
function seedTour(tripId: number, name: string) {
|
||||
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);
|
||||
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);
|
||||
insert.run(place.id, 47.2, 11.2, 'end', 2);
|
||||
return place;
|
||||
}
|
||||
|
||||
const tourColumns = (placeId: number) => {
|
||||
const { place_id: _placeId, ...rest } = testDb
|
||||
.prepare('SELECT * FROM tours WHERE place_id = ?')
|
||||
.get(placeId) as Record<string, unknown>;
|
||||
return rest;
|
||||
};
|
||||
const waypoints = (placeId: number) =>
|
||||
testDb
|
||||
.prepare('SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence')
|
||||
.all(placeId);
|
||||
|
||||
it('TRIP-SVC-081: a copied Tour keeps its facet row and waypoints, remapped onto the copied place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Alps' });
|
||||
const tour = seedTour(trip.id, 'Ridge walk');
|
||||
createPlace(testDb, trip.id, { name: 'Plain stop' });
|
||||
|
||||
const newTripId = await svc.copy(trip.id, user.id, 'Alps again');
|
||||
|
||||
const newTour = testDb
|
||||
.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Ridge walk'")
|
||||
.get(newTripId) as { id: number };
|
||||
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(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.
|
||||
expect(testDb.prepare('SELECT 1 FROM tours WHERE place_id = ?').get(newPlain.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT COUNT(*) AS n FROM tours t JOIN places p ON p.id = t.place_id WHERE p.trip_id = ?')
|
||||
.get(trip.id),
|
||||
).toEqual({ n: 1 });
|
||||
expect(waypoints(tour.id)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-082: a failing waypoint insert rolls the copied Tour back with the rest of the copy', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Doomed alps' });
|
||||
seedTour(trip.id, 'Doomed ridge');
|
||||
const toursBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n;
|
||||
|
||||
const waypointsRepo = await createTestTourWaypointsRepo(testDb);
|
||||
const spy = vi.spyOn(waypointsRepo, 'insertForPlace').mockRejectedValueOnce(new Error('boom'));
|
||||
try {
|
||||
await expect(svc.copy(trip.id, user.id, 'Never lands')).rejects.toThrow('boom');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n).toBe(toursBefore);
|
||||
expect(testDb.prepare("SELECT id FROM trips WHERE title = 'Never lands'").get()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Post-fold quirk fixes (transactions, owner display name) ─────────────────
|
||||
|
||||
describe('quirk fixes', () => {
|
||||
|
||||
@@ -4,13 +4,52 @@ import { FeaturesController } from '../../../src/nest/health/features.controller
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/config', async () => {
|
||||
const { readEnv } = await import('../../../src/app-config');
|
||||
const env = readEnv();
|
||||
return {
|
||||
ENCRYPTION_KEY: 'wiring-test-inert-encryption-key',
|
||||
JWT_SECRET: 'wiring-test-inert-jwt-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
DEFAULT_LANGUAGE: env.app.defaultLanguage,
|
||||
SESSION_DURATION: env.session.duration,
|
||||
SESSION_DURATION_MS: env.session.durationMs,
|
||||
SESSION_DURATION_SECONDS: env.session.durationSeconds,
|
||||
SESSION_DURATION_REMEMBER: env.session.durationRemember,
|
||||
SESSION_DURATION_REMEMBER_MS: env.session.durationRememberMs,
|
||||
SESSION_DURATION_REMEMBER_SECONDS: env.session.durationRememberSeconds,
|
||||
};
|
||||
});
|
||||
|
||||
function ctx(user: unknown) {
|
||||
return { switchToHttp: () => ({ getRequest: () => ({ user }) }) } as never;
|
||||
}
|
||||
|
||||
describe('AppModule wiring', () => {
|
||||
it('registers the upstream feature modules and Tours exactly once', () => {
|
||||
// Only plain class imports: forRoot()/forFeature() entries are dynamic
|
||||
// (MikroOrmModule legitimately appears twice) and some resolve async.
|
||||
const imports = Reflect.getMetadata('imports', AppModule) as unknown[];
|
||||
const names = imports
|
||||
.filter((entry): entry is { name: string } => typeof entry === 'function')
|
||||
.map((entry) => entry.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'GoogleQuotaModule',
|
||||
'ReceiptScanModule',
|
||||
'SchoolHolidaysModule',
|
||||
'DocSyncModule',
|
||||
'DawarichModule',
|
||||
'NotificationsModule',
|
||||
'ToursModule',
|
||||
]),
|
||||
);
|
||||
expect(names.filter((name) => name === 'ToursModule')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('compiles with the global filter + DB provider and resolves the controller', async () => {
|
||||
// Plan 4 Task 4: `DatabaseService` is gone — nothing left in the graph
|
||||
// needs overriding for this to compile.
|
||||
|
||||
Reference in New Issue
Block a user