feat(tours): add non-motorized day tour planning

This commit is contained in:
elmocito
2026-10-04 12:59:55 +02:00
parent c93cf2bce9
commit 9e475f447a
202 changed files with 16129 additions and 383 deletions
+1
View File
@@ -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];
+178 -2
View File
@@ -86,6 +86,115 @@ function createPlaceRegionsFollowPlaceTrigger(db: Database.Database): void {
`);
}
function schemaObjectSql(db: Database.Database, name: string): string | undefined {
return (db.prepare('SELECT sql FROM sqlite_master WHERE name = ?').get(name) as { sql: string } | undefined)?.sql;
}
function hasSchemaColumn(db: Database.Database, table: string, column: string): boolean {
return !!db.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ?').get(table, column);
}
function normalizedSchemaSql(sql: string): string {
return sql.replace(/IF NOT EXISTS/gi, '').replace(/\s+/g, '').replace(/;$/, '');
}
function matchesSchemaObject(db: Database.Database, name: string, sql: string | undefined): boolean {
const actual = schemaObjectSql(db, name);
return sql === undefined ? actual === undefined : actual !== undefined
&& normalizedSchemaSql(actual) === normalizedSchemaSql(sql);
}
function matchesToursSchema(db: Database.Database, stage: number): boolean {
const expected: Record<string, string | undefined> = {
tour_types: `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
)`,
tours: stage >= 2 ? `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
${stage === 4 ? ', max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)' : ''}
)` : undefined,
tour_waypoints: stage >= 3 ? `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)
)` : undefined,
idx_tours_tour_type: stage >= 2 ? 'CREATE INDEX idx_tours_tour_type ON tours(tour_type)' : undefined,
idx_tour_waypoints_place: stage >= 3
? 'CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)' : undefined,
};
return Object.entries(expected).every(([name, sql]) => matchesSchemaObject(db, name, sql));
}
function toursLineageRewind(db: Database.Database, version: number): number | undefined {
const legacy = version >= 216 && version <= 218;
const frozen = version >= 243 && version <= 246;
if (!legacy && !frozen) return undefined;
const tourTables = ['tour_types', 'tours', 'tour_waypoints'].map(name => !!schemaObjectSql(db, name));
const upstreamMarkers = {
immich: hasSchemaColumn(db, 'users', 'immich_allow_insecure_tls'),
atlasTrigger: !!schemaObjectSql(db, 'trg_place_regions_follow_place'),
push: !!schemaObjectSql(db, 'push_subscriptions'),
routeExcluded: hasSchemaColumn(db, 'day_assignments', 'route_excluded'),
packedQuantity: hasSchemaColumn(db, 'packing_items', 'packed_quantity'),
bucketRegion: hasSchemaColumn(db, 'bucket_list', 'region_code'),
holidayFraction: hasSchemaColumn(db, 'vacay_company_holidays', 'fraction'),
entryDraft: hasSchemaColumn(db, 'journey_entries', 'is_draft'),
travelShare: hasSchemaColumn(db, 'share_tokens', 'share_travel_only'),
hiddenImages: hasSchemaColumn(db, 'share_tokens', 'share_hide_images'),
settlementNote: hasSchemaColumn(db, 'budget_settlements', 'note'),
googleUsage: !!schemaObjectSql(db, 'google_api_usage'),
templateWeight: hasSchemaColumn(db, 'packing_template_items', 'weight_grams'),
templateQuantity: hasSchemaColumn(db, 'packing_template_items', 'quantity'),
templateBag: hasSchemaColumn(db, 'packing_template_items', 'bag_name'),
journeyStatus: hasSchemaColumn(db, 'journeys', 'status_override'),
placeEmail: hasSchemaColumn(db, 'places', 'email'),
placeHours: hasSchemaColumn(db, 'places', 'opening_hours'),
photoLocation: hasSchemaColumn(db, 'journeys', 'photo_location'),
};
const legacyMarkers = {
countries: !!schemaObjectSql(db, 'school_holiday_countries'),
regions: !!schemaObjectSql(db, 'school_holiday_regions'),
periods: !!schemaObjectSql(db, 'school_holiday_periods'),
endDay: hasSchemaColumn(db, 'day_assignments', 'end_day'),
boundaries: !!schemaObjectSql(db, 'roadtrip_day_boundaries'),
};
const unsafe = (): never => {
throw new Error(`[migrations] Unsafe Tours migration lineage at schema version ${version}: `
+ JSON.stringify({ tourTables, upstreamMarkers, legacyMarkers })
+ '. Cursor and data were not changed; inspect this database before retrying.');
};
if (!tourTables.some(Boolean)) {
const laterUpstreamMarkers = Object.entries(upstreamMarkers)
.filter(([name]) => !['immich', 'atlasTrigger', 'push'].includes(name));
if (frozen && laterUpstreamMarkers.some(([, present]) => present)) unsafe();
if (frozen && (!upstreamMarkers.immich
|| (version === 243 && (upstreamMarkers.atlasTrigger || upstreamMarkers.push))
|| (version === 244 && !upstreamMarkers.atlasTrigger && !upstreamMarkers.push)
|| (version === 245 && !upstreamMarkers.push)
|| (version === 246 && (!upstreamMarkers.atlasTrigger || !upstreamMarkers.push)))) unsafe();
if (legacy && (Object.values(upstreamMarkers).some(Boolean)
|| !legacyMarkers.countries || !legacyMarkers.regions || !legacyMarkers.periods
|| (version === 216 && (legacyMarkers.endDay || legacyMarkers.boundaries))
|| (version === 217 && legacyMarkers.boundaries)
|| (version >= 217 && !legacyMarkers.endDay)
|| (version >= 218 && !legacyMarkers.boundaries))) unsafe();
return undefined;
}
if (!matchesToursSchema(db, version - (legacy ? 215 : 242))
|| Object.values(upstreamMarkers).some(Boolean)
|| (legacy && Object.values(legacyMarkers).some(Boolean))
|| (frozen && !Object.values(legacyMarkers).every(Boolean))) unsafe();
return legacy ? 215 : 242;
}
function runMigrations(db: Database.Database): void {
db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
const versionRow = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined;
@@ -5450,10 +5559,66 @@ function runMigrations(db: Database.Database): void {
const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as { name: string }[];
if (!cols.some(c => c.name === 'photo_location')) db.exec('ALTER TABLE journeys ADD COLUMN photo_location INTEGER NOT NULL DEFAULT 0');
},
() => {
db.exec(`
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
)
`);
},
() => {
db.exec(`
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
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_tours_tour_type ON tours(tour_type)');
},
() => {
db.exec(`
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)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)');
},
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('tours')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'max_hiking_difficulty')) {
db.exec('ALTER TABLE tours ADD COLUMN max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)');
}
},
];
if (currentVersion < migrations.length) {
for (let i = currentVersion; i < migrations.length; i++) {
const rewindVersion = toursLineageRewind(db, currentVersion);
if (rewindVersion !== undefined && migrations.slice(rewindVersion).some(step => typeof step !== 'function')) {
throw new Error('[migrations] Unsafe Tours migration lineage: bridge replay contains a raw migration');
}
const applyMigrations = (startVersion: number): void => {
for (let i = startVersion; i < migrations.length; i++) {
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
try {
const migration = migrations[i];
@@ -5474,9 +5639,20 @@ function runMigrations(db: Database.Database): void {
}
} catch (err) {
console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err);
if (rewindVersion !== undefined) throw err;
process.exit(1);
}
}
};
if (rewindVersion !== undefined) {
db.transaction(() => {
db.prepare('UPDATE schema_version SET version = ?').run(rewindVersion);
applyMigrations(rewindVersion);
})();
} else if (currentVersion < migrations.length) {
applyMigrations(currentVersion);
}
if (currentVersion < migrations.length) {
console.log(`[DB] Migrations complete — schema version ${migrations.length}`);
}
}
+15
View File
@@ -122,6 +122,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from a shared Naver Maps list', type: 'integration', icon: 'Link2', enabled: 1, sort_order: 13 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
{ id: 'roadtrip', name: 'Road trip', description: 'Drives with stops along the route, driving times, and arrival times that update themselves', type: 'trip', icon: 'Route', 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', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
{ id: 'dawarich', name: 'Dawarich', description: 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', type: 'integration', icon: 'Dawarich', enabled: 0, sort_order: 17 },
@@ -180,10 +181,24 @@ function seedAddons(db: Database.Database): void {
}
}
// MVP seeds/enables only `hike` — the other controlled tour_types (bike, run, ski,
// kayak, walk, food) ship in a later release and are intentionally NOT seeded yet.
function seedTourTypes(db: Database.Database): void {
try {
const insertType = db.prepare(
'INSERT OR IGNORE INTO tour_types (key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
);
insertType.run('hike', 'tourTypes.hike', 'Mountain', '#16a34a', 'pedestrian', 1, 1, 0);
} catch (err: unknown) {
console.error('Error seeding tour types:', err instanceof Error ? err.message : err);
}
}
function runSeeds(db: Database.Database): void {
seedAdminAccount(db);
seedCategories(db);
seedAddons(db);
seedTourTypes(db);
}
export { runSeeds, seedAdminAccount };
@@ -272,6 +272,10 @@ export function applyGlobalMiddleware(
// document and never reaches the Service Worker that would cache it
// (#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
+2 -1
View File
@@ -38,6 +38,7 @@ import { DayNotesModule } from './day-notes/day-notes.module';
import { AccommodationsModule } from './accommodations/accommodations.module';
import { AssignmentsModule } from './assignments/assignments.module';
import { PlacesModule } from './places/places.module';
import { ToursModule } from './tours/tours.module';
import { TripsModule } from './trips/trips.module';
import { TodoModule } from './todo/todo.module';
import { CollabModule } from './collab/collab.module';
@@ -84,7 +85,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, GoogleQuotaModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, ReceiptScanModule, LlmParseModule, ManagedExtModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, GoogleQuotaModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, ToursModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, ReceiptScanModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
@@ -100,9 +100,11 @@ export class AssignmentsService {
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 = ?
`, assignmentId);
@@ -136,9 +138,11 @@ export class AssignmentsService {
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.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
+2
View File
@@ -29,6 +29,8 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[]
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,
+6
View File
@@ -109,9 +109,11 @@ export class DaysService {
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.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
@@ -136,6 +138,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,
@@ -195,9 +199,11 @@ export class DaysService {
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.day_id IN (${dayPlaceholders})
ORDER BY da.order_index ASC, da.created_at ASC
+5 -5
View File
@@ -317,7 +317,7 @@ export class PlacesController {
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
@@ -328,7 +328,7 @@ export class PlacesController {
for (const id of scoped) this.places.onDeleted(id);
// Read the linked expenses before the delete — afterwards the link is gone (#1298).
const expenseIds = 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);
}
@@ -346,7 +346,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')
@@ -530,7 +530,7 @@ export class PlacesController {
}
this.places.onDeleted(Number(id));
const expenseIds = 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);
}
@@ -545,6 +545,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 };
}
}
+2 -2
View File
@@ -280,7 +280,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.',
description: '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(),
@@ -458,7 +458,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.',
description: '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),
+69 -36
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type Database from 'better-sqlite3';
import { resolveCountryCodeSync } from '../atlas/atlas-geo';
import { XMLValidator } from 'fast-xml-parser';
import { TRACK_COLORS, placeMatchStrategies, type PlaceMatchCandidate } from '@trek/shared';
@@ -74,6 +75,14 @@ type Trip = TripAccess;
type ImportedPlace = { id: number; route_geometry?: string | null; route_color?: string | null };
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; description?: string; lat?: number; lng?: number; address?: string;
@@ -185,9 +194,10 @@ export class PlacesService {
filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' },
) {
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 = ?
@@ -507,37 +517,42 @@ 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 place = this.dbs.get<{ google_place_id: string | null; image_url: string | null }>(
'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?', placeId, tripId,
);
const cancelled = noCancelledStays();
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.
this.dbs.transaction(() => {
wasTour = !!this.dbs.get('SELECT 1 FROM tours WHERE place_id = ?', placeId);
this.cancelStaysAt(tripId, placeId, cancelled);
this.dbs.run('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?', tripId, placeId);
this.dbs.run('DELETE FROM places WHERE id = ?', placeId);
});
await reclaimPhotoCache(this.photoCache, place.google_place_id, place.image_url);
await reclaimPlaceImage(this.storage, place.image_url);
return { deleted: true, cancelled };
return { deleted: true, deletedTourPlaceIds: wasTour ? [Number(placeId)] : [], 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 };
const selectStmt = this.dbs.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?');
const selectTourStmt = this.dbs.prepare('SELECT 1 FROM tours WHERE place_id = ?');
const deleteStmt = this.dbs.prepare('DELETE FROM places WHERE id = ?');
const deleteExpenseStmt = this.dbs.prepare('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?');
const deleted: number[] = [];
const deletedTourPlaceIds: number[] = [];
const reclaimable: { google_place_id: string | null; image_url: string | null }[] = [];
this.dbs.transaction(() => {
for (const id of ids) {
const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
if (!row) continue;
if (selectTourStmt.get(id)) deletedTourPlaceIds.push(id);
this.cancelStaysAt(tripId, id, cancelled);
deleteExpenseStmt.run(tripId, id);
deleteStmt.run(id);
@@ -550,7 +565,7 @@ export class PlacesService {
await reclaimPhotoCache(this.photoCache, row.google_place_id, row.image_url);
await reclaimPlaceImage(this.storage, row.image_url);
}
return { deleted, cancelled };
return { deleted, deletedTourPlaceIds, cancelled };
}
/**
@@ -759,11 +774,17 @@ export class PlacesService {
}
private importGpxRows(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): GpxImportResult | null {
const rows = this.prepareGpxRows(fileBuffer, opts);
if (!rows.length) return null;
return this.dbs.transaction(() => this.persistGpxRows(tripId, rows));
}
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) => { const n = Number.parseFloat(String(v)); return Number.isNaN(n) ? null : n; };
@@ -782,8 +803,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) {
@@ -828,8 +848,18 @@ export class PlacesService {
}
}
if (waypoints.length === 0) return null;
return waypoints;
}
importPreparedGpx(tripId: string, rows: PreparedGpxPlace[]): GpxImportResult {
if (!this.dbs.connection.inTransaction) throw new Error('Prepared GPX import requires an active transaction');
const result = this.persistGpxRows(tripId, rows);
const tracks = result.places.filter(place => place.route_geometry && !place.route_color);
this.applyImportedTrackColors(this.dbs.connection, tripId, tracks);
return result;
}
private persistGpxRows(tripId: string, waypoints: PreparedGpxPlace[]): GpxImportResult {
const dedup = this.buildDedupSet(tripId);
const insertStmt = this.dbs.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
@@ -837,18 +867,16 @@ export class PlacesService {
`);
const created: PlaceWithTags[] = [];
let skipped = 0;
this.dbs.transaction(() => {
for (const wp of waypoints) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
skipped++;
continue;
}
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!;
created.push(place);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
for (const wp of waypoints) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
skipped++;
continue;
}
});
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!;
created.push(place);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
}
return { places: created, count: created.length, skipped };
}
@@ -1003,20 +1031,25 @@ export class PlacesService {
// Read and write in one transaction so two concurrent imports cannot both
// read the same set of free colours.
this.dbs.transaction((conn) => {
const taken = new Set(
(conn
.prepare('SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL')
.all(tripId) as { c: string }[]).map((r) => r.c),
);
const free = TRACK_COLORS.filter((c) => !taken.has(c));
const stmt = conn.prepare('UPDATE places SET route_color = ? WHERE id = ?');
tracks.forEach((track, i) => {
// Free ones first, then wrap through the whole palette — never reuse a
// free colour twice within the same import.
const color = i < free.length ? free[i] : TRACK_COLORS[(i - free.length) % TRACK_COLORS.length];
stmt.run(color, track.id);
track.route_color = color;
});
this.applyImportedTrackColors(conn, tripId, tracks);
});
}
private applyImportedTrackColors(conn: Database.Database, tripId: string, tracks: ImportedPlace[]): void {
if (tracks.length === 0) return;
const taken = new Set(
(conn
.prepare('SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL')
.all(tripId) as { c: string }[]).map((r) => r.c),
);
const free = TRACK_COLORS.filter((c) => !taken.has(c));
const stmt = conn.prepare('UPDATE places SET route_color = ? WHERE id = ?');
tracks.forEach((track, i) => {
// Free ones first, then wrap through the whole palette — never reuse a
// free colour twice within the same import.
const color = i < free.length ? free[i] : TRACK_COLORS[(i - free.length) % TRACK_COLORS.length];
stmt.run(color, track.id);
track.route_color = color;
});
}
@@ -0,0 +1,4 @@
import { createZodDto } from 'nestjs-zod';
import { tourCreateRequestSchema } from '@trek/shared';
export class TourCreateDto extends createZodDto(tourCreateRequestSchema) {}
+76
View File
@@ -0,0 +1,76 @@
import {
Body, Controller, Get, Headers, HttpException, Param, Post, Put, UploadedFile, UseGuards, UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { ToursService } from './tours.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { ADDON_IDS } from '../../addons';
import { TourCreateDto } from './dto/tour-create.dto';
const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } };
/**
* /api/trips/:tripId/tours: the Tours facet, read and tours-mode GPX import.
* TripAccessGuard resolves
* :tripId (404 for no access); writes require 'place_edit'. Assignments stay
* on the existing day_edit endpoints.
*/
@Controller('api/trips/:tripId/tours')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class ToursController {
constructor(private readonly tours: ToursService) {}
@Get()
list(@Param('tripId') tripId: string) {
return { tours: this.tours.listTours(tripId) };
}
@Get(':placeId')
detail(@Param('tripId') tripId: string, @Param('placeId') placeId: string) {
return this.tours.getTour(tripId, placeId);
}
@RequirePermission('place_edit')
@Post()
create(
@Param('tripId') tripId: string,
@Body() body: TourCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
return this.tours.createTour(tripId, body, socketId);
}
@RequirePermission('place_edit')
@Put(':placeId')
update(
@Param('tripId') tripId: string,
@Param('placeId') placeId: string,
@Body() body: TourCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
return this.tours.updateTour(tripId, placeId, body, socketId);
}
@RequirePermission('place_edit')
@Post('import/gpx')
@UseInterceptors(FileInterceptor('file', UPLOAD))
importGpx(
@Param('tripId') tripId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Headers('x-socket-id') socketId?: string,
) {
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
const result = 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;
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 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;
+18
View File
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { ToursController } from './tours.controller';
import { ToursService } from './tours.service';
import { PlacesModule } from '../places/places.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
/**
* Tours domain: an isolated bounded context that imports PlacesModule to reuse
* GPX preparation and persistence through PlacesService.
*/
@Module({
imports: [PlacesModule, PermissionsModule, AuthModule, AddonsModule],
controllers: [ToursController],
providers: [ToursService],
})
export class ToursModule {}
+280
View File
@@ -0,0 +1,280 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { TourCreateRequest, TourCreateResponse, TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { PlacesService } from '../places/places.service';
import { computeTourMetrics, parseRouteGeometry, LOW_CONFIDENCE_THRESHOLD, type GeometryPoint } from './tours.helpers';
export interface ImportGpxAsTourResult {
tours: TourListItem[];
caution: boolean;
skipped: number;
}
type TourJoinRow = {
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 | null; planned: number; has_waypoints: 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 dbs: DatabaseService,
private readonly places: PlacesService,
) {}
private toItem(r: TourJoinRow): 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),
};
}
/** All tours (the facet + owning place) for a trip, newest first. */
listTours(tripId: string): TourListItem[] {
const rows = this.dbs.all<TourJoinRow>(`
SELECT p.id AS place_id, p.name, t.tour_type, t.distance, t.elevation_gain,
t.elevation_loss, t.duration, t.difficulty, t.wanderer_ref, t.match_confidence,
t.tour_group_id, t.max_hiking_difficulty,
EXISTS(SELECT 1 FROM day_assignments da WHERE da.place_id = p.id) AS planned,
EXISTS(SELECT 1 FROM tour_waypoints tw WHERE tw.place_id = p.id) 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
`, tripId);
return rows.map(r => this.toItem(r));
}
/** One saved tour plus the persisted routing controls needed by the editor. */
getTour(tripId: string, placeId: string): TourDetailResponse {
const row = this.dbs.get<TourJoinRow>(`
SELECT p.id AS place_id, p.name, t.tour_type, t.distance, t.elevation_gain,
t.elevation_loss, t.duration, t.difficulty, t.wanderer_ref, t.match_confidence,
t.tour_group_id, t.max_hiking_difficulty,
EXISTS(SELECT 1 FROM day_assignments da WHERE da.place_id = p.id) AS planned,
EXISTS(SELECT 1 FROM tour_waypoints tw WHERE tw.place_id = p.id) AS has_waypoints
FROM tours t
JOIN places p ON p.id = t.place_id
WHERE p.trip_id = ? AND p.id = ?
`, tripId, placeId);
if (!row) throw new NotFoundException('Tour not found');
let waypoints = this.dbs.all<TourWaypoint>(`
SELECT lat, lng, role, sequence
FROM tour_waypoints
WHERE place_id = ?
ORDER BY sequence
`, placeId);
// 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 = this.dbs.get<{ route_geometry: string | null }>(
'SELECT route_geometry FROM places WHERE id = ? AND trip_id = ?', placeId, tripId,
);
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. */
createTour(tripId: string, input: TourCreateRequest, socketId?: string): TourCreateResponse {
const geometry = input.route_geometry as GeometryPoint[];
const metrics = computeTourMetrics(geometry);
const start = input.route_geometry[0];
let placeId = 0;
this.dbs.transaction(() => {
const place = this.dbs.run(`
INSERT INTO places (trip_id, name, lat, lng, transport_mode, route_geometry)
VALUES (?, ?, ?, ?, 'walking', ?)
`, tripId, input.name, start[0], start[1], JSON.stringify(input.route_geometry));
placeId = Number(place.lastInsertRowid);
this.dbs.run(`
INSERT INTO tours (
place_id, tour_type, distance, elevation_gain, elevation_loss, duration, match_confidence,
max_hiking_difficulty
) VALUES (?, ?, ?, ?, ?, ?, 1, ?)
`, placeId, input.tour_type, metrics.distanceKm, metrics.elevationGainM,
metrics.elevationLossM, input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60),
input.max_hiking_difficulty);
const insertWaypoint = this.dbs.prepare(`
INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence)
VALUES (?, ?, ?, ?, ?)
`);
for (const waypoint of input.waypoints) {
insertWaypoint.run(placeId, waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence);
}
});
const tour = this.listTours(tripId).find(item => item.place_id === placeId);
const place = this.dbs.getPlaceWithTags(placeId);
if (!tour || !place) throw new Error('Created tour could not be loaded');
const waypoints = this.dbs.all<TourWaypoint>(`
SELECT lat, lng, role, sequence
FROM tour_waypoints
WHERE place_id = ?
ORDER BY sequence
`, 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, waypoints };
}
/** Replace a saved tour's derived route and routing controls as one write. */
updateTour(tripId: string, placeId: string, input: TourCreateRequest, socketId?: string): TourDetailResponse {
// The trip predicate is the cross-trip boundary. Do this before any write so
// an id from another accessible trip cannot be moved into the current one.
this.getTour(tripId, placeId);
const geometry = input.route_geometry as GeometryPoint[];
const metrics = computeTourMetrics(geometry);
const start = input.route_geometry[0];
this.dbs.transaction(() => {
this.dbs.run(`
UPDATE places
SET name = ?, lat = ?, lng = ?, transport_mode = 'walking', route_geometry = ?
WHERE id = ? AND trip_id = ?
`, input.name, start[0], start[1], JSON.stringify(input.route_geometry), placeId, tripId);
this.dbs.run(`
UPDATE tours
SET tour_type = ?, distance = ?, elevation_gain = ?, elevation_loss = ?,
duration = ?, match_confidence = 1, max_hiking_difficulty = ?
WHERE place_id = ?
`, input.tour_type, metrics.distanceKm, metrics.elevationGainM, metrics.elevationLossM,
input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60), input.max_hiking_difficulty, placeId);
this.dbs.run('DELETE FROM tour_waypoints WHERE place_id = ?', placeId);
const insertWaypoint = this.dbs.prepare(`
INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence)
VALUES (?, ?, ?, ?, ?)
`);
for (const waypoint of input.waypoints) {
insertWaypoint.run(placeId, waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence);
}
});
const result = this.getTour(tripId, placeId);
const place = this.dbs.getPlaceWithTags(Number(placeId));
if (!place) throw new Error('Updated tour could not be loaded');
this.broadcastToursChanged(tripId, [Number(placeId)], 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.
*/
importGpxAsTour(tripId: string, fileBuffer: Buffer, defaultName?: string, socketId?: string): ImportGpxAsTourResult | null {
const rows = this.places.prepareGpxRows(fileBuffer, {
importWaypoints: false, importRoutes: true, importTracks: true, defaultName,
});
if (rows.length === 0) return null;
const insertTour = this.dbs.prepare(`
INSERT INTO tours (place_id, tour_type, distance, elevation_gain, elevation_loss, duration, match_confidence, max_hiking_difficulty)
VALUES (?, 'hike', ?, ?, ?, NULL, ?, 2)
`);
const tours: TourListItem[] = [];
const result = this.dbs.transaction(() => {
const result = this.places.importPreparedGpx(tripId, rows);
for (const place of result.places) {
const points = parseRouteGeometry(place.route_geometry);
const metrics = computeTourMetrics(points);
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
insertTour.run(place.id, metrics.distanceKm, metrics.elevationGainM, metrics.elevationLossM, matchConfidence);
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 result;
});
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 };
}
}
+2
View File
@@ -364,6 +364,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;
+60 -1
View File
@@ -13,6 +13,23 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
vi.mock('../../src/config', async () => {
const { readEnv } = await import('../../src/app-config');
const env = readEnv();
return {
ENCRYPTION_KEY: 'assignments-e2e-inert-encryption-key',
JWT_SECRET: 'assignments-e2e-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,
};
});
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
@@ -30,7 +47,8 @@ const { db } = vi.hoisted(() => {
description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT,
place_time TEXT, end_time TEXT, duration_minutes INTEGER DEFAULT 60, notes TEXT, image_url TEXT,
transport_mode TEXT DEFAULT 'walking', google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT,
stop_type TEXT, fill_percent INTEGER);`);
stop_type TEXT, fill_percent INTEGER, route_geometry TEXT);`);
tmp.exec('CREATE TABLE tours (place_id INTEGER PRIMARY KEY);');
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0, notes TEXT,
assignment_time TEXT, assignment_end_time TEXT, leg_transport_mode TEXT,
@@ -132,6 +150,47 @@ 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) VALUES (4)').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).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 2 });
+60 -1
View File
@@ -15,6 +15,23 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
vi.mock('../../src/config', async () => {
const { readEnv } = await import('../../src/app-config');
const env = readEnv();
return {
ENCRYPTION_KEY: 'days-e2e-inert-encryption-key',
JWT_SECRET: 'days-e2e-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,
};
});
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
@@ -34,7 +51,9 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT,
place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, image_url TEXT, transport_mode TEXT,
google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT, stop_type TEXT, fill_percent INTEGER);`);
google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT, stop_type TEXT,
fill_percent INTEGER, route_geometry TEXT);`);
tmp.exec('CREATE TABLE tours (place_id INTEGER PRIMARY KEY);');
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, notes TEXT,
assignment_time TEXT, assignment_end_time TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
@@ -120,6 +139,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);
});
@@ -138,6 +158,45 @@ 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) VALUES (4)').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').set('Cookie', sessionCookie(1)).send({ date: '2026-07-01' });
expect(ok.status).toBe(201);
+17 -3
View File
@@ -30,6 +30,8 @@ const { db } = vi.hoisted(() => {
google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT, transport_mode TEXT,
route_geometry TEXT, route_color TEXT, stop_type TEXT, fill_percent INTEGER, email TEXT, opening_hours TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
// PlacesService projects only tours.place_id and checks for a facet before deletion.
tmp.exec('CREATE TABLE tours (place_id INTEGER PRIMARY KEY);');
tmp.exec(`CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);`);
tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
@@ -123,7 +125,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
});
beforeEach(() => {
db.exec('DELETE FROM trips; DELETE FROM places; DELETE FROM place_tags; DELETE FROM place_ratings; DELETE FROM day_assignments; DELETE FROM days;');
db.exec('DELETE FROM trips; DELETE FROM tours; DELETE FROM places; DELETE FROM place_tags; DELETE FROM place_ratings; DELETE FROM day_assignments; DELETE FROM days;');
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
});
@@ -144,6 +146,18 @@ 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) VALUES (2)').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();
@@ -171,7 +185,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
db.prepare("INSERT INTO places (id, trip_id, name) VALUES (1, 5, 'A'), (2, 5, 'B'), (3, 6, 'Foreign')").run();
const ok = await request(server).post('/api/trips/5/places/bulk-delete').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 }]);
@@ -297,7 +311,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));
+2
View File
@@ -81,6 +81,8 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
image_url TEXT, address TEXT, lat REAL, lng REAL, category_id INTEGER, description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
// The trip bundle's PlacesService projection joins the Tours facet by place_id.
tmp.exec('CREATE TABLE tours (place_id INTEGER PRIMARY KEY);');
// PlacesService.list joins categories and batch-loads tags/ratings.
tmp.exec('CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);');
tmp.exec('CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at DATETIME);');
@@ -0,0 +1,68 @@
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared';
import Database from 'better-sqlite3';
import ts from 'typescript';
import { readEnv } from '../../src/app-config';
import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
import { createTables } from '../../src/db/schema';
import { seedDocumentProviders } from '../../src/db/document-provider-seed';
import { reseatBookedNights } from '../../src/db/reseat-booked-nights';
const migrationPath = resolve(__dirname, '../../src/db/migrations.ts');
const migrationSource = readFileSync(migrationPath, 'utf8');
const localRequire = createRequire(migrationPath);
function migrationArray(source: string): { file: ts.SourceFile; array: ts.ArrayLiteralExpression } {
const file = ts.createSourceFile('migrations.ts', source, ts.ScriptTarget.Latest, true);
let array: ts.ArrayLiteralExpression | undefined;
function visit(node: ts.Node): void {
if (ts.isVariableDeclaration(node) && node.name.getText(file) === 'migrations'
&& node.initializer && ts.isArrayLiteralExpression(node.initializer)) {
array = node.initializer;
}
ts.forEachChild(node, visit);
}
visit(file);
if (!array) throw new Error('Migration array not found');
return { file, array };
}
export function createMigrationPrefixDatabase(version: number): Database.Database {
const { file, array } = migrationArray(migrationSource);
if (!Number.isInteger(version) || version < 0 || version > array.elements.length) {
throw new RangeError(`Unsupported migration prefix version: ${version}`);
}
const truncated = migrationSource.slice(0, array.getStart(file))
+ `[${array.elements.slice(0, version).map(element => element.getFullText(file)).join(',')}]`
+ migrationSource.slice(array.end);
const compiled = ts.transpileModule(truncated, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
}).outputText;
const dependencies: Record<string, unknown> = {
'@trek/shared': { ROADTRIP_PREFERENCE_KEYS },
'../app-config': { readEnv },
'../nest/common/crypto/apiKeyCrypto': { encrypt_api_key },
'./document-provider-seed': { seedDocumentProviders },
'./reseat-booked-nights': { reseatBookedNights },
};
const loaded = { exports: {} as { runMigrations?: (db: Database.Database) => void } };
new Function('require', 'module', 'exports', compiled)(
(specifier: string) => dependencies[specifier] ?? localRequire(specifier), loaded, loaded.exports,
);
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
try {
createTables(db);
if (!loaded.exports.runMigrations) throw new Error('runMigrations export not found');
loaded.exports.runMigrations(db);
const row = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined;
if (row?.version !== version) throw new Error(`Expected schema version ${version}, got ${row?.version ?? 'none'}`);
return db;
} catch (error) {
db.close();
throw error;
}
}
+18 -7
View File
@@ -15,10 +15,21 @@
* ALLOWED_DESTRUCTIVE below with the reason. Anything not on that list is
* treated as a regression.
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { createTestDb } from '../../helpers/test-db';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: 'migration-hygiene-test-key',
JWT_SECRET: 'migration-hygiene-test-secret',
updateJwtSecret: vi.fn(),
}));
vi.mock('../../../src/db/database', () => {
throw new Error('Migration hygiene tests must not import the global database');
});
const MIGRATIONS_PATH = resolve(__dirname, '../../../src/db/migrations.ts');
const migrationsSource = readFileSync(MIGRATIONS_PATH, 'utf8');
@@ -186,13 +197,13 @@ describe('migration hygiene — no silently swallowed errors', () => {
describe('migration hygiene — full chain smoke', () => {
it('migrates a fresh in-memory database from zero to the latest version', () => {
// createTestDb() runs createTables() + the entire runMigrations() chain.
// This proves the logging edits in the previously-empty catch blocks do
// not change control flow / break the migration runner.
const db = createTestDb();
const db = new Database(':memory:');
try {
db.pragma('foreign_keys = ON');
createTables(db);
runMigrations(db);
const row = db.prepare('SELECT version FROM schema_version').get() as { version: number };
expect(row.version).toBeGreaterThan(0);
expect(row.version).toBe(262);
} finally {
db.close();
}
@@ -7,6 +7,16 @@
*/
import { describe, it, expect, vi } from 'vitest';
import Database from 'better-sqlite3';
vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: 'migration-atomicity-test-key',
JWT_SECRET: 'migration-atomicity-test-secret',
updateJwtSecret: vi.fn(),
}));
vi.mock('../../../src/db/database', () => {
throw new Error('Migration version atomicity tests must not import the global database');
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
@@ -1,5 +1,5 @@
import { runMigrations } from '../../../src/db/migrations';
import { createTestDb } from '../../helpers/test-db';
import { createMigrationPrefixDatabase } from '../../helpers/migration-prefix-db';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -26,8 +26,7 @@ describe('place_regions follows the place it was resolved from (#2527)', () => {
| { country_code: string; region_code: string }
| undefined;
beforeEach(() => {
db = createTestDb();
function seedPlaces(): void {
db.exec("INSERT INTO users (username, email, password_hash) VALUES ('traveller', 'traveller@example.test', 'x')");
const userId = (db.prepare('SELECT id FROM users').get() as { id: number }).id;
const tripId = db.prepare("INSERT INTO trips (user_id, title) VALUES (?, 'Trip')").run(userId)
@@ -42,6 +41,17 @@ describe('place_regions follows the place it was resolved from (#2527)', () => {
);
insertRegion.run(placeId);
insertRegion.run(otherPlaceId);
}
function replaceWithPrefix(version: number): void {
db.close();
db = createMigrationPrefixDatabase(version);
seedPlaces();
}
beforeEach(() => {
db = createMigrationPrefixDatabase(PLACE_REGIONS_TRIGGER_AGAIN_VERSION);
seedPlaces();
});
afterEach(() => db?.close());
@@ -108,12 +118,6 @@ describe('place_regions follows the place it was resolved from (#2527)', () => {
it('ships at its own schema version and replays without a second trigger', () => {
expect(triggers()).toHaveLength(1);
db.exec('DROP TRIGGER trg_place_regions_follow_place');
db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_AGAIN_VERSION);
runMigrations(db);
expect(triggers()).toHaveLength(0);
db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION - 1);
runMigrations(db);
runMigrations(db);
expect(triggers()).toHaveLength(1);
@@ -123,6 +127,7 @@ describe('place_regions follows the place it was resolved from (#2527)', () => {
});
it('keeps the slot it has on main, with Web Push after it and the trigger once more after that', () => {
replaceWithPrefix(PLACE_REGIONS_TRIGGER_VERSION - 1);
const sqlByVersion = new Map<number, string>();
let running = 0;
const log = vi.spyOn(console, 'log').mockImplementation((message?: unknown) => {
@@ -135,7 +140,6 @@ describe('place_regions follows the place it was resolved from (#2527)', () => {
return realExec(sql);
});
try {
db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION - 1);
runMigrations(db);
} finally {
exec.mockRestore();
@@ -154,8 +158,7 @@ describe('place_regions follows the place it was resolved from (#2527)', () => {
});
it('gives a 4.3.3 install, already at 244 with the trigger, the Web Push table', () => {
db.exec('DROP TABLE push_subscriptions');
db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION);
replaceWithPrefix(PLACE_REGIONS_TRIGGER_VERSION);
expect(pushTable()).toEqual([]);
runMigrations(db);
@@ -166,6 +169,7 @@ describe('place_regions follows the place it was resolved from (#2527)', () => {
});
it('gives an instance that ran Web Push at 244 the trigger and keeps its subscriptions', () => {
replaceWithPrefix(WEB_PUSH_VERSION);
db.exec('DROP TRIGGER trg_place_regions_follow_place');
db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION);
db.prepare(
@@ -1,5 +1,5 @@
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createMigrationPrefixDatabase } from '../../helpers/migration-prefix-db';
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
@@ -13,10 +13,8 @@ const PREFERENCES_VERSION = 219;
describe('trip driving preferences migration', () => {
it('inherits owner values once, tolerates nulls and preserves trip overrides on replay', () => {
const db = new Database(':memory:');
const db = createMigrationPrefixDatabase(PREFERENCES_VERSION - 1);
try {
createTables(db);
runMigrations(db);
db.exec(
"INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x'), (2, 'member', 'member@test', 'x')",
);
@@ -24,16 +22,34 @@ describe('trip driving preferences migration', () => {
db.exec(
"INSERT INTO settings (user_id, key, value) VALUES (1, 'roadtrip_range_km', '120'), (2, 'roadtrip_range_km', '300'), (1, 'roadtrip_day_start', '\"08:00\"'), (1, 'roadtrip_day_end', NULL), (1, 'routing_base_url', '\"https://private.test\"')",
);
db.prepare('UPDATE schema_version SET version = ?').run(PREFERENCES_VERSION - 1);
runMigrations(db);
expect(db.prepare('SELECT key, value FROM roadtrip_preferences WHERE trip_id = 10 ORDER BY key').all()).toEqual([
{ key: 'roadtrip_day_start', value: '"08:00"' },
{ key: 'roadtrip_range_km', value: '120' },
]);
expect(db.prepare('SELECT value FROM roadtrip_preferences WHERE trip_id = 11').get()).toEqual({ value: '300' });
db.exec("UPDATE roadtrip_preferences SET value = '200' WHERE trip_id = 10 AND key = 'roadtrip_range_km'");
} finally {
db.close();
}
});
it('preserves trip overrides when migration 219 is replayed after a partial cursor update', () => {
const db = createMigrationPrefixDatabase(PREFERENCES_VERSION);
try {
db.exec(
"INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x'), (2, 'member', 'member@test', 'x')",
);
db.exec("INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'First'), (11, 2, 'Second')");
db.exec(
"INSERT INTO settings (user_id, key, value) VALUES (1, 'roadtrip_range_km', '120'), (2, 'roadtrip_range_km', '300'), (1, 'roadtrip_day_start', '\"08:00\"')",
);
db.exec(
"INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES (10, 'roadtrip_day_start', '\"08:00\"'), (10, 'roadtrip_range_km', '200'), (11, 'roadtrip_range_km', '300')",
);
db.prepare('UPDATE schema_version SET version = ?').run(PREFERENCES_VERSION - 1);
runMigrations(db);
expect(
db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 10 AND key = 'roadtrip_range_km'").get(),
).toEqual({ value: '200' });
@@ -0,0 +1,387 @@
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { createMigrationPrefixDatabase } from '../../helpers/migration-prefix-db';
vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: 'r3-test-only-key',
JWT_SECRET: 'r3-test-only-secret',
updateJwtSecret: vi.fn(),
}));
vi.mock('../../../src/db/database', () => {
throw new Error('Tours migration lineage tests must not import the global database');
});
const repositoryRoot = resolve(__dirname, '../../../..');
const databases: Database.Database[] = [];
const disposableDirectories: string[] = [];
function migrationPrefixDatabase(version: number): Database.Database {
const db = createMigrationPrefixDatabase(version);
databases.push(db);
expect(cursor(db)).toBe(version);
return db;
}
function addToursSchemaStage(db: Database.Database, stage: number): void {
db.exec(`
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
)
`);
if (stage >= 2) {
db.exec(`
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
)
`);
db.exec('CREATE INDEX idx_tours_tour_type ON tours(tour_type)');
}
if (stage >= 3) {
db.exec(`
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)
)
`);
db.exec('CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)');
}
if (stage >= 4) {
db.exec('ALTER TABLE tours ADD COLUMN max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)');
}
}
function createUpstream242Fixture(): Database.Database {
return migrationPrefixDatabase(242);
}
function createUpstream258Fixture(): Database.Database {
return migrationPrefixDatabase(258);
}
function createLegacyToursFixture(version: number): Database.Database {
if (version < 216 || version > 218) throw new Error(`Unsupported legacy Tours fixture version: ${version}`);
const db = migrationPrefixDatabase(215);
addToursSchemaStage(db, version - 215);
db.prepare('UPDATE schema_version SET version = ?').run(version);
return db;
}
function createLegacyTours218Fixture(): Database.Database {
return createLegacyToursFixture(218);
}
function createFrozenToursFixture(version: number): Database.Database {
if (version < 243 || version > 246) throw new Error(`Unsupported frozen Tours fixture version: ${version}`);
const db = createUpstream242Fixture();
addToursSchemaStage(db, version - 242);
db.prepare('UPDATE schema_version SET version = ?').run(version);
return db;
}
function createFrozenTours246Fixture(): Database.Database {
return createFrozenToursFixture(246);
}
function createAmbiguousToursFixture(mutation: string, lineage: 'legacy' | 'frozen' = 'frozen'): Database.Database {
const db = lineage === 'legacy' ? createLegacyTours218Fixture() : createFrozenTours246Fixture();
seedTour(db);
db.exec(mutation);
return db;
}
function emptyDb(): Database.Database {
const db = new Database(':memory:');
databases.push(db);
db.pragma('foreign_keys = ON');
createTables(db);
return db;
}
function cursor(db: Database.Database): number {
return (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version;
}
function snapshot(db: Database.Database): Record<string, unknown[]> {
const tables = new Set((db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[])
.map(row => row.name));
return Object.fromEntries(['places', 'tours', 'tour_waypoints', 'day_assignments'].filter(table => tables.has(table)).map(table => [
table, db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(),
]));
}
function schemaSnapshot(db: Database.Database): unknown[] {
return db.prepare(`
SELECT type, name, tbl_name, sql FROM sqlite_master
WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name
`).all();
}
function expectRowsPreserved(actual: unknown[], before: unknown[]): void {
expect(actual).toHaveLength(before.length);
for (const [index, previous] of before.entries()) {
const values = previous as Record<string, unknown>;
const row = actual[index] as Record<string, unknown>;
expect(Object.fromEntries(Object.keys(values).map(key => [key, row[key]]))).toEqual(values);
}
}
function seedTour(db: Database.Database): void {
db.exec(`
INSERT INTO users (id, username, email, password_hash) VALUES (71, 'lineage-test', 'lineage@example.invalid', 'not-a-password');
INSERT INTO trips (id, user_id, title) VALUES (81, 71, 'Synthetic Tours');
INSERT INTO days (id, trip_id, day_number) VALUES (91, 81, 1);
INSERT INTO places (id, trip_id, name, route_geometry) VALUES
(101, 81, 'Historical tour', '[[48,11,600],[48.01,11.02,650],[48.02,11.04,630]]');
INSERT INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'tours.types.hike', 'Mountain', '#008800');
INSERT INTO tours (place_id, tour_type, difficulty) VALUES (101, 'hike', 'T3');
INSERT INTO tour_waypoints (id, place_id, lat, lng, role, sequence) VALUES
(201, 101, 48, 11, 'start', 0), (203, 101, 48.01, 11.02, 'via', 1),
(207, 101, 48.02, 11.04, 'end', 2);
INSERT INTO day_assignments (id, day_id, place_id, order_index) VALUES (301, 91, 101, 0);
`);
}
function expectFinalSchema(db: Database.Database): void {
expect(cursor(db)).toBe(262);
expect(db.pragma('foreign_key_check')).toEqual([]);
const markers: Record<string, string[]> = {
users: ['immich_allow_insecure_tls'], day_assignments: ['route_excluded'],
packing_items: ['packed_quantity'], bucket_list: ['region_code'], vacay_company_holidays: ['fraction'],
journey_entries: ['is_draft'], share_tokens: ['share_travel_only', 'share_hide_images'],
budget_settlements: ['note'], packing_template_items: ['weight_grams', 'quantity', 'bag_name'],
journeys: ['status_override', 'photo_location'], places: ['email', 'opening_hours'],
tours: ['max_hiking_difficulty'],
};
for (const [table, columns] of Object.entries(markers)) {
const actual = (db.pragma(`table_info(${table})`) as { name: string }[]).map(column => column.name);
expect(actual).toEqual(expect.arrayContaining(columns));
}
for (const name of ['tour_types', 'tours', 'tour_waypoints', 'push_subscriptions', 'google_api_usage',
'trg_place_regions_follow_place', 'idx_push_subscriptions_user', 'idx_tours_tour_type', 'idx_tour_waypoints_place']) {
expect(db.prepare('SELECT 1 FROM sqlite_master WHERE name = ?').get(name)).toBeDefined();
}
runMigrations(db);
expect(cursor(db)).toBe(262);
expect(db.pragma('foreign_key_check')).toEqual([]);
}
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`Migration exit ${code}`); });
});
afterEach(() => {
vi.restoreAllMocks();
for (const db of databases.splice(0)) if (db.open) db.close();
for (const directory of disposableDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
});
describe('Tours migration sequence', () => {
it('replays the current upstream 258 prefix with the current Tours tail', () => {
const db = createUpstream258Fixture();
expect(cursor(db)).toBe(258);
expect(db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'tours'").get()).toBeUndefined();
runMigrations(db);
expectFinalSchema(db);
});
});
describe('Tours migration lineage matrix', () => {
it('migrates a fresh disposable database from zero and safely reruns', () => {
const db = emptyDb();
runMigrations(db);
expectFinalSchema(db);
});
it('accepts the exact public upstream schema at historical version 218', () => {
const db = migrationPrefixDatabase(218);
expect(db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'tours'").get()).toBeUndefined();
expect(db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'roadtrip_day_boundaries'").get()).toBeDefined();
expect(db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'school_holiday_periods'").get()).toBeDefined();
expect(db.pragma('table_info(day_assignments)')).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'end_day' }),
]));
runMigrations(db);
expectFinalSchema(db);
});
it.each([242, 243, 244, 245, 246, 258])('migrates pure upstream %i without a rewind', (version) => {
const db = migrationPrefixDatabase(version);
const writes: number[] = [];
const prepare = db.prepare.bind(db);
vi.spyOn(db, 'prepare').mockImplementation(((sql: string) => {
const statement = prepare(sql);
if (sql.includes('UPDATE schema_version SET version')) {
const run = statement.run.bind(statement);
statement.run = ((value: number) => { writes.push(value); return run(value); }) as typeof statement.run;
}
return statement;
}) as typeof db.prepare);
runMigrations(db);
expect(writes.every(value => value > version)).toBe(true);
expectFinalSchema(db);
});
it('accepts upstream Web Push at historical slot 244 and restores the Atlas trigger at 246', () => {
const db = migrationPrefixDatabase(245);
db.exec('DROP TRIGGER trg_place_regions_follow_place; UPDATE schema_version SET version = 244');
runMigrations(db);
expectFinalSchema(db);
});
it('rejects an upstream cursor carrying impossible later schema markers without rewriting it', () => {
const db = migrationPrefixDatabase(243);
db.exec('ALTER TABLE day_assignments ADD COLUMN route_excluded INTEGER NOT NULL DEFAULT 0');
const beforeSchema = schemaSnapshot(db);
const beforeRows = snapshot(db);
expect(() => runMigrations(db)).toThrow(/Unsafe Tours migration lineage/);
expect(cursor(db)).toBe(243);
expect(schemaSnapshot(db)).toEqual(beforeSchema);
expect(snapshot(db)).toEqual(beforeRows);
});
it.each([216, 217, 218])('recognizes the exact legacy Tours %i schema', (version) => {
const db = createLegacyToursFixture(version);
const before = version === 218 ? (seedTour(db), snapshot(db)) : undefined;
runMigrations(db);
expectFinalSchema(db);
if (before) {
const after = snapshot(db);
for (const table of ['places', 'tour_waypoints']) expectRowsPreserved(after[table], before[table]);
expect(after.tours).toEqual(before.tours.map(row => ({ ...row as object, max_hiking_difficulty: 2 })));
expect((after.tours[0] as { place_id: number; tour_type: string }).place_id).toBe(101);
expect((after.tours[0] as { tour_type: string }).tour_type).toBe('hike');
expect(after.tour_waypoints.map(row => (row as { id: number }).id)).toEqual([201, 203, 207]);
expect(after.day_assignments).toEqual(before.day_assignments.map(row => ({
...row as object, end_day: 0, route_excluded: 0, accommodation_id: null,
})));
}
});
it.each([243, 244, 245, 246])('recognizes the exact frozen Tours %i schema', (version) => {
const db = createFrozenToursFixture(version);
if (version === 246) {
seedTour(db);
db.prepare('UPDATE tours SET max_hiking_difficulty = 5').run();
}
const before = version === 246 ? snapshot(db) : undefined;
runMigrations(db);
expectFinalSchema(db);
if (before) {
const after = snapshot(db);
for (const table of ['places', 'tours', 'tour_waypoints']) expectRowsPreserved(after[table], before[table]);
expect((after.tours[0] as { place_id: number; tour_type: string }).place_id).toBe(101);
expect((after.tours[0] as { tour_type: string }).tour_type).toBe('hike');
expect((after.tours[0] as { max_hiking_difficulty: number }).max_hiking_difficulty).toBe(5);
expect(after.tour_waypoints.map(row => (row as { id: number }).id)).toEqual([201, 203, 207]);
expect(after.day_assignments).toEqual(before.day_assignments.map(row => ({ ...row as object, route_excluded: 0 })));
expect(() => db.prepare('UPDATE tours SET max_hiking_difficulty = 7').run()).toThrow();
db.prepare('DELETE FROM places WHERE id = 101').run();
expect(db.prepare('SELECT * FROM tours').all()).toEqual([]);
expect(db.prepare('SELECT * FROM tour_waypoints').all()).toEqual([]);
}
});
it.each([
"ALTER TABLE users ADD COLUMN immich_allow_insecure_tls INTEGER NOT NULL DEFAULT 0",
'CREATE TABLE push_subscriptions (id INTEGER PRIMARY KEY)',
'ALTER TABLE tours ADD COLUMN unknown_manual_column TEXT',
'DROP INDEX idx_tour_waypoints_place',
'UPDATE schema_version SET version = 244',
])('rejects mixed or contradictory frozen schemas without changing cursor or data: %s', (mutation) => {
const db = createAmbiguousToursFixture(mutation, 'frozen');
const before = snapshot(db);
const beforeSchema = schemaSnapshot(db);
const version = cursor(db);
expect(() => runMigrations(db)).toThrow(/Unsafe Tours migration lineage/);
expect(cursor(db)).toBe(version);
expect(snapshot(db)).toEqual(before);
expect(schemaSnapshot(db)).toEqual(beforeSchema);
expect(db.pragma('foreign_key_check')).toEqual([]);
});
it.each(['legacy', 'frozen'] as const)('rolls a recognized %s bridge and its cursor back if replay fails, then restarts safely', (lineage) => {
const version = lineage === 'legacy' ? 218 : 246;
const db = lineage === 'legacy' ? createLegacyTours218Fixture() : createFrozenTours246Fixture();
seedTour(db);
const before = snapshot(db);
const beforeSchema = schemaSnapshot(db);
const exec = db.exec.bind(db);
vi.spyOn(db, 'exec').mockImplementation((sql) => {
if (sql.includes('CREATE TABLE IF NOT EXISTS push_subscriptions')) throw new Error('Injected migration failure');
return exec(sql);
});
expect(() => runMigrations(db)).toThrow();
expect(cursor(db)).toBe(version);
expect(snapshot(db)).toEqual(before);
expect(schemaSnapshot(db)).toEqual(beforeSchema);
expect(process.exit).not.toHaveBeenCalled();
expect(db.prepare("SELECT 1 FROM pragma_table_info('users') WHERE name = 'immich_allow_insecure_tls'").get()).toBeUndefined();
vi.mocked(db.exec).mockRestore();
runMigrations(db);
expectFinalSchema(db);
});
it.each([
'CREATE TABLE school_holiday_countries (code TEXT PRIMARY KEY, name TEXT NOT NULL)',
'ALTER TABLE day_assignments ADD COLUMN end_day INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE tour_types ADD COLUMN unknown_manual_column TEXT',
'UPDATE schema_version SET version = 216',
])('rejects ambiguous legacy Tours schemas without rewinding: %s', (mutation) => {
const db = createAmbiguousToursFixture(mutation, 'legacy');
const before = snapshot(db);
const beforeSchema = schemaSnapshot(db);
const version = cursor(db);
expect(() => runMigrations(db)).toThrow(/Unsafe Tours migration lineage/);
expect(cursor(db)).toBe(version);
expect(snapshot(db)).toEqual(before);
expect(schemaSnapshot(db)).toEqual(beforeSchema);
});
it('upgrades a copied synthetic frozen-246 database and safely reopens it without touching its source', async () => {
const source = createFrozenTours246Fixture();
seedTour(source);
source.prepare('UPDATE tours SET max_hiking_difficulty = 5').run();
const before = snapshot(source);
const parent = resolve(repositoryRoot, 'server/data/tmp');
mkdirSync(parent, { recursive: true });
const directory = mkdtempSync(resolve(parent, 'b1-tours-lineage-'));
disposableDirectories.push(directory);
const filename = resolve(directory, 'synthetic-frozen-copy.sqlite');
await source.backup(filename);
const copy = new Database(filename);
databases.push(copy);
copy.pragma('foreign_keys = ON');
runMigrations(copy);
expectFinalSchema(copy);
const after = snapshot(copy);
for (const table of ['places', 'tours', 'tour_waypoints', 'day_assignments']) {
expectRowsPreserved(after[table], before[table]);
}
copy.close();
const reopened = new Database(filename);
databases.push(reopened);
reopened.pragma('foreign_keys = ON');
runMigrations(reopened);
expectFinalSchema(reopened);
expect(snapshot(reopened)).toEqual(after);
expect(cursor(source)).toBe(246);
expect(snapshot(source)).toEqual(before);
});
});
@@ -45,6 +45,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.
@@ -138,8 +144,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 () => {
@@ -158,7 +164,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', () => {
@@ -104,6 +104,25 @@ describe('verifyTripAccess', () => {
// ── getAssignmentsForDay ──────────────────────────────────────────────────────
describe('getAssignmentsForDay', () => {
it('projects only facet-backed Tour geometry in both day read paths', () => {
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);
testDb.prepare("INSERT INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'hike', 'hike', '#000000')").run();
testDb.prepare("INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')").run(tour.id);
createDayAssignment(testDb, day.id, tour.id, { order_index: 0 });
createDayAssignment(testDb, day.id, track.id, { order_index: 1 });
for (const assignments of [svc.getAssignmentsForDay(day.id), 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', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -1,7 +1,26 @@
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),
}),
}));
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { HttpException } from '@nestjs/common';
import { PlacesController } from '../../../src/nest/places/places.controller';
import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import type { PlacesService } from '../../../src/nest/places/places.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
import type { User } from '../../../src/types';
@@ -43,6 +62,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.
@@ -256,14 +279,21 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
// element before the handler runs.
it('returns empty for an empty list without touching the service', async () => {
const removeMany = vi.fn();
expect(await new PlacesController(svc({ removeMany } as Partial<PlacesService>), new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [] })).toEqual({ deleted: [], count: 0 });
expect(await new PlacesController(svc({ removeMany } as Partial<PlacesService>), new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [] })).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: [] } }); const onDeleted = vi.fn(); const broadcast = vi.fn();
const removeMany = vi.fn().mockReturnValue({ deleted: [1, 2], deletedTourPlaceIds: [2], cancelled: { reservationIds: [], budgetItemIds: [] } }); const onDeleted = vi.fn(); const broadcast = vi.fn();
const scopedIds = vi.fn().mockReturnValue([1, 2]);
const s = svc({ removeMany, onDeleted, broadcast, scopedIds } as Partial<PlacesService>);
expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1, 2] }, 'sock')).toEqual({ deleted: [1, 2], count: 2 });
expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1, 2] }, 'sock')).toEqual({ deleted: [1, 2], count: 2, tourPlaceIds: [2] });
expect(onDeleted).toHaveBeenCalledTimes(2);
expect(broadcast).toHaveBeenCalledTimes(2);
});
@@ -477,8 +507,12 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
expect(await thrownAsync(() => new PlacesController(svc({ remove, onDeleted } as Partial<PlacesService>), new RuntimeEnvService(), storageStub).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } });
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: [] } }), broadcast: vi.fn() } as Partial<PlacesService>);
expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9')).toEqual({ success: true });
const s = svc({ 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');
});
it('DELETE /:id announces the booking and the expense a cancelled night took down', async () => {
@@ -565,7 +599,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> = {}) =>
@@ -145,6 +145,20 @@ describe('list', () => {
expect(places).toHaveLength(2);
});
it('PLACE-SVC-TOURS-002 — identifies Tours by their facet in the ordinary Place read model', () => {
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 OR IGNORE INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'test', 'Mountain', '#000000')`).run();
testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(tour.id);
const byId = new Map((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', () => {
const { user } = createUser(testDb);
const t1 = createTrip(testDb, user.id);
@@ -480,6 +494,43 @@ 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;
testDb.prepare(`INSERT OR IGNORE INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'test', 'Mountain', '#000000')`).run();
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);
@@ -556,6 +607,25 @@ 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;
testDb.prepare(`INSERT OR IGNORE INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'test', 'Mountain', '#000000')`).run();
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,68 @@
import { describe, expect, it, vi } from 'vitest';
import 'reflect-metadata';
vi.mock('../../../src/config', () => { throw new Error('Config initialization is forbidden in mock-only tests'); });
vi.mock('../../../src/db/database', () => { throw new Error('Legacy database imports are forbidden in mock-only tests'); });
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in mock-only tests'); });
vi.mock('../../../src/nest/database/database.service', () => ({
DatabaseService: vi.fn(() => { throw new Error('Database construction is forbidden in mock-only tests'); }),
}));
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
import { GUARDS_METADATA } from '@nestjs/common/constants';
import type { TourCreateRequest } from '@trek/shared';
import { ToursController } from '../../../src/nest/tours/tours.controller';
import type { ToursService } from '../../../src/nest/tours/tours.service';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
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,
};
describe('ToursController planner routes', () => {
it('TOURS-CTL-001: hides every Tours route behind the addon guard before auth', () => {
const guards = Reflect.getMetadata(GUARDS_METADATA, ToursController) as unknown[];
expect(guards).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
expect(Reflect.getMetadata(REQUIRE_ADDON, ToursController)).toEqual({ addonId: 'tours', label: 'Tours' });
});
it('TOURS-CTL-002: POST /tours delegates the validated payload and socket id', () => {
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
const service = { createTour: vi.fn().mockReturnValue(result) } as unknown as ToursService;
const controller = new ToursController(service);
expect(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', () => {
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
const service = { getTour: vi.fn().mockReturnValue(result) } as unknown as ToursService;
const controller = new ToursController(service);
expect(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', () => {
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
const service = { updateTour: vi.fn().mockReturnValue(result) } as unknown as ToursService;
const controller = new ToursController(service);
expect(controller.update('7', '1', request, 'socket-2')).toBe(result);
expect(service.updateTour).toHaveBeenCalledWith('7', '1', request, 'socket-2');
});
});
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { Logger } from '@nestjs/common';
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: vi.fn(),
}));
vi.mock('../../../src/db/database', () => ({
db: {
prepare: () => { throw new Error('Legacy database access forbidden'); },
exec: () => { throw new Error('Legacy database access forbidden'); },
},
}));
vi.mock('../../../src/nest/database/database.service', () => ({ DatabaseService: class {} }));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { PlacesService } from '../../../src/nest/places/places.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { gpxParser } from '../../../src/nest/places/places.helpers';
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>`);
function makeHarness() {
const raw = new Database(':memory:');
raw.pragma('foreign_keys = ON');
raw.exec(`
CREATE TABLE places (id INTEGER PRIMARY KEY, trip_id TEXT, name TEXT, description TEXT,
lat REAL, lng REAL, transport_mode TEXT, route_geometry TEXT, route_color TEXT,
google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT);
CREATE TABLE tours (place_id INTEGER PRIMARY KEY REFERENCES places(id), tour_type TEXT,
distance REAL, elevation_gain REAL, elevation_loss REAL, duration REAL,
match_confidence REAL, max_hiking_difficulty INTEGER);
CREATE TABLE tour_waypoints (id INTEGER PRIMARY KEY, tour_place_id INTEGER REFERENCES tours(place_id));
`);
const db = {
connection: raw,
prepare: (sql: string) => raw.prepare(sql),
all: (sql: string, ...params: unknown[]) => raw.prepare(sql).all(...params),
getPlaceWithTags: (id: number) => ({ ...raw.prepare('SELECT * FROM places WHERE id = ?').get(id) as object, tags: [] }),
transaction: vi.fn(<Result>(write: (conn: Database.Database) => Result): Result => {
expect(raw.inTransaction).toBe(false);
return raw.transaction(() => write(raw))();
}),
};
const broadcast = vi.fn();
const places = new PlacesService(db as never, {} as never, { broadcast } as never,
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never);
return { raw, db, places, broadcast, tours: new ToursService(db as never, places) };
}
describe('Tours GPX atomic persistence and postcommit publication', () => {
let setup: ReturnType<typeof makeHarness>;
beforeEach(() => { setup = makeHarness(); });
afterEach(() => { setup.raw.close(); vi.restoreAllMocks(); });
function expectEmpty() {
for (const table of ['places', 'tours', 'tour_waypoints']) {
expect(setup.raw.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get()).toEqual({ count: 0 });
}
expect(setup.broadcast).not.toHaveBeenCalled();
}
it('commits routes, tracks, colors and facets once before publishing, without Planner waypoints', () => {
setup.broadcast.mockImplementation((tripId, event, payload, socketId) => {
expect(setup.raw.inTransaction).toBe(false);
expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 2 });
expect(tripId).toBe('7');
if (event === 'tours:changed') {
expect(payload).toEqual({ placeIds: [1, 2] });
expect(socketId).toBe('socket');
} else {
expect(event).toBe('place:created');
expect(socketId).toBe('socket');
expect(payload.place.route_color).toBeTruthy();
}
});
const result = setup.tours.importGpxAsTour('7', mixedGpx, 'walk.gpx', 'socket')!;
expect(setup.db.transaction).toHaveBeenCalledOnce();
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 });
expect(result.tours[1]).toMatchObject({ name: 'Ridge', elevation_gain: 50, elevation_loss: 30, caution: false });
const rows = setup.raw.prepare('SELECT * FROM places ORDER BY id').all() as { trip_id: string; description: string; route_geometry: string; route_color: string }[];
expect(rows.map(row => row.trip_id)).toEqual(['7', '7']);
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(setup.raw.prepare('SELECT COUNT(*) AS count FROM tour_waypoints').get()).toEqual({ count: 0 });
expect(setup.broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']);
expect(setup.broadcast.mock.calls.slice(1).map(call => call[2].place.id)).toEqual(result.tours.map(tour => tour.place_id));
});
it.each([1, 2])('rolls back every carrier and facet when facet %i fails', (ordinal) => {
setup.raw.exec(`CREATE TRIGGER fail_facet BEFORE INSERT ON tours
WHEN (SELECT COUNT(*) FROM tours) = ${ordinal - 1}
BEGIN SELECT RAISE(ABORT, 'facet failure'); END;`);
expect(() => setup.tours.importGpxAsTour('7', mixedGpx)).toThrow('facet failure');
expectEmpty();
});
it('rolls back earlier carriers when the later Place insert fails', () => {
setup.raw.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge'
BEGIN SELECT RAISE(ABORT, 'place failure'); END;`);
expect(() => setup.tours.importGpxAsTour('7', mixedGpx)).toThrow('place failure');
expectEmpty();
});
it('rolls back carriers and earlier colors when later coloring fails', () => {
setup.raw.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
expect(() => setup.tours.importGpxAsTour('7', mixedGpx)).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', (xml) => {
expect(setup.tours.importGpxAsTour('7', Buffer.from(xml))).toBeNull();
expect(setup.db.transaction).not.toHaveBeenCalled();
expectEmpty();
});
it('propagates parser failures before entering the transaction', () => {
vi.spyOn(gpxParser, 'parse').mockImplementationOnce(() => { throw new Error('parser failure'); });
expect(() => setup.tours.importGpxAsTour('7', mixedGpx)).toThrow('parser failure');
expect(setup.db.transaction).not.toHaveBeenCalled();
expectEmpty();
});
it('keeps a committed response and attempts remaining events if publication throws', () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
setup.broadcast.mockImplementationOnce(() => { throw new Error('transport unavailable'); });
expect(setup.tours.importGpxAsTour('7', mixedGpx)?.tours).toHaveLength(2);
expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 2 });
expect(setup.broadcast).toHaveBeenCalledTimes(3);
expect(warn).toHaveBeenCalledOnce();
});
it('keeps committed rows and success response if the Tours invalidation event fails', () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
setup.broadcast.mockImplementation((_tripId, event) => {
if (event === 'tours:changed') throw new Error('invalidation unavailable');
});
const result = setup.tours.importGpxAsTour('7', mixedGpx);
expect(result?.tours).toHaveLength(2);
expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM places').get()).toEqual({ count: 2 });
expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 2 });
expect(setup.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', () => {
expect(setup.tours.importGpxAsTour('7', mixedGpx)?.tours).toHaveLength(2);
const rowsBefore = setup.raw.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all();
const facetsBefore = setup.raw.prepare('SELECT place_id FROM tours ORDER BY place_id').all();
setup.broadcast.mockClear();
expect(setup.tours.importGpxAsTour('7', mixedGpx)).toEqual({ tours: [], caution: false, skipped: 2 });
expect(setup.raw.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all()).toEqual(rowsBefore);
expect(setup.raw.prepare('SELECT place_id FROM tours ORDER BY place_id').all()).toEqual(facetsBefore);
expect(setup.broadcast).not.toHaveBeenCalled();
})
it('imports only the new tracks and publishes only committed rows in a mixed result', () => {
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(setup.tours.importGpxAsTour('7', existing)?.tours).toHaveLength(1);
setup.broadcast.mockClear();
const result = setup.tours.importGpxAsTour('7', mixedGpx, 'walk.gpx', 'socket');
expect(result?.tours).toHaveLength(1);
expect(result?.tours[0].name).toBe('walk');
expect(result?.skipped).toBe(1);
expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM places').get()).toEqual({ count: 2 });
expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 2 });
expect(setup.broadcast.mock.calls.map(call => [call[1], call[2]])).toEqual([
['tours:changed', { placeIds: [2] }],
['place:created', expect.objectContaining({ place: expect.objectContaining({ id: 2, name: 'walk' }) })],
]);
})
it('preserves single-track response and trip scoping', () => {
const single = Buffer.from('<gpx><trk><trkseg><trkpt lat="48" lon="11"/><trkpt lat="48.01" lon="11.01"/></trkseg></trk></gpx>');
const result = setup.tours.importGpxAsTour('8', single, 'single.gpx')!;
expect(result.tours).toHaveLength(1);
expect(result.tours[0].name).toBe('single');
expect(result.caution).toBe(true);
expect(setup.broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created']);
});
it('rejects caller-owned persistence outside a transaction before writing', () => {
expect(() => setup.places.importPreparedGpx('7', setup.places.prepareGpxRows(mixedGpx))).toThrow('active transaction');
expectEmpty();
});
});
@@ -0,0 +1,238 @@
import 'reflect-metadata';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { HttpException, type ExecutionContext } from '@nestjs/common';
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { RequestMethod } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { TourCreateRequest } from '@trek/shared';
vi.mock('../../../src/config', () => { throw new Error('Config initialization is forbidden in mock-only tests'); });
vi.mock('../../../src/nest/database/database.service', () => ({
DatabaseService: vi.fn(() => { throw new Error('Database construction is forbidden in this suite'); }),
}));
vi.mock('../../../src/db/database', () => ({
db: { prepare: vi.fn(() => { throw new Error('Legacy database access is forbidden in this suite'); }) },
}));
vi.mock('better-sqlite3', () => ({
default: vi.fn(() => { throw new Error('SQLite construction is forbidden in this suite'); }),
}));
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() }));
import { DatabaseService } from '../../../src/nest/database/database.service';
import { db as legacyDatabase } from '../../../src/db/database';
import SqliteDatabase from 'better-sqlite3';
import { PlacesController } from '../../../src/nest/places/places.controller';
import { DayAssignmentsController } from '../../../src/nest/assignments/assignments.controller';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { TripAccessGuard, TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
const matrix = [[false, false], [true, false], [false, true], [true, true]] as const;
afterAll(() => {
expect(DatabaseService).not.toHaveBeenCalled();
expect(legacyDatabase.prepare).not.toHaveBeenCalled();
expect(SqliteDatabase).not.toHaveBeenCalled();
});
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' } };
const trip = { id: 7, user_id: 1 };
const access = vi.fn((id: number, userId: number) => id === 7 && userId === 2 ? trip : undefined);
const permission = vi.fn((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 guard = new TripAccessGuard({ canAccessTrip: access } as never, { checkPermission: permission } as never, new Reflector());
return { request, access, permission, context, guard };
}
async function rejected(action: () => unknown, status: number) {
await expect(Promise.resolve().then(action)).rejects.toMatchObject({ status });
}
function placesFixture() {
const places = {
get: vi.fn(() => ({ id: 42, trip_id: 7 })),
onDeleted: vi.fn(),
linkedExpenseIds: vi.fn(() => []),
remove: vi.fn(async () => ({ deleted: true, deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } })),
broadcast: vi.fn(),
};
return { places, controller: new PlacesController(places as never, {} as never, {} as never) };
}
function assignmentsFixture() {
const assignment = { id: 51, place_id: 42, day_id: 11 };
const assignments = {
dayExists: vi.fn(() => true),
placeExists: vi.fn(() => true),
assignmentExistsInDay: vi.fn(() => true),
createAssignment: vi.fn(() => assignment),
deleteAssignment: vi.fn(),
broadcast: vi.fn(),
reconcile: vi.fn(),
};
return { assignment, assignments, controller: new DayAssignmentsController(assignments as never) };
}
describe('Tours deletion and assignment authorization (mock-only)', () => {
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 = () => {
auth.guard.canActivate(auth.context);
return controller.remove(auth.request.user as never, '7', '42', 'socket');
};
if (canEdit) {
await expect(invoke()).resolves.toEqual({ success: true, tourPlaceIds: [] });
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 = () => {
auth.guard.canActivate(auth.context);
return handler === 'create'
? controller.create(auth.request.user as never, '7', '11', { place_id: 42, notes: 'Tour' }, 'socket')
: controller.remove(auth.request.user as never, '7', '11', '51', 'socket');
};
if (canAssign) {
expect(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 },
],
};
function tourDatabaseFixture(ownerTripId: string) {
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 waypointRun = vi.fn();
const db = {
get: vi.fn((sql: string, tripId: string, placeId: string) => {
expect(sql.replace(/\s+/g, ' ')).toContain('WHERE p.trip_id = ? AND p.id = ?');
return tripId === ownerTripId && placeId === '42' ? row : undefined;
}),
all: vi.fn(() => input.waypoints),
run: vi.fn(),
prepare: vi.fn(() => ({ run: waypointRun })),
transaction: vi.fn((write: () => void) => write()),
getPlaceWithTags: vi.fn(() => ({ id: 42, trip_id: Number(ownerTripId) })),
};
const places = { broadcast: vi.fn() };
return { db, waypointRun, places, service: new ToursService(db as never, places as never) };
}
describe('Real ToursService update trip boundary (mock database module)', () => {
it('rejects a placeId belonging to another trip before any transaction or write', () => {
const { db, waypointRun, places, service } = tourDatabaseFixture('8');
expect(service.getTour('8', '42').tour.place_id).toBe(42);
db.get.mockClear();
db.all.mockClear();
try {
service.updateTour('7', '42', input, 'socket');
expect.fail('Expected cross-trip rejection');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(404);
expect((error as Error).message).toBe('Tour not found');
}
expect(db.get).toHaveBeenCalledExactlyOnceWith(expect.any(String), '7', '42');
for (const method of [db.all, db.transaction, db.run, db.prepare, waypointRun, db.getPlaceWithTags, places.broadcast]) {
expect(method).not.toHaveBeenCalled();
}
expect(DatabaseService).not.toHaveBeenCalled();
});
it('allows a same-trip update through the fake transaction and prepare interface', () => {
const { db, waypointRun, places, service } = tourDatabaseFixture('7');
expect(service.updateTour('7', '42', input, 'socket').tour.place_id).toBe(42);
expect(db.get).toHaveBeenCalledTimes(2);
expect(db.transaction).toHaveBeenCalledOnce();
expect(db.run).toHaveBeenCalledTimes(3);
expect(db.run).toHaveBeenNthCalledWith(1, expect.stringMatching(/WHERE id = \? AND trip_id = \?/), input.name, 48, 11, JSON.stringify(input.route_geometry), '42', '7');
expect(db.prepare).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('INSERT INTO tour_waypoints'));
expect(waypointRun).toHaveBeenCalledTimes(2);
for (const waypoint of input.waypoints) {
expect(waypointRun).toHaveBeenCalledWith('42', waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence);
}
expect(db.getPlaceWithTags).toHaveBeenCalledWith(42);
expect(places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket');
expect(DatabaseService).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,108 @@
import 'reflect-metadata';
import { describe, expect, it, vi } from 'vitest';
import { HttpException, type ExecutionContext } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
vi.mock('../../../src/config', () => { throw new Error('Config initialization is forbidden in mock-only tests'); });
vi.mock('../../../src/db/database', () => { throw new Error('Legacy database imports are forbidden in mock-only tests'); });
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in mock-only tests'); });
vi.mock('../../../src/nest/database/database.service', () => ({
DatabaseService: vi.fn(() => { throw new Error('Database construction is forbidden in mock-only tests'); }),
}));
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
import { ToursController } from '../../../src/nest/tours/tours.controller';
import { TripAccessGuard, TRIP_PERMISSION_KEY, TRIP_REQUEST_KEY } from '../../../src/nest/permissions/trip-access.guard';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { extractToken, verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
function fixture(handler: keyof ToursController, canEdit = false, canAssign = false, tripId = '7') {
const request = { params: { tripId }, user: { id: 2, role: 'user' } };
const trip = { id: 7, user_id: 1 };
const access = vi.fn((id: number, userId: number) => id === 7 && userId === 2 ? trip : undefined);
const permission = vi.fn((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 guard = new TripAccessGuard({ canAccessTrip: access } as never, { checkPermission: permission } as never, new Reflector());
return { request, trip, access, permission, context, guard };
}
function expectStatus(action: () => unknown, status: number) {
try {
action();
expect.fail('Expected guard rejection');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(status);
}
}
describe('Tours permission contract (mock-only, no DB)', () => {
it('retains addon/JWT/trip guard order and exact write metadata', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
for (const handler of ['create', 'update', 'importGpx'] 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([[false, false], [true, false], [false, true], [true, true]])('edit=%s assign=%s permits reads and only place-edit writes', (canEdit, canAssign) => {
for (const handler of ['list', 'detail'] as const) {
const test = fixture(handler, canEdit, canAssign);
expect(test.guard.canActivate(test.context)).toBe(true);
expect(test.permission).not.toHaveBeenCalled();
expect(test.request[TRIP_REQUEST_KEY as keyof typeof test.request]).toEqual(test.trip);
}
for (const handler of ['create', 'update', 'importGpx'] as const) {
const test = fixture(handler, canEdit, canAssign);
if (canEdit) expect(test.guard.canActivate(test.context)).toBe(true);
else expectStatus(() => test.guard.canActivate(test.context), 403);
expect(test.permission).toHaveBeenCalledWith('place_edit', 'user', 1, 2, true);
}
});
it.each(['list', 'detail', 'create', 'update', 'importGpx'] as const)('%s rejects inaccessible route trips before any write permission check', handler => {
const test = fixture(handler, true, true, '8');
expectStatus(() => test.guard.canActivate(test.context), 404);
expect(test.access).toHaveBeenCalledWith(8, 2);
expect(test.permission).not.toHaveBeenCalled();
});
it('preserves addon and JWT refusal without reaching trip resolution', () => {
const test = fixture('create', true, true);
const addon = new AddonGuard({ isAddonEnabled: vi.fn(() => false) } as never, new Reflector());
expectStatus(() => addon.canActivate(test.context), 404);
vi.mocked(extractToken).mockReturnValue(null);
const jwt = new JwtAuthGuard();
expectStatus(() => jwt.canActivate(test.context), 401);
vi.mocked(extractToken).mockReturnValue('token');
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
expectStatus(() => jwt.canActivate(test.context), 401);
expect(test.access).not.toHaveBeenCalled();
});
it('delegates import with the route trip and preserves missing/empty-file prechecks', () => {
const imported = { tours: [{ place_id: 42 }] };
const importGpxAsTour = vi.fn(() => imported);
const controller = new ToursController({ importGpxAsTour } as never);
const buffer = Buffer.from('gpx');
expect(controller.importGpx('7', { buffer, originalname: 'ridge.gpx' } as Express.Multer.File, 'socket')).toBe(imported);
expect(importGpxAsTour).toHaveBeenCalledWith('7', buffer, 'ridge.gpx', 'socket');
const allSkipped = { tours: [], caution: false, skipped: 2 };
importGpxAsTour.mockReturnValueOnce(allSkipped as never);
expect(controller.importGpx('7', { buffer, originalname: 'duplicates.gpx' } as Express.Multer.File)).toBe(allSkipped);
expectStatus(() => controller.importGpx('7', undefined), 400);
importGpxAsTour.mockReturnValueOnce(null as never);
expectStatus(() => controller.importGpx('7', { buffer, originalname: 'empty.gpx' } as Express.Multer.File), 400);
});
});
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../src/config', () => { throw new Error('Config initialization is forbidden in mock-only tests'); });
vi.mock('../../../src/db/database', () => { throw new Error('Legacy database imports are forbidden in mock-only tests'); });
vi.mock('../../../src/nest/database/database.service', () => ({
DatabaseService: vi.fn(() => { throw new Error('Database construction is forbidden in mock-only tests'); }),
}));
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in mock-only tests'); });
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
import { ToursService } from '../../../src/nest/tours/tours.service';
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
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,
};
let inTransaction = false;
const waypointRun = vi.fn(() => { expect(inTransaction).toBe(true); });
const db = {
get: vi.fn((): unknown => row),
all: vi.fn((sql: string): unknown[] => sql.includes('JOIN places') ? [row] : request.waypoints),
run: vi.fn<(sql: string, ...params: unknown[]) => { lastInsertRowid: number }>(() => {
expect(inTransaction).toBe(true);
return { lastInsertRowid: 42 };
}),
prepare: vi.fn(() => {
expect(inTransaction).toBe(true);
return { run: waypointRun };
}),
transaction: vi.fn((write: () => void) => {
inTransaction = true;
try { write(); } finally { inTransaction = false; }
}),
getPlaceWithTags: vi.fn(() => ({ id: 42, trip_id: 7 })),
};
const places = { broadcast: vi.fn() };
return { db, waypointRun, places, service: new ToursService(db as never, places as never) };
}
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,
};
describe('ToursService planner contracts (mock-only; SQLite rollback semantics require separate integration coverage)', () => {
let setup: ReturnType<typeof makeService>;
beforeEach(() => { setup = makeService(); });
it('TOURS-SVC-001: sends geometry, metrics, and ordered controls through one injected transaction', () => {
const result = setup.service.createTour('7', request, 'socket-1');
expect(setup.db.transaction).toHaveBeenCalledOnce();
expect(setup.db.run).toHaveBeenCalledTimes(2);
expect(setup.db.run).toHaveBeenNthCalledWith(1, expect.stringContaining('INSERT INTO places'), '7', request.name, 48, 11, JSON.stringify(request.route_geometry));
expect(setup.db.run).toHaveBeenNthCalledWith(2, expect.stringContaining('INSERT INTO tours'), 42, 'hike', expect.any(Number), 50, 20, 60, 2);
expect(setup.db.run.mock.calls[1][3]).toBeGreaterThan(0);
expect(setup.waypointRun).toHaveBeenCalledTimes(2);
for (const waypoint of request.waypoints) {
expect(setup.waypointRun).toHaveBeenCalledWith(42, waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence);
}
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: { id: 42, trip_id: 7 } }, 'socket-1');
});
it('TOURS-SVC-002: propagates injected waypoint failure without publishing or reading an uncommitted create', () => {
const failure = new Error('Injected waypoint failure');
setup.waypointRun.mockImplementationOnce(() => { throw failure; });
expect(() => setup.service.createTour('7', request)).toThrow(failure);
expect(setup.db.transaction).toHaveBeenCalledOnce();
expect(setup.db.all).not.toHaveBeenCalled();
expect(setup.db.getPlaceWithTags).not.toHaveBeenCalled();
expect(setup.places.broadcast).not.toHaveBeenCalled();
});
it('TOURS-SVC-003: validates and rounds fractional routed duration before the create write', () => {
const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 });
const result = setup.service.createTour('7', validated);
expect(setup.db.run).toHaveBeenNthCalledWith(2, expect.stringContaining('INSERT INTO tours'), 42, 'hike', expect.any(Number), 50, 20, 60, 2);
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', () => {
expect(setup.service.getTour('7', '42').waypoints).toEqual(request.waypoints);
expect(setup.db.get).toHaveBeenCalledWith(expect.stringMatching(/WHERE p.trip_id = \? AND p.id = \?/), '7', '42');
expect(setup.db.all).toHaveBeenCalledWith(expect.stringContaining('ORDER BY sequence'), '42');
setup.db.get.mockReturnValueOnce(undefined);
setup.db.all.mockClear();
expect(() => setup.service.getTour('8', '42')).toThrow('Tour not found');
expect(setup.db.all).not.toHaveBeenCalled();
setup.db.get.mockReturnValueOnce({ place_id: 42, has_waypoints: 0 }).mockReturnValueOnce({ route_geometry: JSON.stringify(request.route_geometry) });
setup.db.all.mockReturnValueOnce([]);
const legacyGpx = setup.service.getTour('7', '42');
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 },
]);
for (const method of [setup.db.transaction, setup.db.run, setup.db.prepare, setup.waypointRun, setup.places.broadcast]) {
expect(method).not.toHaveBeenCalled();
}
});
it('TOURS-SVC-005: updates route data and replaces controls through the injected transaction', () => {
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,
};
setup.service.updateTour('7', '42', update, 'socket-2');
expect(setup.db.transaction).toHaveBeenCalledOnce();
expect(setup.db.run).toHaveBeenCalledTimes(3);
expect(setup.db.run).toHaveBeenNthCalledWith(1, expect.stringMatching(/WHERE id = \? AND trip_id = \?/), update.name, 49, 12, JSON.stringify(update.route_geometry), '42', '7');
expect(setup.db.run).toHaveBeenNthCalledWith(2, expect.stringContaining('UPDATE tours'), 'hike', expect.any(Number), 60, 0, 45, 2, '42');
expect(setup.db.run).toHaveBeenNthCalledWith(3, 'DELETE FROM tour_waypoints WHERE place_id = ?', '42');
expect(setup.waypointRun).toHaveBeenCalledTimes(3);
for (const waypoint of update.waypoints) {
expect(setup.waypointRun).toHaveBeenCalledWith('42', waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence);
}
expect(setup.places.broadcast).toHaveBeenCalledWith(
'7', 'place:updated', expect.objectContaining({ place: expect.anything() }), 'socket-2',
);
});
it('TOURS-SVC-006: propagates injected replacement failure without publishing or reloading', () => {
const failure = new Error('Injected replacement failure');
setup.waypointRun.mockImplementationOnce(() => { throw failure; });
expect(() => setup.service.updateTour('7', '42', request)).toThrow(failure);
expect(setup.db.transaction).toHaveBeenCalledOnce();
expect(setup.db.get).toHaveBeenCalledTimes(1);
expect(setup.db.getPlaceWithTags).not.toHaveBeenCalled();
expect(setup.places.broadcast).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { Logger } from '@nestjs/common';
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),
}),
}));
import { ToursService } from '../../../src/nest/tours/tours.service';
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
function makeService() {
const raw = new Database(':memory:');
raw.pragma('foreign_keys = ON');
raw.exec(`
CREATE TABLE places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL,
name TEXT NOT NULL,
lat REAL,
lng REAL,
transport_mode TEXT,
route_geometry TEXT
);
CREATE TABLE day_assignments (place_id INTEGER);
CREATE TABLE tour_types (key TEXT PRIMARY KEY);
INSERT INTO tour_types (key) VALUES ('hike');
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, max_hiking_difficulty INTEGER NOT NULL DEFAULT 2,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
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, lng REAL NOT NULL,
role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')),
sequence INTEGER NOT NULL,
UNIQUE(place_id, sequence)
);
`);
const db = {
get: <T>(sql: string, ...params: unknown[]) => raw.prepare(sql).get(...params as never[]) as T | undefined,
all: <T>(sql: string, ...params: unknown[]) => raw.prepare(sql).all(...params as never[]) as T[],
run: (sql: string, ...params: unknown[]) => raw.prepare(sql).run(...params as never[]),
prepare: (sql: string) => raw.prepare(sql),
transaction: <T>(fn: () => T) => raw.transaction(fn)(),
getPlaceWithTags: (id: number) => raw.prepare('SELECT * FROM places WHERE id = ?').get(id),
};
const places = { broadcast: vi.fn() };
return { raw, places, service: new ToursService(db as never, places as never) };
}
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,
};
describe('ToursService planner creation', () => {
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: atomically stores full geometry, derived metrics, and ordered control points', () => {
const writeStates: boolean[] = [];
setup.places.broadcast.mockImplementation(() => { writeStates.push(setup.raw.inTransaction); });
const result = setup.service.createTour('7', request, 'socket-1');
const place = setup.raw.prepare('SELECT * FROM places').get() as Record<string, unknown>;
const tour = setup.raw.prepare('SELECT * FROM tours').get() as Record<string, unknown>;
const points = setup.raw.prepare('SELECT role, sequence FROM tour_waypoints ORDER BY sequence').all();
expect(JSON.parse(String(place.route_geometry))).toEqual(request.route_geometry);
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 });
expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [1] }, 'socket-1');
expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, '7', 'place:created', { place: expect.anything() }, 'socket-1');
expect(setup.places.broadcast).toHaveBeenCalledTimes(2);
expect(writeStates).toEqual([false, false]);
});
it('TOURS-SVC-002: rolls the owning place and facet back when a waypoint insert fails', () => {
const invalid = {
...request,
waypoints: [request.waypoints[0], { ...request.waypoints[1], sequence: 0 }],
} as TourCreateRequest;
expect(() => setup.service.createTour('7', invalid)).toThrow();
expect(setup.raw.prepare('SELECT count(*) AS n FROM places').get()).toEqual({ n: 0 });
expect(setup.raw.prepare('SELECT count(*) AS n FROM tours').get()).toEqual({ n: 0 });
expect(setup.places.broadcast).not.toHaveBeenCalled();
});
it('TOURS-SVC-003: validates a fractional routed duration before the atomic create', () => {
const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 });
const result = setup.service.createTour('7', validated);
expect(setup.raw.prepare('SELECT count(*) AS n FROM places').get()).toEqual({ n: 1 });
expect(setup.raw.prepare('SELECT count(*) AS n FROM tours').get()).toEqual({ n: 1 });
expect(setup.raw.prepare('SELECT count(*) AS n FROM tour_waypoints').get()).toEqual({ n: 2 });
expect(setup.raw.prepare('SELECT duration FROM tours').get()).toEqual({ duration: 60 });
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false });
});
it('TOURS-SVC-004: loads only a trip-owned tour with its ordered control points', () => {
const created = setup.service.createTour('7', request);
expect(setup.service.getTour('7', String(created.tour.place_id))).toEqual(created);
expect(() => setup.service.getTour('8', String(created.tour.place_id))).toThrow('Tour not found');
setup.raw.prepare('DELETE FROM tour_waypoints WHERE place_id = ?').run(created.tour.place_id);
const legacyGpx = setup.service.getTour('7', String(created.tour.place_id));
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 },
]);
});
it('TOURS-SVC-005: atomically updates route data and replaces persisted control points', () => {
const created = setup.service.createTour('7', request);
setup.places.broadcast.mockClear();
const writeStates: boolean[] = [];
setup.places.broadcast.mockImplementation(() => { writeStates.push(setup.raw.inTransaction); });
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,
};
const result = setup.service.updateTour('7', 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(setup.raw.prepare('SELECT name, lat, lng FROM places').get()).toEqual({
name: 'Updated ridge walk', lat: 49, lng: 12,
});
expect(setup.raw.prepare('SELECT duration FROM tours').get()).toEqual({ duration: 45 });
expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [created.tour.place_id] }, 'socket-2');
expect(setup.places.broadcast).toHaveBeenNthCalledWith(2,
'7', 'place:updated', expect.objectContaining({ place: expect.anything() }), 'socket-2',
);
expect(writeStates).toEqual([false, false]);
});
it('TOURS-SVC-006: rolls an invalid waypoint replacement back to the previous saved tour', () => {
const created = setup.service.createTour('7', request);
const invalid = {
...request,
name: 'Must roll back',
waypoints: [request.waypoints[0], { ...request.waypoints[1], sequence: 0 }],
} as TourCreateRequest;
expect(() => setup.service.updateTour('7', String(created.tour.place_id), invalid)).toThrow();
expect(setup.raw.prepare('SELECT name FROM places').get()).toEqual({ name: 'Ridge walk' });
expect(setup.service.getTour('7', String(created.tour.place_id)).waypoints).toEqual(request.waypoints);
});
it('keeps a committed create successful when Tours invalidation publication fails', () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
setup.places.broadcast.mockImplementation((_tripId, event) => {
if (event === 'tours:changed') throw new Error('transport unavailable');
});
const result = setup.service.createTour('7', request);
expect(result.tour.name).toBe('Ridge walk');
expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM places').get()).toEqual({ count: 1 });
expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 1 });
expect(setup.places.broadcast).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledOnce();
});
});
+30 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { db } from '../../../src/db/database';
@@ -7,11 +7,40 @@ import { FeaturesController } from '../../../src/nest/health/features.controller
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
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 pinned upstream module union and Tours exactly once', () => {
const imports = Reflect.getMetadata('imports', AppModule) as Array<Function | { module: Function }>;
const names = imports.map(entry => (typeof entry === 'function' ? entry : entry.module).name);
expect(names).toHaveLength(68);
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 () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(DatabaseService)