feat(roadtrip): share trip preferences and improve service stops

This commit is contained in:
Maurice
2026-09-12 00:24:45 +02:00
parent dd6ad31ab9
commit b773faff70
72 changed files with 1136 additions and 375 deletions
+6
View File
@@ -1,3 +1,4 @@
import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared';
import { readEnv } from '../app-config';
import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto';
@@ -4550,6 +4551,11 @@ function runMigrations(db: Database.Database): void {
PRIMARY KEY (trip_id, day_number)
)`);
},
() => {
db.exec('CREATE TABLE IF NOT EXISTS roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (trip_id, key))');
const inherit = db.prepare('INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL');
for (const key of ROADTRIP_PREFERENCE_KEYS) inherit.run(key);
},
];
if (currentVersion < migrations.length) {
@@ -74,7 +74,7 @@ export class RoadtripPlanService {
return {
days,
visits,
settings: this.preferences.read(userId),
settings: this.preferences.read(tripId),
profiles: this.router.profiles(),
vias: this.roadtrip.listForTrip(tripId),
tracks: this.roadtrip.tracksForTrip(tripId),
@@ -27,7 +27,7 @@ export class RoadtripPlanningMcp {
@Tool({
name: 'get_roadtrip_context',
description:
'Read the saved roadtrip days, visits, pinned times, stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes your personal driving preferences. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings affect your account; visits and manual boundaries affect the shared trip.',
'Read the saved roadtrip days, visits, pinned times, stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
@@ -0,0 +1,30 @@
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
import { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common';
import { roadtripPreferencesUpdateSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
class PreferencesDto extends createZodDto(roadtripPreferencesUpdateSchema) {}
@Controller('api/trips/:tripId/roadtrip/preferences')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
export class RoadtripPreferencesController {
constructor(private readonly preferences: RoadtripPreferencesService) {}
@Get()
read(@Param('tripId') tripId: string) {
return { tripId: Number(tripId), preferences: this.preferences.read(Number(tripId)) };
}
@Put()
@RequirePermission('day_edit')
update(@Param('tripId') tripId: string, @Body() patch: PreferencesDto) {
return { tripId: Number(tripId), preferences: this.preferences.update(Number(tripId), patch) };
}
}
@@ -1,4 +1,5 @@
import { ADDON_IDS } from '../../addons';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
McpController,
Tool,
@@ -11,9 +12,13 @@ import {
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
import { DatabaseService } from '../database/database.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
import { roadtripPreferencesUpdateSchema, type RoadtripPreferences } from '@trek/shared';
import { z } from 'zod';
const when = addonGate(ADDON_IDS.ROADTRIP);
@McpController()
@@ -22,32 +27,37 @@ export class RoadtripPreferencesMcp {
private readonly preferences: RoadtripPreferencesService,
private readonly auth: AuthService,
readonly addons: AddonsService,
private readonly db: DatabaseService,
private readonly guards: McpToolGuardsService,
) {}
@Tool({
name: 'get_roadtrip_settings',
description:
'Read your personal driving preferences, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. These preferences apply to all your trips, not to other travellers. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.',
inputSchema: {},
'Read the shared driving preferences for one trip, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. Every member of this trip sees the same preferences. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.',
inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'settings', mode: 'read' },
access: { group: 'trips', mode: 'read' },
when,
})
async read(_input: Record<string, never>, ctx: McpContext) {
return ok({ settings: this.preferences.read(ctx.userId), scope: 'current_user' });
async read({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
return ok({ tripId, settings: this.preferences.read(tripId), scope: 'trip' });
}
@Tool({
name: 'update_roadtrip_settings',
description:
'Change personal driving preferences for all your trips. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.',
inputSchema: { settings: roadtripPreferencesUpdateSchema },
'Change the shared driving preferences for this trip. Changes apply to all its travellers and require permission to edit days. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.',
inputSchema: { tripId: z.number().int().positive(), settings: roadtripPreferencesUpdateSchema },
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'settings', mode: 'write' },
access: { group: 'trips', mode: 'write' },
when,
})
async update({ settings }: { settings: RoadtripPreferences }, ctx: McpContext) {
async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
return ok({ settings: this.preferences.update(ctx.userId, settings), scope: 'current_user' });
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
return ok({ tripId, settings: this.preferences.update(tripId, settings), scope: 'trip' });
}
}
@@ -1,5 +1,5 @@
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { SettingsService } from '../settings/settings.service';
import { HttpException, Injectable } from '@nestjs/common';
import {
ROADTRIP_PREFERENCE_KEYS,
@@ -11,12 +11,22 @@ import {
@Injectable()
export class RoadtripPreferencesService {
constructor(
private readonly settings: SettingsService,
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
) {}
read(userId: number): RoadtripPreferences {
const settings = this.settings.getUserSettings(userId);
read(tripId: number): RoadtripPreferences {
const settings: Record<string, unknown> = {};
for (const row of this.db.all<{ key: string; value: string }>(
'SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?',
tripId,
)) {
try {
settings[row.key] = JSON.parse(row.value);
} catch {
settings[row.key] = row.value;
}
}
const preferences: Record<string, unknown> = {};
for (const key of ROADTRIP_PREFERENCE_KEYS) {
const parsed = roadtripPreferencesSchema.shape[key].safeParse(settings[key]);
@@ -25,15 +35,24 @@ export class RoadtripPreferencesService {
return roadtripPreferencesSchema.parse(preferences);
}
update(userId: number, patch: RoadtripPreferences): RoadtripPreferences {
update(tripId: number, patch: RoadtripPreferences): RoadtripPreferences {
const validated = roadtripPreferencesUpdateSchema.parse(patch);
const next = { ...this.read(userId), ...validated };
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) {
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
}
this.settings.bulkUpsertSettings(userId, validated);
const saved = this.read(userId);
this.realtime.broadcastToUser(userId, { type: 'roadtripPreferences:changed', preferences: saved });
const saved = this.db.transaction(() => {
const next = { ...this.read(tripId), ...validated };
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) {
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
}
for (const [key, value] of Object.entries(validated)) {
this.db.run(
'INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES (?, ?, ?) ON CONFLICT(trip_id, key) DO UPDATE SET value = excluded.value',
tripId,
key,
JSON.stringify(value),
);
}
return this.read(tripId);
});
this.realtime.broadcast(String(tripId), 'roadtripPreferences:changed', { preferences: saved });
return saved;
}
}
+37 -17
View File
@@ -1,31 +1,51 @@
import { Module } from '@nestjs/common';
import { RoadtripController } from './roadtrip.controller';
import { RoadtripService } from './roadtrip.service';
import { RoadtripMcp } from './roadtrip.mcp';
import { AddonsModule } from '../addons/addons.module';
import { AuthModule } from '../auth/auth.module';
import { MapsModule } from '../maps/maps.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { PluginsRuntimeModule } from '../plugins/plugins-runtime.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { AddonsModule } from '../addons/addons.module';
import { DayBoundariesController } from './day-boundaries.controller';
import { DayBoundariesService } from './day-boundaries.service';
import { DayBoundariesMcp } from './day-boundaries.mcp';
import { SettingsModule } from '../settings/settings.module';
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
import { RoadtripPreferencesMcp } from './roadtrip-preferences.mcp';
import { RoadtripRouterService } from './roadtrip-router.service';
import { DayBoundariesController } from './day-boundaries.controller';
import { DayBoundariesMcp } from './day-boundaries.mcp';
import { DayBoundariesService } from './day-boundaries.service';
import { RoadtripPlanService } from './roadtrip-plan.service';
import { RoadtripPlanningMcp } from './roadtrip-planning.mcp';
import { PluginsRuntimeModule } from '../plugins/plugins-runtime.module';
import { MapsModule } from '../maps/maps.module';
import { RoadtripPreferencesController } from './roadtrip-preferences.controller';
import { RoadtripPreferencesMcp } from './roadtrip-preferences.mcp';
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
import { RoadtripRouterService } from './roadtrip-router.service';
import { RoadtripController } from './roadtrip.controller';
import { RoadtripMcp } from './roadtrip.mcp';
import { RoadtripService } from './roadtrip.service';
import { Module } from '@nestjs/common';
/** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */
@Module({
// McpShared brings the tool guards, Auth the demo check, Permissions the trip guard,
// Addons the enabled-check the MCP tools gate on (the controller has @RequireAddon).
imports: [McpSharedModule, PermissionsModule, AuthModule, AddonsModule, RealtimeModule, SettingsModule, PluginsRuntimeModule, MapsModule],
controllers: [RoadtripController, DayBoundariesController],
providers: [RoadtripService, RoadtripMcp, DayBoundariesService, DayBoundariesMcp, RoadtripPreferencesService, RoadtripPreferencesMcp, RoadtripRouterService, RoadtripPlanService, RoadtripPlanningMcp],
imports: [
McpSharedModule,
PermissionsModule,
AuthModule,
AddonsModule,
RealtimeModule,
SettingsModule,
PluginsRuntimeModule,
MapsModule,
],
controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController],
providers: [
RoadtripService,
RoadtripMcp,
DayBoundariesService,
DayBoundariesMcp,
RoadtripPreferencesService,
RoadtripPreferencesMcp,
RoadtripRouterService,
RoadtripPlanService,
RoadtripPlanningMcp,
],
exports: [RoadtripService],
})
export class RoadtripModule {}
+1
View File
@@ -624,6 +624,7 @@ export class TripsService {
}
}
this.db.prepare('INSERT INTO roadtrip_preferences (trip_id, key, value) SELECT ?, key, value FROM roadtrip_preferences WHERE trip_id = ?').run(newTripId, sourceTripId);
const oldBoundaries = this.db.prepare('SELECT * FROM roadtrip_day_boundaries WHERE trip_id = ?').all(sourceTripId) as {
day_number: number; from_assignment_id: number; to_assignment_id: number | null; fraction: number;
}[];
+89 -20
View File
@@ -11,14 +11,19 @@
* The addon ships disabled, so "off" is the state an existing install upgrades
* into. That is what makes it worth a test rather than a comment.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import request from 'supertest';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module';
import { seedUser, sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { seedUser, sessionCookie } from './harness';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -30,6 +35,9 @@ const { db } = vi.hoisted(() => {
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
avatar TEXT);`);
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, end_date TEXT);');
tmp.exec(
'CREATE TABLE roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(trip_id, key));',
);
tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);');
tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
day_number INTEGER, date TEXT, title TEXT, notes TEXT);`);
@@ -52,11 +60,15 @@ const { db } = vi.hoisted(() => {
vi.mock('../../src/db/database', () => ({
db,
canAccessTrip: (tripId: number | string, userId: number) =>
db.prepare(`
db
.prepare(
`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
`,
)
.get(userId, tripId, userId),
isOwner: () => false,
getPlaceWithTags: () => null,
closeDb: () => {},
@@ -64,16 +76,12 @@ vi.mock('../../src/db/database', () => ({
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
const ADDON_ID = 'roadtrip';
function setAddon(enabled: boolean): void {
db.prepare('INSERT INTO addons (id, enabled) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled')
.run(ADDON_ID, enabled ? 1 : 0);
db.prepare(
'INSERT INTO addons (id, enabled) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled',
).run(ADDON_ID, enabled ? 1 : 0);
}
describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
@@ -117,15 +125,71 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
setAddon(true);
});
afterAll(async () => { await app?.close(); });
afterAll(async () => {
await app?.close();
});
const cookie = () => sessionCookie(1);
describe('shared trip driving preferences', () => {
it('shares values with members and isolates other trips', async () => {
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run();
try {
await request(server)
.put('/api/trips/5/roadtrip/preferences')
.set('Cookie', cookie())
.send({ roadtrip_range_km: 120, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' })
.expect(200);
const member = await request(server)
.get('/api/trips/5/roadtrip/preferences')
.set('Cookie', sessionCookie(2))
.expect(200);
expect(member.body.preferences.roadtrip_range_km).toBe(120);
await request(server)
.put('/api/trips/5/roadtrip/preferences')
.set('Cookie', sessionCookie(2))
.send({ roadtrip_range_km: 160 })
.expect(200);
const owner = await request(server)
.get('/api/trips/5/roadtrip/preferences')
.set('Cookie', cookie())
.expect(200);
expect(owner.body.preferences.roadtrip_range_km).toBe(160);
const other = await request(server)
.get('/api/trips/6/roadtrip/preferences')
.set('Cookie', sessionCookie(2))
.expect(200);
expect(other.body.preferences).toEqual({});
checkPermission.mockReturnValue(false);
await request(server)
.put('/api/trips/5/roadtrip/preferences')
.set('Cookie', sessionCookie(2))
.send({ roadtrip_range_km: 999 })
.expect(403);
expect(
db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_range_km'").get(),
).toEqual({ value: '160' });
} finally {
db.prepare('DELETE FROM trip_members WHERE trip_id = 5 AND user_id = 2').run();
}
});
it('refuses strangers and invalid daily windows', async () => {
await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', sessionCookie(2)).expect(404);
await request(server)
.put('/api/trips/5/roadtrip/preferences')
.set('Cookie', cookie())
.send({ roadtrip_day_start: '18:00', roadtrip_day_end: '08:00' })
.expect(400);
});
});
describe('the addon gate', () => {
// Six routes, all of them. `@RequireAddon` is metadata and inert without the
// guard, and the guard is not global — so this block is the only thing that
// fails if somebody drops AddonGuard from the chain again.
const routes: [string, string, unknown?][] = [
['get', '/api/trips/5/roadtrip/preferences'],
['put', '/api/trips/5/roadtrip/preferences', { roadtrip_range_km: 120 }],
['get', '/api/trips/5/roadtrip/vias'],
['get', '/api/trips/5/roadtrip/days/3/vias'],
['post', '/api/trips/5/roadtrip/days/3/vias', { after_order_index: 0, lat: 53, lng: 10 }],
@@ -137,7 +201,8 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
it('ROADTRIP-E2E-001: answers 404 on every route while the addon is off', async () => {
setAddon(false);
for (const [method, url, body] of routes) {
const req = (request(server) as never as Record<string, (u: string) => request.Test>)[method](url)
const req = (request(server) as never as Record<string, (u: string) => request.Test>)
[method](url)
.set('Cookie', cookie());
const res = await (body ? req.send(body as object) : req);
expect(res.status, `${method.toUpperCase()} ${url}`).toBe(404);
@@ -224,8 +289,9 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
expect(moved.status).toBe(200);
expect(moved.body.via).toMatchObject({ lat: 54, lng: 11 });
expect((await request(server).delete(`/api/trips/5/roadtrip/days/3/vias/${id}`).set('Cookie', cookie())).status)
.toBe(200);
expect(
(await request(server).delete(`/api/trips/5/roadtrip/days/3/vias/${id}`).set('Cookie', cookie())).status,
).toBe(200);
const after = await request(server).get('/api/trips/5/roadtrip/days/3/vias').set('Cookie', cookie());
expect(after.body.vias).toEqual([]);
});
@@ -235,7 +301,10 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
.post('/api/trips/5/roadtrip/days/3/vias/batch')
.set('Cookie', cookie())
.send({
vias: [{ after_order_index: 0, lat: 53, lng: 10 }, { after_order_index: 0, lat: 53.5, lng: 10.5 }],
vias: [
{ after_order_index: 0, lat: 53, lng: 10 },
{ after_order_index: 0, lat: 53.5, lng: 10.5 },
],
track: { place_id: 10, stray_km: 1.5 },
});
expect(res.status).toBe(200);
@@ -0,0 +1,38 @@
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
describe('trip driving preferences migration', () => {
it('inherits owner values once, tolerates nulls and preserves trip overrides on replay', () => {
const db = new Database(':memory:');
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')",
);
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\"'), (1, 'roadtrip_day_end', NULL), (1, 'routing_base_url', '\"https://private.test\"')",
);
const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version;
db.prepare('UPDATE schema_version SET version = ?').run(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'");
db.prepare('UPDATE schema_version SET version = ?').run(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' });
} finally {
db.close();
}
});
});
@@ -13,7 +13,7 @@ import { describe, it, expect, vi } from 'vitest';
const ctx = { userId: 5 } as McpContext;
function setup() {
let settings: Record<string, unknown> = {
const settings: Record<string, unknown> = {
roadtrip_day_start: '08:00',
roadtrip_day_end: '10:00',
roadtrip_vehicle: 'electric',
@@ -24,12 +24,18 @@ function setup() {
};
const store = {
getUserSettings: vi.fn(() => settings),
bulkUpsertSettings: vi.fn((_id: number, patch: Record<string, unknown>) => {
settings = { ...settings, ...patch };
}),
};
const realtime = { broadcastToUser: vi.fn() };
const preferences = new RoadtripPreferencesService(store as never, realtime as never);
const realtime = { broadcast: vi.fn() };
const tripSettings = new Map<number, Record<string, unknown>>([[10, { ...settings }]]);
const preferenceDb = {
all: (_sql: string, tripId: number) =>
Object.entries(tripSettings.get(tripId) ?? {}).map(([key, value]) => ({ key, value: JSON.stringify(value) })),
run: vi.fn((_sql: string, tripId: number, key: string, value: string) =>
tripSettings.set(tripId, { ...tripSettings.get(tripId), [key]: JSON.parse(value) }),
),
transaction: (fn: () => unknown) => fn(),
};
const preferences = new RoadtripPreferencesService(preferenceDb as never, realtime as never);
const days = [{ id: 1, day_number: 1, title: null, date: '2026-09-11', default_transport_mode: 'driving' }];
const visits = [1, 2, 3].map((id) => ({
id,
@@ -82,25 +88,24 @@ function setup() {
roadtrip as never,
boundaries as never,
);
return { preferences, store, realtime, db, plans, router, visits, days, boundaries };
return { preferenceDb, preferences, store, realtime, db, plans, router, visits, days, boundaries };
}
describe('roadtrip preferences', () => {
it('reads only public driving preferences and never endpoint URLs or credentials', () => {
const s = setup();
expect(s.preferences.read(5)).toMatchObject({ roadtrip_range_km: 100 });
expect(JSON.stringify(s.preferences.read(5))).not.toMatch(/secret|private.example|routing_base_url/);
expect(s.preferences.read(10)).toMatchObject({ roadtrip_range_km: 100 });
expect(JSON.stringify(s.preferences.read(10))).not.toMatch(/secret|private.example|routing_base_url/);
});
it('validates the complete window before an atomic write and broadcasts only to its owner', () => {
it('validates the complete window before an atomic write and broadcasts to every member of its trip', () => {
const s = setup();
expect(() => s.preferences.update(5, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow();
expect(s.store.bulkUpsertSettings).not.toHaveBeenCalled();
s.preferences.update(5, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' });
expect(s.realtime.broadcastToUser).toHaveBeenCalledWith(5, {
type: 'roadtripPreferences:changed',
preferences: s.preferences.read(5),
expect(() => s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow();
expect(s.preferenceDb.run).not.toHaveBeenCalled();
s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' });
expect(s.realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripPreferences:changed', {
preferences: s.preferences.read(10),
});
expect(s.preferences.read(5).roadtrip_range_km).toBe(100);
expect(s.preferences.read(10).roadtrip_range_km).toBe(100);
});
it.each([
{ llm_api_key: 'x' },
@@ -114,12 +119,18 @@ describe('roadtrip preferences', () => {
});
it('allows clearing daily times and limits and refuses demo writes', async () => {
const s = setup();
s.preferences.update(5, { roadtrip_day_start: '', roadtrip_range_km: 0 });
expect(s.preferences.read(5).roadtrip_day_start).toBe('');
const mcp = new RoadtripPreferencesMcp(s.preferences, { isDemoUser: () => true } as never, {} as never);
s.store.bulkUpsertSettings.mockClear();
await mcp.update({ settings: { roadtrip_range_km: 300 } }, ctx);
expect(s.store.bulkUpsertSettings).not.toHaveBeenCalled();
s.preferences.update(10, { roadtrip_day_start: '', roadtrip_range_km: 0 });
expect(s.preferences.read(10).roadtrip_day_start).toBe('');
const mcp = new RoadtripPreferencesMcp(
s.preferences,
{ isDemoUser: () => true } as never,
{} as never,
s.db as never,
{} as never,
);
s.preferenceDb.run.mockClear();
await mcp.update({ tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx);
expect(s.preferenceDb.run).not.toHaveBeenCalled();
});
});
@@ -138,8 +149,8 @@ describe('browser-independent roadtrip calculation', () => {
it('previews settings without saving them', async () => {
const s = setup();
await s.plans.calculate(10, 5, { roadtrip_day_end: '20:00' });
expect(s.preferences.read(5).roadtrip_day_end).toBe('10:00');
expect(s.store.bulkUpsertSettings).not.toHaveBeenCalled();
expect(s.preferences.read(10).roadtrip_day_end).toBe('10:00');
expect(s.preferenceDb.run).not.toHaveBeenCalled();
});
it('checks trip access before reading or routing', async () => {
const s = setup();
@@ -212,7 +223,7 @@ describe('Roadtrip MCP registration and search', () => {
const addons = { isAddonEnabled: vi.fn(() => true) };
const registry = createTestRegistry(
[
new RoadtripPreferencesMcp(s.preferences, {} as never, addons as never),
new RoadtripPreferencesMcp(s.preferences, {} as never, addons as never, s.db as never, {} as never),
new RoadtripPlanningMcp(s.plans, {} as never, addons as never),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
@@ -223,8 +234,9 @@ describe('Roadtrip MCP registration and search', () => {
names.push(name);
},
};
registry.attach(registrar as never, { ...ctx, scopes: ['settings:read'] });
expect(names).toEqual(['get_roadtrip_settings']);
registry.attach(registrar as never, { ...ctx, scopes: ['trips:read'] });
expect(names).toContain('get_roadtrip_settings');
expect(names).not.toContain('update_roadtrip_settings');
names.length = 0;
addons.isAddonEnabled.mockReturnValue(false);
registry.attach(registrar as never, { ...ctx, scopes: null });