mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
refactor(roadtrip): roadtrip tables through repositories
This commit is contained in:
@@ -147,6 +147,78 @@ interface AssignmentTimeSortKyselyDB {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPL2's row, exactly as `RoadtripPlanService.context`'s old local `VisitRow`
|
||||
* interface declared it — the stay the visit stands for on its check-in day
|
||||
* (never `a.accommodation_id`, see the method docstring), and the checkout
|
||||
* day's `day_number`, both `null` for a visit with no linked stay.
|
||||
*/
|
||||
export interface RoadtripVisitRow {
|
||||
id: number;
|
||||
day_id: number;
|
||||
place_id: number;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
time: string | null;
|
||||
end_time: string | null;
|
||||
duration_minutes: number | null;
|
||||
end_day: number;
|
||||
leg_transport_mode: string | null;
|
||||
incoming_leg_transport_mode: string | null;
|
||||
stop_type: string | null;
|
||||
fill_percent: number | null;
|
||||
stay_id: number | null;
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
checkout_day: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `day_assignments`/`days`/`places`/`day_accommodations`'s shape for RPL2
|
||||
* (`listRoadtripVisits`), the same Kysely-typed-DB-interface shape
|
||||
* `AssignmentTimeSortKyselyDB` above uses. `days` is joined twice under two
|
||||
* aliases (`d`, `checkout`) — both resolve against this one `days` entry.
|
||||
*/
|
||||
interface RoadtripVisitsKyselyDB {
|
||||
day_assignments: {
|
||||
id: number;
|
||||
day_id: number;
|
||||
place_id: number;
|
||||
order_index: number | null;
|
||||
created_at: string | null;
|
||||
assignment_time: string | null;
|
||||
assignment_end_time: string | null;
|
||||
end_day: number;
|
||||
leg_transport_mode: string | null;
|
||||
incoming_leg_transport_mode: string | null;
|
||||
};
|
||||
days: {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
day_number: number;
|
||||
};
|
||||
places: {
|
||||
id: number;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
place_time: string | null;
|
||||
end_time: string | null;
|
||||
duration_minutes: number | null;
|
||||
stop_type: string | null;
|
||||
fill_percent: number | null;
|
||||
};
|
||||
day_accommodations: {
|
||||
id: number;
|
||||
place_id: number;
|
||||
start_day_id: number;
|
||||
end_day_id: number | null;
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
/** The DY1/DY3/AS1/AS3 projection's SELECT list, shared by all three query shapes below. */
|
||||
private assignmentWithPlaceSelect(platform: Platform) {
|
||||
@@ -624,4 +696,88 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
async setAccommodation(id: number, accommodation_id: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { accommodation_id });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3d Task 1 (`RoadtripPlanService`) — additive, per this task's own
|
||||
// file-ownership rule ("all additive methods on Days/Places/DayAssignments
|
||||
// repositories").
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* RPL2 (`roadtrip-plan.service.ts::context`'s `visits` read) — the
|
||||
* cluster's most dialect-specific SELECT (inventory §17b):
|
||||
*
|
||||
* ```sql
|
||||
* SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
|
||||
* COALESCE(a.assignment_time, p.place_time) AS time,
|
||||
* COALESCE(a.assignment_end_time, p.end_time) AS end_time,
|
||||
* p.duration_minutes, a.end_day,
|
||||
* a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent,
|
||||
* stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
|
||||
* FROM day_assignments a
|
||||
* JOIN days d ON d.id = a.day_id
|
||||
* JOIN places p ON p.id = a.place_id
|
||||
* LEFT JOIN day_accommodations stay
|
||||
* ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1)
|
||||
* LEFT JOIN days checkout ON checkout.id = stay.end_day_id
|
||||
* WHERE d.trip_id = ?
|
||||
* ORDER BY d.day_number, a.order_index, a.created_at
|
||||
* ```
|
||||
*
|
||||
* A correlated scalar subquery INSIDE a `LEFT JOIN … ON` clause — no
|
||||
* QueryBuilder shape expresses this (§17b), and no MikroORM relation
|
||||
* connects a place+check-in-day pair to its `day_accommodations` row (the
|
||||
* stay is matched by `place_id`+`start_day_id`, not by
|
||||
* `a.accommodation_id` — deliberately: the inventory's own RPL2 note),
|
||||
* so this is `this.kysely()` from the start, same escape-hatch order as
|
||||
* `reanchorToDay`/`effectiveStart`/`listForTimeSort` above. `days` is
|
||||
* joined twice under two different aliases (`d`, `checkout`) — ordinary
|
||||
* Kysely self-join aliasing, no separate interface entry needed since both
|
||||
* resolve against the same `days` table shape.
|
||||
*/
|
||||
async listRoadtripVisits(trip_id: number): Promise<RoadtripVisitRow[]> {
|
||||
const rows = await this.kysely<RoadtripVisitsKyselyDB>()
|
||||
.selectFrom('day_assignments as a')
|
||||
.innerJoin('days as d', 'd.id', 'a.day_id')
|
||||
.innerJoin('places as p', 'p.id', 'a.place_id')
|
||||
.leftJoin('day_accommodations as stay', (join) =>
|
||||
join.on('stay.id', '=', (eb) =>
|
||||
eb
|
||||
.selectFrom('day_accommodations as da2')
|
||||
.select('da2.id')
|
||||
.whereRef('da2.place_id', '=', 'p.id')
|
||||
.whereRef('da2.start_day_id', '=', 'd.id')
|
||||
.orderBy('da2.id', 'asc')
|
||||
.limit(1),
|
||||
),
|
||||
)
|
||||
.leftJoin('days as checkout', 'checkout.id', 'stay.end_day_id')
|
||||
.select((eb) => [
|
||||
'a.id as id',
|
||||
'a.day_id as day_id',
|
||||
'a.place_id as place_id',
|
||||
'p.name as name',
|
||||
'p.lat as lat',
|
||||
'p.lng as lng',
|
||||
eb.fn.coalesce('a.assignment_time', 'p.place_time').as('time'),
|
||||
eb.fn.coalesce('a.assignment_end_time', 'p.end_time').as('end_time'),
|
||||
'p.duration_minutes as duration_minutes',
|
||||
'a.end_day as end_day',
|
||||
'a.leg_transport_mode as leg_transport_mode',
|
||||
'a.incoming_leg_transport_mode as incoming_leg_transport_mode',
|
||||
'p.stop_type as stop_type',
|
||||
'p.fill_percent as fill_percent',
|
||||
'stay.id as stay_id',
|
||||
'stay.check_in as check_in',
|
||||
'stay.check_out as check_out',
|
||||
'checkout.day_number as checkout_day',
|
||||
])
|
||||
.where('d.trip_id', '=', trip_id)
|
||||
.orderBy('d.day_number', 'asc')
|
||||
.orderBy('a.order_index', 'asc')
|
||||
.orderBy('a.created_at', 'asc')
|
||||
.$castTo<RoadtripVisitRow>()
|
||||
.execute();
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,15 @@ export interface DayIdAndNumberRow {
|
||||
day_number: number;
|
||||
}
|
||||
|
||||
/** RPL1's projection: `SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number`. */
|
||||
export interface PlanDayRow {
|
||||
id: number;
|
||||
day_number: number;
|
||||
date: string | null;
|
||||
title: string | null;
|
||||
default_transport_mode: string | null;
|
||||
}
|
||||
|
||||
export class DaysRepository extends TrekRepository<Days> {
|
||||
/** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */
|
||||
async listByTrip(trip_id: number): Promise<DayRow[]> {
|
||||
@@ -210,6 +219,25 @@ export class DaysRepository extends TrekRepository<Days> {
|
||||
return row ?? undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3d Task 1 (`RoadtripPlanService`) — additive, per this task's own
|
||||
// file-ownership rule ("all additive methods on Days/Places/DayAssignments
|
||||
// repositories").
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* RPL1 (`roadtrip-plan.service.ts::context`) — `SELECT id, day_number,
|
||||
* date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY
|
||||
* day_number`.
|
||||
*/
|
||||
async listPlanDays(trip_id: number): Promise<PlanDayRow[]> {
|
||||
return await this.qb('d')
|
||||
.select(['d.id', 'd.day_number', 'd.date', 'd.title', 'd.default_transport_mode'])
|
||||
.where({ trip: trip_id })
|
||||
.orderBy({ day_number: 'asc' })
|
||||
.execute<PlanDayRow[]>('all', false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3c Task 3 (`AssignmentsService`) — appended per the task's own
|
||||
// file-ownership rule ("additive methods on Days/Places repositories if
|
||||
|
||||
@@ -832,4 +832,37 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
fill_percent: input.fill_percent,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3d Task 1 (`RoadtripService`/`ChargingService`) — additive, per this
|
||||
// task's own file-ownership rule ("all additive methods on Days/Places/
|
||||
// DayAssignments repositories").
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* RT13 (`RoadtripService.trackExists`) — `SELECT id FROM places WHERE id =
|
||||
* ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry !=
|
||||
* ''`. Two `.andWhere()` calls (rule 23 — typed conditions, not an inline
|
||||
* SQL string) rather than one `$and` array: the two operators (`$ne: null`
|
||||
* for `IS NOT NULL`, `$ne: ''` for `!= ''`) target the SAME property, so a
|
||||
* single filter object literal cannot carry both keys.
|
||||
*/
|
||||
async isTrackInTrip(id: number, trip_id: number): Promise<boolean> {
|
||||
const row = await this.qb('p')
|
||||
.select(['p.id'])
|
||||
.where({ id, trip: trip_id })
|
||||
.andWhere({ route_geometry: { $ne: null } })
|
||||
.andWhere({ route_geometry: { $ne: '' } })
|
||||
.execute<{ id: number } | undefined>('get', false);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** CH1 (`ChargingService.read`) — `SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?`. */
|
||||
async findChargingProbe(id: number, trip_id: number): Promise<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined> {
|
||||
const row = await this.qb('p')
|
||||
.select(['p.name', 'p.lat', 'p.lng', 'p.stop_type'])
|
||||
.where({ id, trip: trip_id })
|
||||
.execute<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined>('get', false);
|
||||
return row ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,65 @@
|
||||
import type { RoadtripDayBoundary } from '@trek/shared';
|
||||
import type { RoadtripDayBoundaries } from '../entities/RoadtripDayBoundaries.entity';
|
||||
import { columnRef } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class RoadtripDayBoundariesRepository extends TrekRepository<RoadtripDayBoundaries> {}
|
||||
/**
|
||||
* `roadtrip_day_boundaries` — where a manual day ending sits (a stop, or a
|
||||
* fraction along the driving leg between two consecutive stops), overriding
|
||||
* the automatic day-ending rule daily travel times uses.
|
||||
*/
|
||||
export class RoadtripDayBoundariesRepository extends TrekRepository<RoadtripDayBoundaries> {
|
||||
/**
|
||||
* RB1 — `SELECT day_number, from_assignment_id, to_assignment_id, fraction
|
||||
* FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number`.
|
||||
*
|
||||
* `columnRef` for `from_assignment_id`/`to_assignment_id` — both are
|
||||
* `persist(false)` mirrors of the `fromAssignment`/`toAssignment`
|
||||
* relations, and selecting either bare (`'b.from_assignment_id'`) drops it
|
||||
* from the result silently, EVEN with no active join on that relation
|
||||
* (verified directly here, not assumed from `PlaceRatingsRepository
|
||||
* .listForPlaces`'s narrower, join-only framing of the same trap).
|
||||
*/
|
||||
async listForTrip(trip_id: number): Promise<RoadtripDayBoundary[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return await this.qb('b')
|
||||
.select(['b.day_number', columnRef(platform, 'b.from_assignment_id'), columnRef(platform, 'b.to_assignment_id'), 'b.fraction'])
|
||||
.where({ trip: trip_id })
|
||||
.orderBy({ day_number: 'asc' })
|
||||
.execute<RoadtripDayBoundary[]>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* RB3 — `INSERT INTO roadtrip_day_boundaries (trip_id, day_number,
|
||||
* from_assignment_id, to_assignment_id, fraction) VALUES (?×5) ON CONFLICT
|
||||
* (trip_id, day_number) DO UPDATE SET from_assignment_id =
|
||||
* excluded.from_assignment_id, to_assignment_id = excluded.to_assignment_id,
|
||||
* fraction = excluded.fraction`.
|
||||
*
|
||||
* `onConflictFields: ['trip', 'day_number']` — the composite PK the
|
||||
* migration and the entity's own `[PrimaryKeyProp]?: ['trip',
|
||||
* 'day_number']` both declare; `onConflictMergeFields` names the three
|
||||
* relation/scalar properties the legacy SET list names (`fromAssignment`/
|
||||
* `toAssignment`, not their `persist(false)` `_id` mirrors —
|
||||
* `OauthConsentsRepository.upsertGrant`'s precedent), so a conflict never
|
||||
* touches `trip`/`day_number` themselves (the key) or any future column
|
||||
* this entity gains.
|
||||
*/
|
||||
async upsertBoundary(trip_id: number, boundary: RoadtripDayBoundary): Promise<void> {
|
||||
await this.upsert(
|
||||
{
|
||||
trip: trip_id,
|
||||
day_number: boundary.day_number,
|
||||
fromAssignment: boundary.from_assignment_id,
|
||||
toAssignment: boundary.to_assignment_id,
|
||||
fraction: boundary.fraction,
|
||||
},
|
||||
{ onConflictFields: ['trip', 'day_number'], onConflictAction: 'merge', onConflictMergeFields: ['fromAssignment', 'toAssignment', 'fraction'] },
|
||||
);
|
||||
}
|
||||
|
||||
/** RB4 — `DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?`, silent on a miss. */
|
||||
async deleteForDay(trip_id: number, day_number: number): Promise<void> {
|
||||
await this.nativeDelete({ trip: trip_id, day_number });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,71 @@
|
||||
import type { RoadtripDayTrack } from '@trek/shared';
|
||||
import type { RoadtripDayTracks } from '../entities/RoadtripDayTracks.entity';
|
||||
import { columnRef } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class RoadtripDayTracksRepository extends TrekRepository<RoadtripDayTracks> {}
|
||||
/**
|
||||
* `roadtrip_day_tracks` — which recorded track (a `places` row with route
|
||||
* geometry) a day's drive was fitted to, and how far the fitted route still
|
||||
* strayed from it.
|
||||
*/
|
||||
export class RoadtripDayTracksRepository extends TrekRepository<RoadtripDayTracks> {
|
||||
/** RT7 — `DELETE FROM roadtrip_day_tracks WHERE day_id = ?` (`createMany`'s explicit `track: null` clear). */
|
||||
async deleteForDay(day_id: number): Promise<void> {
|
||||
await this.nativeDelete({ day: day_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* RT8 — `INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km)
|
||||
* VALUES (?, ?, ?) ON CONFLICT(day_id) DO UPDATE SET place_id =
|
||||
* excluded.place_id, stray_km = excluded.stray_km`.
|
||||
*
|
||||
* `onConflictFields: ['day']` — the RELATION property, not the `day_id`
|
||||
* scalar mirror (`persist(false)`, not a real column MikroORM's upsert
|
||||
* builder can target) — the same naming `PlaceRatingsRepository
|
||||
* .upsertRating`/`TripMembersRepository`'s own composite-key upserts use
|
||||
* for a relation-backed conflict target. `RoadtripDayTracks.day` is this
|
||||
* table's PRIMARY KEY (a nullable one-to-one, per Task 0's `RULE12`
|
||||
* finding — ruled NOT a generator defect, matching the `VacayUserSettings`
|
||||
* precedent), so the single-element list targets the table's actual PK,
|
||||
* `roadtrip_day_tracks(day_id)`. Proven, not assumed: `RoadtripDayTracks
|
||||
* .repository.test.ts` (`UPSERTTRACKREPO-*`) upserts the SAME `day_id`
|
||||
* twice with different `place_id`/`stray_km` and asserts exactly one row
|
||||
* survives with the SECOND call's values — if `onConflictFields: ['day']`
|
||||
* resolved to anything other than the physical `day_id` column, SQLite's
|
||||
* own `ON CONFLICT` target-matching would either throw (no matching
|
||||
* unique index) or let the second call's row collide on the real PK and
|
||||
* raise `UNIQUE constraint failed`, so the test is a genuine proof, not a
|
||||
* behavioural coincidence. `created_at` is named in neither
|
||||
* `onConflictMergeFields` NOR the insert payload beyond its own default,
|
||||
* so a conflict never refreshes it (pinned — the legacy statement's SET
|
||||
* list never named it either).
|
||||
*/
|
||||
async upsertTrack(day_id: number, place_id: number, stray_km: number | null): Promise<void> {
|
||||
await this.upsert(
|
||||
{ day: day_id, place: place_id, stray_km },
|
||||
{ onConflictFields: ['day'], onConflictAction: 'merge', onConflictMergeFields: ['place', 'stray_km'] },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RT12 — `SELECT t.day_id, t.place_id, t.stray_km FROM roadtrip_day_tracks
|
||||
* t JOIN days d ON d.id = t.day_id WHERE d.trip_id = ? ORDER BY t.day_id`.
|
||||
*
|
||||
* `columnRef` for both `day_id` (actively joined via `t.day`) and
|
||||
* `place_id` (not joined here, but selected the same defensive way for
|
||||
* consistency with every other persist(false) mirror in this cluster —
|
||||
* `PlaceRatingsRepository.listForPlaces`'s docstring is the verified trap
|
||||
* this sidesteps). Ordered by `d.id` (`= t.day_id` by the join condition)
|
||||
* rather than the mirror column itself, the same reason
|
||||
* `RoadtripViasRepository.listForTrip` orders by `d.id`.
|
||||
*/
|
||||
async listForTrip(trip_id: number): Promise<RoadtripDayTrack[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return await this.qb('t')
|
||||
.join('t.day', 'd')
|
||||
.select([columnRef(platform, 't.day_id'), columnRef(platform, 't.place_id'), 't.stray_km'])
|
||||
.where({ 'd.trip': trip_id })
|
||||
.orderBy({ 'd.id': 'asc' })
|
||||
.execute<RoadtripDayTrack[]>('all', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
import type { RoadtripPreferences } from '../entities/RoadtripPreferences.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class RoadtripPreferencesRepository extends TrekRepository<RoadtripPreferences> {}
|
||||
/** RPF1's raw row — the JSON parse (with its raw-string fallback) stays in the service. */
|
||||
export interface RoadtripPreferenceRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `roadtrip_preferences` — a trip-scoped key/value store, one row per
|
||||
* preference key, values JSON-encoded in the service.
|
||||
*/
|
||||
export class RoadtripPreferencesRepository extends TrekRepository<RoadtripPreferences> {
|
||||
/** RPF1 — `SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?` (no ORDER BY, matching the legacy statement). */
|
||||
async listForTrip(trip_id: number): Promise<RoadtripPreferenceRow[]> {
|
||||
return await this.qb('r')
|
||||
.select(['r.key', 'r.value'])
|
||||
.where({ trip: trip_id })
|
||||
.execute<RoadtripPreferenceRow[]>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* RPF3 — `INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES
|
||||
* (?, ?, ?) ON CONFLICT(trip_id, key) DO UPDATE SET value = excluded.value`.
|
||||
*
|
||||
* `onConflictFields: ['trip', 'key']` — the composite PK
|
||||
* (`[PrimaryKeyProp]?: ['trip', 'key']`); `onConflictMergeFields:
|
||||
* ['value']` matches the legacy SET list exactly (never touches `trip`/
|
||||
* `key`, the key itself).
|
||||
*/
|
||||
async upsertValue(trip_id: number, key: string, value: string): Promise<void> {
|
||||
await this.upsert({ trip: trip_id, key, value }, { onConflictFields: ['trip', 'key'], onConflictAction: 'merge', onConflictMergeFields: ['value'] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,175 @@
|
||||
import type { RoadtripVia } from '@trek/shared';
|
||||
import type { AnchoredVia } from '@trek/shared/roadtrip';
|
||||
import type { RoadtripVias } from '../entities/RoadtripVias.entity';
|
||||
import { columnRef, maxOf } from '../dialect/sql-functions';
|
||||
import { toRow, type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class RoadtripViasRepository extends TrekRepository<RoadtripVias> {}
|
||||
const _roadtripViaRowKeys: AssertRowKeys<RoadtripVia, RoadtripVias> = true;
|
||||
|
||||
/** RT18/AC13 — the OLD anchor a re-anchor reads before any write. */
|
||||
export interface RoadtripViaAnchorRow {
|
||||
id: number;
|
||||
after_order_index: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
/** RT21 — the same day's vias without `sequence` (the renumber pass reads it separately). */
|
||||
export interface RoadtripViaLegRow {
|
||||
id: number;
|
||||
after_order_index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `roadtrip_vias` — the points a day's drive is routed through without stopping.
|
||||
*
|
||||
* `RT2` (`listForDay`) is the ONE copy of that projection: `AccommodationsService`
|
||||
* (AC16, Task 3) and `AssignmentsService` (AS23, `listDayVias`) both call it now,
|
||||
* retiring the "deliberate duplicate" the legacy `AssignmentsService` docstring
|
||||
* used to carry.
|
||||
*/
|
||||
export class RoadtripViasRepository extends TrekRepository<RoadtripVias> {
|
||||
/** RT2/AC16/AS23 — `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE day_id = ? ORDER BY after_order_index, sequence, id`. */
|
||||
async listForDay(day_id: number): Promise<RoadtripVia[]> {
|
||||
const vias = await this.find({ day: day_id }, { orderBy: { after_order_index: 'asc', sequence: 'asc', id: 'asc' } });
|
||||
return vias.map((v) => toRow(v) as RoadtripVia);
|
||||
}
|
||||
|
||||
/**
|
||||
* RT3 — `SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng,
|
||||
* v.created_at FROM roadtrip_vias v JOIN days d ON d.id = v.day_id WHERE
|
||||
* d.trip_id = ? ORDER BY v.day_id, v.after_order_index, v.sequence, v.id`.
|
||||
*
|
||||
* Ordered by `d.id` rather than the joined table's own `v.day_id` (always the
|
||||
* same value, by the join condition): `v.day_id` is a `persist(false)` mirror
|
||||
* of the `day` relation and selecting or ordering by it directly, alongside an
|
||||
* active join on that same relation, is the exact trap `PlaceRatingsRepository
|
||||
* .listForPlaces`'s docstring documents (the column silently aliases off the
|
||||
* JOINED entity instead) — `columnRef` below sidesteps it for the SELECT list
|
||||
* the same way `AssignmentWithPlaceRow`'s `category_id` does; ordering by
|
||||
* `d.id` sidesteps it for the ORDER BY without needing a third mechanism.
|
||||
*/
|
||||
async listForTrip(trip_id: number): Promise<RoadtripVia[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return await this.qb('v')
|
||||
.join('v.day', 'd')
|
||||
.select(['v.id', columnRef(platform, 'v.day_id'), 'v.after_order_index', 'v.sequence', 'v.lat', 'v.lng', 'v.created_at'])
|
||||
.where({ 'd.trip': trip_id })
|
||||
.orderBy({ 'd.id': 'asc', 'v.after_order_index': 'asc', 'v.sequence': 'asc', 'v.id': 'asc' })
|
||||
.execute<RoadtripVia[]>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* RT4/RT10 — `SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM
|
||||
* roadtrip_vias WHERE day_id = ? AND after_order_index = ?`, read as a plain
|
||||
* `MAX()` (`maxOf`) folded to the same result in JS: no rows (or an
|
||||
* all-`NULL` `sequence`, impossible here since the column is `NOT NULL
|
||||
* DEFAULT 0`) reads back `null`, and `(null ?? -1) + 1 === 0`, matching
|
||||
* `COALESCE(MAX(sequence) + 1, 0)`'s own null branch; a real max `m` reads
|
||||
* back `m`, and `(m ?? -1) + 1 === m + 1`, matching `MAX(sequence) + 1`.
|
||||
*/
|
||||
async nextSequence(day_id: number, after_order_index: number): Promise<number> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.qb('v')
|
||||
.select([maxOf(platform, 'v.sequence', 'max_seq')])
|
||||
.where({ day: day_id, after_order_index })
|
||||
.execute<{ max_seq: number | null } | undefined>('get', false);
|
||||
return (row?.max_seq ?? -1) + 1;
|
||||
}
|
||||
|
||||
/** RT5/RT11/TP43 — `INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?×5)`, returning the new id. */
|
||||
async insertVia(input: { day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }): Promise<number> {
|
||||
return await this.insert({
|
||||
day: input.day_id,
|
||||
after_order_index: input.after_order_index,
|
||||
sequence: input.sequence,
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
});
|
||||
}
|
||||
|
||||
/** RT9 — `DELETE FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?` (one leg's own vias, `replace_legs`). */
|
||||
async deleteLeg(day_id: number, after_order_index: number): Promise<void> {
|
||||
await this.nativeDelete({ day: day_id, after_order_index });
|
||||
}
|
||||
|
||||
/** RT14 — `SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?`, the scoping check `move`'s two un-transacted UPDATEs (R7) rely on. */
|
||||
async existsInDay(id: number, day_id: number): Promise<boolean> {
|
||||
const row = await this.qb('v')
|
||||
.select(['v.id'])
|
||||
.where({ id, day: day_id })
|
||||
.execute<{ id: number } | undefined>('get', false);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* RT15 — `UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?`. No
|
||||
* `day_id` scoping, matching the legacy statement exactly (R7 — an
|
||||
* un-transacted check-then-act behind {@link existsInDay}; flagged, not
|
||||
* fixed).
|
||||
*/
|
||||
async moveCoordinates(id: number, lat: number, lng: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { lat, lng });
|
||||
}
|
||||
|
||||
/** RT16 — `UPDATE roadtrip_vias SET lat = ?, lng = ?, after_order_index = ? WHERE id = ?`. Same R7 scoping note as {@link moveCoordinates}. */
|
||||
async moveCoordinatesAndAnchor(id: number, lat: number, lng: number, after_order_index: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { lat, lng, after_order_index });
|
||||
}
|
||||
|
||||
/** RT18/AC13 — `SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?` (no ORDER BY — matches the legacy statement). */
|
||||
async listAnchors(day_id: number): Promise<RoadtripViaAnchorRow[]> {
|
||||
return await this.qb('v')
|
||||
.select(['v.id', 'v.after_order_index', 'v.sequence'])
|
||||
.where({ day: day_id })
|
||||
.execute<RoadtripViaAnchorRow[]>('all', false);
|
||||
}
|
||||
|
||||
/** RT21 — `SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ?` (no ORDER BY — the caller's own JS sort is total, §18's RT21 note). */
|
||||
async listLegs(day_id: number): Promise<RoadtripViaLegRow[]> {
|
||||
return await this.qb('v')
|
||||
.select(['v.id', 'v.after_order_index'])
|
||||
.where({ day: day_id })
|
||||
.execute<RoadtripViaLegRow[]>('all', false);
|
||||
}
|
||||
|
||||
/** RT19/AS21 — `DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?`, silent on a miss (`reanchor`'s per-id remove). */
|
||||
async deleteInDay(id: number, day_id: number): Promise<void> {
|
||||
await this.nativeDelete({ id, day: day_id });
|
||||
}
|
||||
|
||||
/** RT23 — the same statement as {@link deleteInDay}, but the caller (`remove`) needs the row count to answer 404 on a miss. */
|
||||
async deleteInDayCounted(id: number, day_id: number): Promise<number> {
|
||||
return await this.nativeDelete({ id, day: day_id });
|
||||
}
|
||||
|
||||
/** RT20/AC15/AS22 — `UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?`, silent on a miss. */
|
||||
async setAnchor(id: number, day_id: number, after_order_index: number): Promise<void> {
|
||||
await this.nativeUpdate({ id, day: day_id }, { after_order_index });
|
||||
}
|
||||
|
||||
/** RT22 — `UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?`, every row unconditionally (unlike AC17). */
|
||||
async setSequence(id: number, day_id: number, sequence: number): Promise<void> {
|
||||
await this.nativeUpdate({ id, day: day_id }, { sequence });
|
||||
}
|
||||
|
||||
/** RT24 — `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?`. */
|
||||
async findById(id: number): Promise<RoadtripVia | null> {
|
||||
const via = await this.findOne({ id });
|
||||
return via ? (toRow(via) as RoadtripVia) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AS20 (`AssignmentsService.reanchorVias`) — `SELECT id, after_order_index,
|
||||
* lat, lng FROM roadtrip_vias WHERE day_id = ?`, the `AnchoredVia` shape
|
||||
* `reanchorByStopOrder` (`@trek/shared/roadtrip`) reads — a different
|
||||
* projection from {@link listAnchors} (which carries `sequence`, not
|
||||
* `lat`/`lng`), so it stays its own method rather than reusing one.
|
||||
*/
|
||||
async listForReanchor(day_id: number): Promise<AnchoredVia[]> {
|
||||
return await this.qb('v')
|
||||
.select(['v.id', 'v.after_order_index', 'v.lat', 'v.lng'])
|
||||
.where({ day: day_id })
|
||||
.execute<AnchoredVia[]>('all', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AssignmentParticipants } from '../../db/entities/AssignmentParticipants
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
|
||||
|
||||
/**
|
||||
* The assignments SERVICE, split from the controller/MCP/RPC surfaces (the
|
||||
@@ -21,13 +22,14 @@ import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
* none of the four service imports reaches the days or places domains.
|
||||
*
|
||||
* `MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days,
|
||||
* Places, TripMembers])` registers `DayAssignmentsRepository`/
|
||||
* Places, TripMembers, RoadtripVias])` registers `DayAssignmentsRepository`/
|
||||
* `AssignmentParticipantsRepository`/`DaysRepository`/`PlacesRepository`/
|
||||
* `TripMembersRepository` for `AssignmentsService`'s `@InjectRepository`
|
||||
* constructor params (Plan 3c Task 3) — the entity classes only, not the
|
||||
* `DaysModule`/`PlacesModule` modules themselves, so the loop this module's
|
||||
* docstring already avoids stays avoided (`DaysModule`'s own precedent for
|
||||
* pulling in `Trips` the same way).
|
||||
* `TripMembersRepository`/`RoadtripViasRepository` for `AssignmentsService`'s
|
||||
* `@InjectRepository` constructor params (Plan 3c Task 3; `RoadtripVias`
|
||||
* added by Plan 3d Task 1 for AS20–AS23) — the entity classes only, not the
|
||||
* `DaysModule`/`PlacesModule`/`RoadtripModule` modules themselves, so the
|
||||
* loop this module's docstring already avoids stays avoided (`DaysModule`'s
|
||||
* own precedent for pulling in `Trips` the same way).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -35,7 +37,7 @@ import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
QueryHelpersModule,
|
||||
JourneyDomainModule,
|
||||
RealtimeModule,
|
||||
MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days, Places, TripMembers]),
|
||||
MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days, Places, TripMembers, RoadtripVias]),
|
||||
],
|
||||
providers: [AssignmentsService],
|
||||
exports: [AssignmentsService],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
|
||||
import { isEmptyReanchoring, reanchorByStopOrder, type AnchoredVia } from '@trek/shared/roadtrip';
|
||||
import { isEmptyReanchoring, reanchorByStopOrder } from '@trek/shared/roadtrip';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
@@ -21,6 +21,8 @@ import { Places } from '../../db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
|
||||
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
|
||||
import type { RoadtripViasRepository } from '../../db/repositories/RoadtripVias.repository';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
@@ -64,8 +66,12 @@ function sortMinutes(time: string | null): number | null {
|
||||
* The day-assignment/participant SQL now lives in `DayAssignmentsRepository`/
|
||||
* `AssignmentParticipantsRepository` (Plan 3c Task 3, consuming Task 2's
|
||||
* `findWithPlaceAndCategory`/`listForDay` projection unchanged for AS1/AS3);
|
||||
* AS20–AS23 (`roadtrip_vias`) stay raw on `DatabaseService`, `// ASn — Plan
|
||||
* 3d` marked — that table belongs to Plan 3d. AS5 (`placeExists`) called
|
||||
* AS20–AS23 (`roadtrip_vias`) now live in `RoadtripViasRepository` (Plan 3d
|
||||
* Task 1) — AS20/AS21/AS22 convert inside `updateTime`'s existing
|
||||
* transaction (legacy order, R7's non-transactional class does not apply
|
||||
* here since these three already ran inside a transaction), and AS23
|
||||
* (`listDayVias`) is retired in favour of the repository's own `listForDay`
|
||||
* (RT2's one copy). AS5 (`placeExists`) called
|
||||
* `PlacesRepository.existsInTrip` through Task 4's follow-up, once that file
|
||||
* was free (it moved off an inlined `qb()` read this class carried for one
|
||||
* session while `Places.repository.ts` was another task's exclusive file).
|
||||
@@ -100,6 +106,7 @@ export class AssignmentsService {
|
||||
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
@InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository,
|
||||
@InjectRepository(RoadtripVias) private readonly roadtripViasRepo: RoadtripViasRepository,
|
||||
) {}
|
||||
|
||||
async verifyTripAccess(tripId: string | number, userId: number) {
|
||||
@@ -374,7 +381,10 @@ export class AssignmentsService {
|
||||
return await this.sortDayByTime(stored.day_id);
|
||||
});
|
||||
|
||||
const vias = sorted?.viasMoved ? { dayId: sorted.dayId, vias: await this.listDayVias(sorted.dayId) } : null;
|
||||
// AS23 — `RoadtripViasRepository.listForDay`, the one copy of this projection
|
||||
// (RT2/AC16); the "deliberate duplicate" the legacy docstring warned against
|
||||
// dedupe-ing is gone now that a repository is the one place to inject it from.
|
||||
const vias = sorted?.viasMoved ? { dayId: sorted.dayId, vias: await this.roadtripViasRepo.listForDay(sorted.dayId) } : null;
|
||||
|
||||
return {
|
||||
assignment: await this.getAssignmentWithPlace(idNum),
|
||||
@@ -438,38 +448,21 @@ export class AssignmentsService {
|
||||
// is right for a stop the sort made last and wrong for one that was last already.
|
||||
const lastAt = previousIds.length - 1;
|
||||
const seam = previousIds[lastAt] === nextIds[lastAt] ? lastAt : null;
|
||||
// AS20 — Plan 3d (`roadtrip_vias`)
|
||||
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId)
|
||||
// AS20 — `RoadtripViasRepository.listForReanchor`.
|
||||
const vias = (await this.roadtripViasRepo.listForReanchor(dayId))
|
||||
.filter(via => via.after_order_index !== seam);
|
||||
const plan = reanchorByStopOrder(vias, previousIds, nextIds);
|
||||
for (const viaId of plan.remove) {
|
||||
// AS21 — Plan 3d
|
||||
this.dbs.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
|
||||
// AS21 — `RoadtripViasRepository.deleteInDay` (= RT19).
|
||||
await this.roadtripViasRepo.deleteInDay(viaId, dayId);
|
||||
}
|
||||
for (const via of plan.vias) {
|
||||
// AS22 — Plan 3d
|
||||
this.dbs.run('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?', via.after_order_index, via.id, dayId);
|
||||
// AS22 — `RoadtripViasRepository.setAnchor` (= RT20).
|
||||
await this.roadtripViasRepo.setAnchor(via.id, dayId, via.after_order_index);
|
||||
}
|
||||
return !isEmptyReanchoring(plan);
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's vias in the shape the road trip routes broadcast them. RoadtripService
|
||||
* has this query too, but its module imports this one, so it cannot be injected here.
|
||||
*
|
||||
* AS23 — Plan 3d (`roadtrip_vias`): a deliberate duplicate of RoadtripService's
|
||||
* own query, per the legacy docstring's own note — do not dedupe.
|
||||
*/
|
||||
private async listDayVias(dayId: number): Promise<RoadtripVia[]> {
|
||||
return this.dbs.all<RoadtripVia>(
|
||||
`SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
|
||||
FROM roadtrip_vias
|
||||
WHERE day_id = ?
|
||||
ORDER BY after_order_index, sequence, id`,
|
||||
dayId,
|
||||
);
|
||||
}
|
||||
|
||||
/** AS24 — `DayAssignmentsRepository.setEndDay`. */
|
||||
async setEndDay(id: string | number, endDay: boolean) {
|
||||
const idNum = toRowId(id)!;
|
||||
|
||||
@@ -5,25 +5,27 @@ import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { ChargingService } from './charging.service';
|
||||
import { answeringRefusals } from './roadtrip-mcp.helpers';
|
||||
|
||||
@McpController()
|
||||
export class ChargingMcp {
|
||||
constructor(private readonly charging: ChargingService, private readonly db: DatabaseService, readonly addons: AddonsService) {}
|
||||
constructor(private readonly charging: ChargingService, @InjectRepository(Trips) private readonly tripsRepo: TripsRepository, readonly addons: AddonsService) {}
|
||||
@Tool({ name: 'get_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a saved charging stop. No API key required. Coverage varies by region and operator. Unknown or stale status is not available capacity. Prices can have conditions and extra fees and are not personalized roaming quotes.',
|
||||
inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
|
||||
async read({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
return answeringRefusals(async () => ok(await this.charging.read(tripId, placeId)));
|
||||
}
|
||||
@Tool({ name: 'lookup_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a charging station by coordinate, before it is added to a trip. Needs a trip only for access control. No API key required. Every caveat of get_roadtrip_charging_info applies unchanged: coverage varies by region and operator, unknown or stale status is not available capacity, prices are gross per-component figures that can carry conditions and extra fees and are not personalized roaming quotes. Report the source, licence and timestamp alongside any figure from it. Charging power, socket types and socket counts are not in this answer; they come from the place search.',
|
||||
inputSchema: { tripId: z.number().int().positive(), ...chargingLookupSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
|
||||
async lookup({ tripId, lat, lng, name }: { tripId: number; lat: number; lng: number; name: string }, ctx: McpContext) {
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
return ok(await this.charging.lookup(lat, lng, name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { ChargingInfo } from '@trek/shared';
|
||||
import { z } from 'zod';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { readCappedJson } from '../../utils/cappedFetch';
|
||||
import { chargingLocation, chargingSource, chargingTariff, matchChargingLocation, normalizeCharging, type ChargingSource, type ChargingTariff } from './charging.helpers';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
|
||||
/** The shape of "we cannot say", exported so nothing has to restate its thirteen fields. */
|
||||
export const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false });
|
||||
@@ -18,9 +20,10 @@ export class ChargingService {
|
||||
private cached = new Map<string, { expires: number; value: Promise<ChargingInfo> }>();
|
||||
private sources?: { expires: number; value: Promise<ChargingSource[]> };
|
||||
private tariffs = new Map<string, { expires: number; value: Promise<ChargingTariff[]> }>();
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(@InjectRepository(Places) private readonly placesRepo: PlacesRepository) {}
|
||||
/** CH1 — `PlacesRepository.findChargingProbe` (trip-scoped). */
|
||||
async read(tripId: number, placeId: number) {
|
||||
const place = this.db.get<{ name: string; lat: number | null; lng: number | null; stop_type: string }>('SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
|
||||
const place = await this.placesRepo.findChargingProbe(placeId, tripId);
|
||||
if (!place) throw new HttpException({ error: 'Place not found' }, 404);
|
||||
if (place.stop_type !== 'charging' || place.lat == null || place.lng == null) return empty('unknown');
|
||||
return this.lookup(place.lat, place.lng, place.name);
|
||||
|
||||
@@ -2,7 +2,9 @@ import { z } from 'zod';
|
||||
import { MAX_TRIP_DAYS, roadtripDayBoundarySchema, type RoadtripDayBoundary } from '@trek/shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, ok, type McpContext } from '../../nest-mcp';
|
||||
import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
@@ -18,7 +20,7 @@ const when = addonGate(ADDON_IDS.ROADTRIP);
|
||||
export class DayBoundariesMcp {
|
||||
constructor(
|
||||
private readonly boundaries: DayBoundariesService,
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
|
||||
private readonly auth: AuthService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
private readonly realtime: RealtimeService,
|
||||
@@ -31,7 +33,7 @@ export class DayBoundariesMcp {
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when,
|
||||
})
|
||||
async list({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
return ok({ boundaries: await this.boundaries.list(tripId) });
|
||||
}
|
||||
|
||||
@@ -43,7 +45,7 @@ export class DayBoundariesMcp {
|
||||
})
|
||||
async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// A stop from another trip is refused by the service, with the reason.
|
||||
return answeringRefusals(async () => {
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import type { RoadtripDayBoundary } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
|
||||
import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.entity';
|
||||
import type { RoadtripDayBoundariesRepository } from '../../db/repositories/RoadtripDayBoundaries.repository';
|
||||
|
||||
@Injectable()
|
||||
export class DayBoundariesService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(
|
||||
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
|
||||
@InjectRepository(RoadtripDayBoundaries) private readonly boundariesRepo: RoadtripDayBoundariesRepository,
|
||||
) {}
|
||||
|
||||
/** RB1 — `RoadtripDayBoundariesRepository.listForTrip`. */
|
||||
async list(tripId: string | number): Promise<RoadtripDayBoundary[]> {
|
||||
return this.db.all<RoadtripDayBoundary>(
|
||||
'SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number', tripId,
|
||||
);
|
||||
return await this.boundariesRepo.listForTrip(Number(tripId));
|
||||
}
|
||||
|
||||
async save(tripId: string | number, boundary: RoadtripDayBoundary): Promise<RoadtripDayBoundary[]> {
|
||||
const belongs = (id: number) => this.db.get(
|
||||
'SELECT a.id FROM day_assignments a JOIN days d ON d.id = a.day_id WHERE a.id = ? AND d.trip_id = ?', id, tripId,
|
||||
);
|
||||
if (!belongs(boundary.from_assignment_id) || boundary.to_assignment_id !== null && !belongs(boundary.to_assignment_id)) {
|
||||
const tripIdNum = Number(tripId);
|
||||
// RB2 — `belongs`, a synchronous closure over `this.db.get` in the legacy code
|
||||
// (inventory §18.4: converted naively, `!belongs(x)` on a Promise is always
|
||||
// `false`, accepting a foreign assignment id). Every call site below is
|
||||
// `await`ed — the async-closure-truthiness-guards ratchet
|
||||
// (`tests/integration/async-closure-truthiness-guards.test.ts`, RB2-001,
|
||||
// Task 0) stays green with the `await`, and goes red the moment one is
|
||||
// dropped (proven for this task's report by hand-dropping one and reverting).
|
||||
// `DayAssignmentsRepository.findInTrip` (AS12) is the trip-scoped read.
|
||||
const belongs = async (id: number) => !!(await this.dayAssignmentsRepo.findInTrip(id, tripIdNum));
|
||||
if (!(await belongs(boundary.from_assignment_id)) || (boundary.to_assignment_id !== null && !(await belongs(boundary.to_assignment_id)))) {
|
||||
throw new HttpException({ error: 'Stop not found' }, 404);
|
||||
}
|
||||
this.db.run(`INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction)
|
||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT (trip_id, day_number) DO UPDATE SET
|
||||
from_assignment_id = excluded.from_assignment_id, to_assignment_id = excluded.to_assignment_id, fraction = excluded.fraction`,
|
||||
tripId, boundary.day_number, boundary.from_assignment_id, boundary.to_assignment_id, boundary.to_assignment_id === null ? 1 : boundary.fraction);
|
||||
return this.list(tripId);
|
||||
// RB2 → RB3, un-transacted (R7 — pin, don't fix): the ownership check above and
|
||||
// the upsert below are two statements, not one.
|
||||
// RB3 — `RoadtripDayBoundariesRepository.upsertBoundary`. `to_assignment_id ===
|
||||
// null ? 1 : fraction` stays the service's own coercion, unchanged.
|
||||
await this.boundariesRepo.upsertBoundary(tripIdNum, {
|
||||
...boundary,
|
||||
fraction: boundary.to_assignment_id === null ? 1 : boundary.fraction,
|
||||
});
|
||||
return await this.list(tripIdNum);
|
||||
}
|
||||
|
||||
/** RB4 — `RoadtripDayBoundariesRepository.deleteForDay`, silent on a miss. */
|
||||
async remove(tripId: string | number, dayNumber: number): Promise<RoadtripDayBoundary[]> {
|
||||
this.db.run('DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?', tripId, dayNumber);
|
||||
return this.list(tripId);
|
||||
await this.boundariesRepo.deleteForDay(Number(tripId), dayNumber);
|
||||
return await this.list(tripId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { GoogleRouteImport, GoogleRoutePreview } from '@trek/shared';
|
||||
import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service';
|
||||
import { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from '../places/maps-dir.helpers';
|
||||
import { safeFetchFollow } from '../../utils/ssrfGuard';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleRouteService {
|
||||
constructor(private readonly maps: MapsService, private readonly db: DatabaseService,
|
||||
constructor(private readonly maps: MapsService,
|
||||
private readonly places: PlacesService, private readonly assignments: AssignmentsService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly uow: UnitOfWork) {}
|
||||
private readonly uow: UnitOfWork,
|
||||
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository) {}
|
||||
|
||||
async preview(raw: string): Promise<GoogleRoutePreview> {
|
||||
let url = new URL(raw);
|
||||
@@ -56,9 +62,11 @@ export class GoogleRouteService {
|
||||
}
|
||||
|
||||
async import(tripId: number, userId: number, input: GoogleRouteImport, socketId?: string) {
|
||||
const access = await this.db.canAccessTrip(tripId, userId);
|
||||
// GR0 — `TripsRepository.findAccessible` (keeps the row: `access.user_id` feeds the permission check below).
|
||||
const access = await this.tripsRepo.findAccessible(tripId, userId);
|
||||
if (!access) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user';
|
||||
// GR1 — `UsersRepository.getRole`.
|
||||
const role = (await this.usersRepo.getRole(userId)) ?? 'user';
|
||||
// `every` cannot await the permission check, so the same all-of test runs as
|
||||
// an explicit loop — same actions, same order, same short-circuit.
|
||||
for (const action of ['place_edit', 'day_edit']) {
|
||||
|
||||
@@ -4,12 +4,14 @@ import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { RoadtripHazardsService } from './roadtrip-hazards.service';
|
||||
|
||||
@McpController()
|
||||
export class RoadtripHazardsMcp {
|
||||
constructor(private readonly hazards: RoadtripHazardsService, private readonly db: DatabaseService, readonly addons: AddonsService) {}
|
||||
constructor(private readonly hazards: RoadtripHazardsService, @InjectRepository(Trips) private readonly tripsRepo: TripsRepository, readonly addons: AddonsService) {}
|
||||
@Tool({
|
||||
name: 'get_roadtrip_hazards',
|
||||
description: 'Read current DWD and GDACS hazard notices, geometry, source timestamps and feed availability. These are current notices, not forecasts for the trip dates or confirmed road closures. A point means no affected-area polygon is available. Coverage can be incomplete. This does not change routes. The shared roadtrip_show_hazards preference controls map display.',
|
||||
@@ -17,7 +19,7 @@ export class RoadtripHazardsMcp {
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP),
|
||||
})
|
||||
async read({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
return ok(await this.hazards.read());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { DayBoundariesService } from './day-boundaries.service';
|
||||
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
|
||||
@@ -6,6 +6,12 @@ import { RoadtripRouterService } from './roadtrip-router.service';
|
||||
import { RoadtripService } from './roadtrip.service';
|
||||
import { Injectable, HttpException } from '@nestjs/common';
|
||||
import { type RoadtripPreferences } from '@trek/shared';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import type { DaysRepository } from '../../db/repositories/Days.repository';
|
||||
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
|
||||
import {
|
||||
assembleRoadtrip,
|
||||
foldRouteRun,
|
||||
@@ -25,66 +31,28 @@ import {
|
||||
standsAsDay,
|
||||
} from '@trek/shared/roadtrip';
|
||||
|
||||
interface StoredDay {
|
||||
id: number;
|
||||
day_number: number;
|
||||
date: string | null;
|
||||
title: string | null;
|
||||
default_transport_mode: string | null;
|
||||
}
|
||||
interface VisitRow {
|
||||
/** The booking this stop stands for on its check-in day, when there is one. */
|
||||
stay_id: number | null;
|
||||
check_in: string | null;
|
||||
/** The drive does not read these two (#2357): they are the booking as get_roadtrip_context reports it. */
|
||||
check_out: string | null;
|
||||
checkout_day: number | null;
|
||||
id: number;
|
||||
day_id: number;
|
||||
place_id: number;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
time: string | null;
|
||||
end_time: string | null;
|
||||
duration_minutes: number | null;
|
||||
end_day: number;
|
||||
leg_transport_mode: string | null;
|
||||
incoming_leg_transport_mode: string | null;
|
||||
stop_type: string | null;
|
||||
fill_percent: number | null;
|
||||
}
|
||||
const stopKey = (s: RoadtripStop) =>
|
||||
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`;
|
||||
|
||||
@Injectable()
|
||||
export class RoadtripPlanService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly preferences: RoadtripPreferencesService,
|
||||
private readonly router: RoadtripRouterService,
|
||||
private readonly roadtrip: RoadtripService,
|
||||
private readonly boundaries: DayBoundariesService,
|
||||
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
|
||||
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
|
||||
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
|
||||
) {}
|
||||
|
||||
async context(tripId: number, userId: number) {
|
||||
if (!(await this.db.canAccessTrip(tripId, userId))) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
const days = this.db.all<StoredDay>(
|
||||
'SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number',
|
||||
tripId,
|
||||
);
|
||||
const visits = this.db.all<VisitRow>(
|
||||
`SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
|
||||
COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time,
|
||||
p.duration_minutes, a.end_day,
|
||||
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
|
||||
FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id
|
||||
LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1)
|
||||
LEFT JOIN days checkout ON checkout.id = stay.end_day_id
|
||||
WHERE d.trip_id = ? ORDER BY d.day_number, a.order_index, a.created_at`,
|
||||
tripId,
|
||||
);
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, userId))) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
// RPL1 — `DaysRepository.listPlanDays`.
|
||||
const days = await this.daysRepo.listPlanDays(tripId);
|
||||
// RPL2 — `DayAssignmentsRepository.listRoadtripVisits`.
|
||||
const visits = await this.dayAssignmentsRepo.listRoadtripVisits(tripId);
|
||||
return {
|
||||
days,
|
||||
visits,
|
||||
|
||||
@@ -12,7 +12,9 @@ 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 { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
|
||||
import { answeringRefusals } from './roadtrip-mcp.helpers';
|
||||
@@ -28,7 +30,7 @@ export class RoadtripPreferencesMcp {
|
||||
private readonly preferences: RoadtripPreferencesService,
|
||||
private readonly auth: AuthService,
|
||||
readonly addons: AddonsService,
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
) {}
|
||||
|
||||
@@ -42,7 +44,7 @@ export class RoadtripPreferencesMcp {
|
||||
when,
|
||||
})
|
||||
async read({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
return ok({ tripId, settings: await this.preferences.read(tripId), scope: 'trip' });
|
||||
}
|
||||
|
||||
@@ -57,7 +59,7 @@ export class RoadtripPreferencesMcp {
|
||||
})
|
||||
async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// A day window that ends before it starts is refused by the service, with the reason.
|
||||
return answeringRefusals(async () => ok({ tripId, settings: await this.preferences.update(tripId, settings), scope: 'trip' }));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
@@ -8,21 +8,21 @@ import {
|
||||
roadtripPreferencesUpdateSchema,
|
||||
type RoadtripPreferences,
|
||||
} from '@trek/shared';
|
||||
import { RoadtripPreferences as RoadtripPreferencesEntity } from '../../db/entities/RoadtripPreferences.entity';
|
||||
import type { RoadtripPreferencesRepository } from '../../db/repositories/RoadtripPreferences.repository';
|
||||
|
||||
@Injectable()
|
||||
export class RoadtripPreferencesService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly uow: UnitOfWork,
|
||||
@InjectRepository(RoadtripPreferencesEntity) private readonly preferencesRepo: RoadtripPreferencesRepository,
|
||||
) {}
|
||||
|
||||
/** RPF1 — `RoadtripPreferencesRepository.listForTrip`, with the legacy JSON parse (raw-string fallback) unchanged. */
|
||||
async read(tripId: number): Promise<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,
|
||||
)) {
|
||||
for (const row of await this.preferencesRepo.listForTrip(tripId)) {
|
||||
try {
|
||||
settings[row.key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
@@ -44,15 +44,14 @@ export class RoadtripPreferencesService {
|
||||
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);
|
||||
}
|
||||
// RPF3 — `RoadtripPreferencesRepository.upsertValue`.
|
||||
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),
|
||||
);
|
||||
await this.preferencesRepo.upsertValue(tripId, key, JSON.stringify(value));
|
||||
}
|
||||
return this.read(tripId);
|
||||
// RPF1's read-after-write, inside the same transaction (§18.10) — `find`-based,
|
||||
// so `TrekRepository`'s `disableIdentityMap: true` default already keeps this
|
||||
// fresh against the upsert just above.
|
||||
return await this.read(tripId);
|
||||
});
|
||||
// The saving tab is left out, like every other trip mutation: it already has
|
||||
// the answer, and its own echo costs it a second store commit and the route
|
||||
|
||||
@@ -108,7 +108,8 @@ export class RoadtripRouterService {
|
||||
// R1.4: a `flatMap` callback cannot await the now-async profile read, so the
|
||||
// fan-out runs as an explicit loop — same providers, same order.
|
||||
for (const id of this.hooks.providersOf('routeProvider')) {
|
||||
for (const profile of await declaredProfiles(this.db.connection, id)) plugin.push(`plugin:${id}/${profile}`);
|
||||
// RRT1 — `plugins` is Plan 3j's table; stays a raw hand-off of the connection.
|
||||
for (const profile of await declaredProfiles(this.db.connection, id)) plugin.push(`plugin:${id}/${profile}`); // Plan 3j
|
||||
}
|
||||
}
|
||||
return ['driving', 'walking', 'cycling', ...plugin];
|
||||
@@ -175,7 +176,7 @@ export class RoadtripRouterService {
|
||||
points.length > 30 ||
|
||||
!pluginsEnabled() ||
|
||||
!this.hooks.providersOf('routeProvider').includes(id) ||
|
||||
!(await declaredProfiles(this.db.connection, id)).includes(name)
|
||||
!(await declaredProfiles(this.db.connection, id)).includes(name) // RRT2 — Plan 3j
|
||||
)
|
||||
throw new Error('Routing plugin is unavailable or has too many waypoints');
|
||||
const raw = await this.hooks.route(id, { tripId, dayId, profile: name, waypoints: points }, userId);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, ok, type McpContext } from '../../nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { RoadtripService } from './roadtrip.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
@@ -30,7 +32,7 @@ const roadtripAddonOn = addonGate(ADDON_IDS.ROADTRIP);
|
||||
export class RoadtripMcp {
|
||||
constructor(
|
||||
private readonly roadtrip: RoadtripService,
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
private readonly auth: AuthService,
|
||||
readonly addons: AddonsService,
|
||||
@@ -48,7 +50,7 @@ export class RoadtripMcp {
|
||||
when: roadtripAddonOn,
|
||||
})
|
||||
async listVias({ tripId, dayId }: { tripId: number; dayId?: number }, ctx: McpContext) {
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (dayId != null) {
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
return ok({ vias: await this.roadtrip.listForDay(dayId) });
|
||||
@@ -75,7 +77,7 @@ export class RoadtripMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
const via = await this.roadtrip.create(dayId, { after_order_index, lat, lng });
|
||||
@@ -117,7 +119,7 @@ export class RoadtripMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
// The same check the REST route makes, in the same change: permission alone would let
|
||||
@@ -156,7 +158,7 @@ export class RoadtripMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
const next = await this.roadtrip.reanchor(dayId, { vias, remove });
|
||||
@@ -178,7 +180,7 @@ export class RoadtripMcp {
|
||||
})
|
||||
async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
if (!(await this.roadtrip.remove(viaId, dayId))) return noAccess();
|
||||
@@ -194,7 +196,7 @@ export class RoadtripMcp {
|
||||
})
|
||||
async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!(await this.db.canAccessTrip(input.tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.tripsRepo.findAccessible(input.tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId))) return permissionDenied();
|
||||
if (!(await this.roadtrip.dayExists(input.dayId, input.tripId))) return noAccess();
|
||||
const via = await this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index);
|
||||
|
||||
@@ -30,14 +30,36 @@ import { RoadtripService } from './roadtrip.service';
|
||||
import { RoadtripSearchService } from './roadtrip-search.service';
|
||||
import { RoadtripSearchController } from './roadtrip-search.controller';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { RoadtripHazardsController } from './roadtrip-hazards.controller';
|
||||
import { RoadtripHazardsService } from './roadtrip-hazards.service';
|
||||
import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
|
||||
import { RoadtripDayTracks } from '../../db/entities/RoadtripDayTracks.entity';
|
||||
import { RoadtripPreferences } from '../../db/entities/RoadtripPreferences.entity';
|
||||
import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.entity';
|
||||
|
||||
/** 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).
|
||||
//
|
||||
// `MikroOrmModule.forFeature(...)` (Plan 3d Task 1) registers every
|
||||
// repository this module's own providers `@InjectRepository` — a
|
||||
// `forFeature` registration only reaches providers declared in ITS OWN
|
||||
// `providers` array (`AssignmentsDomainModule`'s docstring), so the four
|
||||
// new roadtrip repositories plus `Trips`/`Days`/`Places`/`Users`/
|
||||
// `DayAssignments` (read through `TripsRepository.findAccessible`,
|
||||
// `DaysRepository.existsInTrip`/`listPlanDays`, `PlacesRepository
|
||||
// .isTrackInTrip`/`findChargingProbe`, `UsersRepository.getRole`,
|
||||
// `DayAssignmentsRepository.findInTrip`/`listRoadtripVisits`) all need
|
||||
// their own entry here, even though `PlacesModule`/`AssignmentsModule`
|
||||
// already register some of the same entities for THEIR OWN providers.
|
||||
imports: [
|
||||
McpSharedModule,
|
||||
PermissionsModule,
|
||||
@@ -47,6 +69,7 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
|
||||
SettingsModule,
|
||||
PluginsRuntimeModule,
|
||||
MapsModule, PlacesModule, AssignmentsModule,
|
||||
MikroOrmModule.forFeature([Trips, Days, Places, Users, DayAssignments, RoadtripVias, RoadtripDayTracks, RoadtripPreferences, RoadtripDayBoundaries]),
|
||||
],
|
||||
controllers: [ChargingController, ChargingLookupController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
||||
providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { RoadtripDayTrack, RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import type { DaysRepository } from '../../db/repositories/Days.repository';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
|
||||
import type { RoadtripViasRepository } from '../../db/repositories/RoadtripVias.repository';
|
||||
import { RoadtripDayTracks } from '../../db/entities/RoadtripDayTracks.entity';
|
||||
import type { RoadtripDayTracksRepository } from '../../db/repositories/RoadtripDayTracks.repository';
|
||||
|
||||
/**
|
||||
* Via points: the places a day's drive is made to pass through without stopping.
|
||||
@@ -11,13 +20,22 @@ import { UnitOfWork } from '../database/unit-of-work';
|
||||
* number in the chain, an arrival time and a line in the itinerary. A via only bends the
|
||||
* route. Modelling one as a place would put a numbered stop in the middle of the day for
|
||||
* a spot nobody stops at, and it would show up in the PDF, the map pins and the schedule.
|
||||
*
|
||||
* `roadtrip_vias`/`roadtrip_day_tracks` through `RoadtripViasRepository`/
|
||||
* `RoadtripDayTracksRepository` (Plan 3d Task 1); RT1 (`dayExists`) and RT13
|
||||
* (`trackExists`) delegate to `DaysRepository.existsInTrip`/
|
||||
* `PlacesRepository.isTrackInTrip`, the same trip-scoping gates the 3c
|
||||
* repositories already expose.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RoadtripService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly uow: UnitOfWork,
|
||||
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
@InjectRepository(RoadtripVias) private readonly viasRepo: RoadtripViasRepository,
|
||||
@InjectRepository(RoadtripDayTracks) private readonly tracksRepo: RoadtripDayTracksRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -35,55 +53,60 @@ export class RoadtripService {
|
||||
this.realtime.broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
/** The day exists and belongs to this trip. 404 material, checked before every write. */
|
||||
/**
|
||||
* The gate every write below parses `dayId` through ONCE (rule 21), before
|
||||
* it reaches a repository write — never a bare `Number(dayId)`.
|
||||
*
|
||||
* Load-bearing, not decorative (Task 0 review carry, RT8): `dayId` feeding
|
||||
* `RoadtripDayTracksRepository.upsertTrack` is that table's PRIMARY KEY
|
||||
* column (`roadtrip_day_tracks.day_id`, a single-column INTEGER PK, unlike
|
||||
* `roadtrip_vias.id`'s own separate autoincrement PK) — SQLite accepts
|
||||
* `INSERT … VALUES (NULL, …)` on an INTEGER PRIMARY KEY column and
|
||||
* silently assigns the next free rowid instead of refusing, so a
|
||||
* `Number(dayId)` that resolved to `NaN`/`undefined` would not throw, it
|
||||
* would silently write to WHATEVER day that rowid happens to be — possibly
|
||||
* another trip's. Every caller of `create`/`createMany` reaches them only
|
||||
* after the controller/MCP's own `dayExists` gate already proved `dayId`
|
||||
* belongs to this trip (a raw-bind affinity check, R7), so this second
|
||||
* parse is defence in depth, not the primary guard — but it is what turns
|
||||
* "cannot happen" into "cannot happen even if the first gate is bypassed
|
||||
* or wrong", and it is what stops `NaN`/`null` from ever reaching the
|
||||
* upsert at all.
|
||||
*/
|
||||
private requireDayId(dayId: string | number): number {
|
||||
const parsed = toRowId(dayId);
|
||||
if (parsed === null) throw new HttpException({ error: 'Day not found' }, 404);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** The day exists and belongs to this trip. 404 material, checked before every write. RT1 → `DaysRepository.existsInTrip` (raw-bind, the same affinity seam the legacy statement used). */
|
||||
async dayExists(dayId: string | number, tripId: string | number): Promise<boolean> {
|
||||
return !!this.db.get<{ id: number }>(
|
||||
'SELECT id FROM days WHERE id = ? AND trip_id = ?',
|
||||
dayId,
|
||||
tripId,
|
||||
);
|
||||
return await this.daysRepo.existsInTrip(dayId, tripId);
|
||||
}
|
||||
|
||||
async listForDay(dayId: string | number): Promise<RoadtripVia[]> {
|
||||
return this.db.all<RoadtripVia>(
|
||||
`SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
|
||||
FROM roadtrip_vias
|
||||
WHERE day_id = ?
|
||||
ORDER BY after_order_index, sequence, id`,
|
||||
dayId,
|
||||
);
|
||||
return await this.viasRepo.listForDay(Number(dayId));
|
||||
}
|
||||
|
||||
/** Every via of a trip, so the client can route all days without one request per day. */
|
||||
async listForTrip(tripId: string | number): Promise<RoadtripVia[]> {
|
||||
return this.db.all<RoadtripVia>(
|
||||
`SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng, v.created_at
|
||||
FROM roadtrip_vias v
|
||||
JOIN days d ON d.id = v.day_id
|
||||
WHERE d.trip_id = ?
|
||||
ORDER BY v.day_id, v.after_order_index, v.sequence, v.id`,
|
||||
tripId,
|
||||
);
|
||||
return await this.viasRepo.listForTrip(Number(tripId));
|
||||
}
|
||||
|
||||
async create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): Promise<RoadtripVia> {
|
||||
const dayIdNum = this.requireDayId(dayId);
|
||||
// Appended after whatever already follows that stop, unless the caller says where.
|
||||
const sequence = input.sequence ?? (
|
||||
this.db.get<{ next: number }>(
|
||||
'SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?',
|
||||
dayId,
|
||||
input.after_order_index,
|
||||
)?.next ?? 0
|
||||
);
|
||||
const result = this.db.run(
|
||||
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
|
||||
dayId,
|
||||
input.after_order_index,
|
||||
// RT4 (`nextSequence`) then RT5 (`insertVia`), un-transacted (R7 — pin, don't fix):
|
||||
// two concurrent adds on one leg can read the same next sequence.
|
||||
const sequence = input.sequence ?? (await this.viasRepo.nextSequence(dayIdNum, input.after_order_index));
|
||||
const id = await this.viasRepo.insertVia({
|
||||
day_id: dayIdNum,
|
||||
after_order_index: input.after_order_index,
|
||||
sequence,
|
||||
input.lat,
|
||||
input.lng,
|
||||
);
|
||||
return (await this.byId(Number(result.lastInsertRowid)))!;
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
});
|
||||
return (await this.viasRepo.findById(id))!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,22 +127,17 @@ export class RoadtripService {
|
||||
track?: { place_id: number; stray_km?: number | null } | null;
|
||||
},
|
||||
): Promise<RoadtripVia[]> {
|
||||
const dayIdNum = this.requireDayId(dayId);
|
||||
return await this.uow.transactional(async () => {
|
||||
// Inside the same transaction as the chain it describes. A day that says it follows
|
||||
// a road whose vias never landed is worse than a day that says nothing.
|
||||
if (input.track === null) {
|
||||
this.db.run('DELETE FROM roadtrip_day_tracks WHERE day_id = ?', dayId);
|
||||
await this.tracksRepo.deleteForDay(dayIdNum);
|
||||
} else if (input.track) {
|
||||
this.db.run(
|
||||
`INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, ?)
|
||||
ON CONFLICT(day_id) DO UPDATE SET place_id = excluded.place_id, stray_km = excluded.stray_km`,
|
||||
dayId,
|
||||
input.track.place_id,
|
||||
input.track.stray_km ?? null,
|
||||
);
|
||||
await this.tracksRepo.upsertTrack(dayIdNum, input.track.place_id, input.track.stray_km ?? null);
|
||||
}
|
||||
for (const leg of input.replace_legs ?? []) {
|
||||
this.db.run('DELETE FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?', dayId, leg);
|
||||
await this.viasRepo.deleteLeg(dayIdNum, leg);
|
||||
}
|
||||
// Per leg, because sequence only orders the vias that follow the same stop. Read
|
||||
// once up front rather than per insert: the loop is inside the transaction, and a
|
||||
@@ -128,23 +146,12 @@ export class RoadtripService {
|
||||
for (const via of input.vias) {
|
||||
let seq = nextSeq.get(via.after_order_index);
|
||||
if (seq === undefined) {
|
||||
seq = this.db.get<{ next: number }>(
|
||||
'SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?',
|
||||
dayId,
|
||||
via.after_order_index,
|
||||
)?.next ?? 0;
|
||||
seq = await this.viasRepo.nextSequence(dayIdNum, via.after_order_index);
|
||||
}
|
||||
this.db.run(
|
||||
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
|
||||
dayId,
|
||||
via.after_order_index,
|
||||
seq,
|
||||
via.lat,
|
||||
via.lng,
|
||||
);
|
||||
await this.viasRepo.insertVia({ day_id: dayIdNum, after_order_index: via.after_order_index, sequence: seq, lat: via.lat, lng: via.lng });
|
||||
nextSeq.set(via.after_order_index, seq + 1);
|
||||
}
|
||||
return this.listForDay(dayId);
|
||||
return await this.viasRepo.listForDay(dayIdNum);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,23 +162,12 @@ export class RoadtripService {
|
||||
* second route for a handful of rows would be a second round trip for nothing.
|
||||
*/
|
||||
async tracksForTrip(tripId: string | number): Promise<RoadtripDayTrack[]> {
|
||||
return this.db.all<RoadtripDayTrack>(
|
||||
`SELECT t.day_id, t.place_id, t.stray_km
|
||||
FROM roadtrip_day_tracks t
|
||||
JOIN days d ON d.id = t.day_id
|
||||
WHERE d.trip_id = ?
|
||||
ORDER BY t.day_id`,
|
||||
tripId,
|
||||
);
|
||||
return await this.tracksRepo.listForTrip(Number(tripId));
|
||||
}
|
||||
|
||||
/** Whether a place is on this trip, and is a track rather than an ordinary place. */
|
||||
/** Whether a place is on this trip, and is a track rather than an ordinary place. RT13 → `PlacesRepository.isTrackInTrip`. */
|
||||
async trackExists(placeId: number, tripId: string | number): Promise<boolean> {
|
||||
return !!this.db.get<{ id: number }>(
|
||||
"SELECT id FROM places WHERE id = ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != ''",
|
||||
placeId,
|
||||
tripId,
|
||||
);
|
||||
return await this.placesRepo.isTrackInTrip(placeId, Number(tripId));
|
||||
}
|
||||
|
||||
/** Moving a via is the whole edit; where it sits in the chain does not change. */
|
||||
@@ -182,28 +178,26 @@ export class RoadtripService {
|
||||
lng: number,
|
||||
afterOrderIndex?: number,
|
||||
): Promise<RoadtripVia | null> {
|
||||
const existing = this.db.get<{ id: number }>(
|
||||
'SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?',
|
||||
id,
|
||||
dayId,
|
||||
);
|
||||
if (!existing) return null;
|
||||
const idNum = toRowId(id);
|
||||
const dayIdNum = toRowId(dayId);
|
||||
if (idNum === null || dayIdNum === null) return null;
|
||||
// RT14 → RT15/RT16, un-transacted (R7 — pin, don't fix): the scoping check and the
|
||||
// write that follows are two statements, not one.
|
||||
const exists = await this.viasRepo.existsInDay(idNum, dayIdNum);
|
||||
if (!exists) return null;
|
||||
// The anchor moves with the point when the caller worked out a new one. It is not a
|
||||
// property of the via but of where the via sits along the drive, so dragging one past
|
||||
// a stop changes which leg it belongs to — and leaving it behind is what made the
|
||||
// route run out to the point and back instead of bending through it.
|
||||
//
|
||||
// RT15/RT16 keep the legacy's own scoping (`WHERE id = ?`, no `AND day_id`) — pinned,
|
||||
// not fixed, per R7: the check above is what guards these writes.
|
||||
if (afterOrderIndex === undefined) {
|
||||
this.db.run('UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?', lat, lng, id);
|
||||
await this.viasRepo.moveCoordinates(idNum, lat, lng);
|
||||
} else {
|
||||
this.db.run(
|
||||
'UPDATE roadtrip_vias SET lat = ?, lng = ?, after_order_index = ? WHERE id = ?',
|
||||
lat,
|
||||
lng,
|
||||
afterOrderIndex,
|
||||
id,
|
||||
);
|
||||
await this.viasRepo.moveCoordinatesAndAnchor(idNum, lat, lng, afterOrderIndex);
|
||||
}
|
||||
return this.byId(Number(id));
|
||||
return await this.viasRepo.findById(idNum);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,29 +216,18 @@ export class RoadtripService {
|
||||
dayId: string | number,
|
||||
input: { vias: { id: number; after_order_index: number }[]; remove?: number[] },
|
||||
): Promise<RoadtripVia[]> {
|
||||
const dayIdNum = this.requireDayId(dayId);
|
||||
return await this.uow.transactional(async () => {
|
||||
// Read before writing: the OLD anchor is what says which of two merged
|
||||
// legs came first, and after the updates that information is gone.
|
||||
const before = new Map(
|
||||
this.db
|
||||
.all<{ id: number; after_order_index: number; sequence: number }>(
|
||||
'SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?',
|
||||
dayId,
|
||||
)
|
||||
.map((v) => [v.id, v]),
|
||||
);
|
||||
const before = new Map((await this.viasRepo.listAnchors(dayIdNum)).map((v) => [v.id, v]));
|
||||
|
||||
for (const id of input.remove ?? []) {
|
||||
this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
|
||||
await this.viasRepo.deleteInDay(id, dayIdNum);
|
||||
before.delete(id);
|
||||
}
|
||||
for (const via of input.vias) {
|
||||
this.db.run(
|
||||
'UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?',
|
||||
via.after_order_index,
|
||||
via.id,
|
||||
dayId,
|
||||
);
|
||||
await this.viasRepo.setAnchor(via.id, dayIdNum, via.after_order_index);
|
||||
}
|
||||
|
||||
// Renumber, because `sequence` is allocated per leg and starts at 0 in
|
||||
@@ -259,10 +242,7 @@ export class RoadtripService {
|
||||
// leg belong ahead of the ones from the leg that merged into it. Old
|
||||
// sequence orders within a leg, and the id settles the rest so the result
|
||||
// never depends on row order.
|
||||
const rows = this.db.all<{ id: number; after_order_index: number }>(
|
||||
'SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ?',
|
||||
dayId,
|
||||
);
|
||||
const rows = await this.viasRepo.listLegs(dayIdNum);
|
||||
const byLeg = new Map<number, { id: number }[]>();
|
||||
for (const row of rows) {
|
||||
const list = byLeg.get(row.after_order_index) ?? [];
|
||||
@@ -279,24 +259,20 @@ export class RoadtripService {
|
||||
a.id - b.id
|
||||
);
|
||||
});
|
||||
list.forEach((row, index) => {
|
||||
this.db.run('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?', index, row.id, dayId);
|
||||
});
|
||||
for (const [index, row] of list.entries()) {
|
||||
await this.viasRepo.setSequence(row.id, dayIdNum, index);
|
||||
}
|
||||
}
|
||||
|
||||
return this.listForDay(dayId);
|
||||
return await this.viasRepo.listForDay(dayIdNum);
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string | number, dayId: string | number): Promise<boolean> {
|
||||
const result = this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
private async byId(id: number): Promise<RoadtripVia | null> {
|
||||
return this.db.get<RoadtripVia>(
|
||||
'SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?',
|
||||
id,
|
||||
) ?? null;
|
||||
const idNum = toRowId(id);
|
||||
const dayIdNum = toRowId(dayId);
|
||||
if (idNum === null || dayIdNum === null) return false;
|
||||
const deleted = await this.viasRepo.deleteInDayCounted(idNum, dayIdNum);
|
||||
return deleted > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@ import { RoadtripSearchService } from '../../src/nest/roadtrip/roadtrip-search.s
|
||||
import { GoogleRouteService } from '../../src/nest/roadtrip/google-route.service';
|
||||
import { ChargingService } from '../../src/nest/roadtrip/charging.service';
|
||||
/**
|
||||
* Road trip module e2e — the real guard chain against a temp SQLite db.
|
||||
* Road trip module e2e — the real guard chain against a real migrated-and-
|
||||
* seeded temp SQLite db (`createSnapshotTestDb()`, Plan 3d Task 1 — this used
|
||||
* to hand-roll ten CREATE TABLEs, a second hand-maintained schema copy that
|
||||
* omitted `day_assignments`/`day_accommodations`/most `users`/`trips`/
|
||||
* `places` columns and even LEFT JOINed the inverse 1:1 `roadtrip_day_tracks`
|
||||
* relation `Days` carries — the exact "e2e suites build their schema from
|
||||
* hand-written partial DDL" risk the plan's inventory §15c flagged, and the
|
||||
* same class of failure `days.e2e.test.ts`'s own conversion, Plan 3c Task 2,
|
||||
* fixed for that file). Every service in this module now runs its real SQL
|
||||
* through repositories (DI-injected, no service mock) against the real
|
||||
* request-scoped `EntityManager` `createTestMikroOrmModule` wires in; only
|
||||
* the permission check and the WebSocket broadcast stay mocked.
|
||||
*
|
||||
* The unit test next door pins the handler bodies. What only a booted container
|
||||
* can show is the thing the controller's own comment calls load-bearing: that
|
||||
@@ -21,7 +32,7 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module';
|
||||
import { RoadtripHazardsService } from '../../src/nest/roadtrip/roadtrip-hazards.service';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
@@ -31,63 +42,21 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockIns
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec('PRAGMA foreign_keys = ON');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
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, currency 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);`);
|
||||
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
|
||||
lat REAL, lng REAL, route_geometry TEXT, stop_type TEXT, fill_percent INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE roadtrip_vias (id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
after_order_index INTEGER NOT NULL, sequence INTEGER NOT NULL DEFAULT 0,
|
||||
lat REAL NOT NULL, lng REAL NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP);`);
|
||||
tmp.exec(`CREATE TABLE roadtrip_day_tracks (
|
||||
day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE,
|
||||
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, stray_km REAL);`);
|
||||
// AddonsService reads this; StorageRegistryService reads app_settings at init.
|
||||
tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT, description TEXT, category TEXT, sort_order INTEGER DEFAULT 0);`);
|
||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
return { db: tmp };
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db,
|
||||
canAccessTrip: (tripId: number | string, userId: number) =>
|
||||
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),
|
||||
isOwner: () => false,
|
||||
getPlaceWithTags: () => null,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { db } from '../../src/db/database';
|
||||
|
||||
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);
|
||||
// The seeder (`AddonSeeder`, run once as part of `createSnapshotTestDb()`'s
|
||||
// migration pass) already inserted this row, disabled — a plain UPDATE,
|
||||
// not an upsert, matching every other addon-toggling e2e in this file set.
|
||||
db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, ADDON_ID);
|
||||
}
|
||||
|
||||
describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
@@ -108,8 +77,12 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
seedUser(db as never, { id: 2, email: 'other@example.test' });
|
||||
// harness.ts's seedUser() omits password_hash, which the real migrated
|
||||
// schema requires NOT NULL (days.e2e.test.ts/assignments.e2e.test.ts's
|
||||
// own precedent) — raw inserts here instead, matching the SeededUser
|
||||
// shape id/role/password_version=0 that sessionCookie() needs.
|
||||
db.prepare("INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)").run();
|
||||
db.prepare("INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (2, 'other', 'other@example.test', 'x', 'user', 0)").run();
|
||||
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (5, 1, ?)').run('Norway');
|
||||
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (6, 2, ?)').run('Somebody else');
|
||||
db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1)').run();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.reposito
|
||||
import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo,
|
||||
createTestAssignmentParticipantsRepo, createTestDayAssignmentsRepo, createTestDaysRepo, createTestPlacesRepo,
|
||||
createTestTripMembersRepo, sharedTestOrm,
|
||||
createTestTripMembersRepo, createTestRoadtripViasRepo, sharedTestOrm,
|
||||
} from './test-uow';
|
||||
|
||||
/**
|
||||
@@ -50,6 +50,7 @@ export async function accommodationsOver(dbs: DatabaseService): Promise<Accommod
|
||||
await createTestDaysRepo(dbs.connection),
|
||||
await createTestPlacesRepo(dbs.connection),
|
||||
await createTestTripMembersRepo(dbs.connection),
|
||||
await createTestRoadtripViasRepo(dbs.connection),
|
||||
);
|
||||
return new AccommodationsService(dbs, permissions, realtime, assignments, await createTestUnitOfWork(dbs.connection));
|
||||
}
|
||||
|
||||
@@ -118,7 +118,9 @@ import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo, createTestTagsRepo, createTestSettingsRepo,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTripMembersRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestRoadtripViasRepo, createTestRoadtripDayTracksRepo,
|
||||
createTestReservationsRepo,
|
||||
createTestReservationEndpointsRepo,
|
||||
} from './test-uow';
|
||||
import { createTestOrm } from './test-orm';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
@@ -185,6 +187,8 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
await createTestDayAssignmentsRepo(dbService.connection),
|
||||
await createTestDayNotesRepo(dbService.connection),
|
||||
await createTestTripsRepo(dbService.connection),
|
||||
await createTestReservationsRepo(dbService.connection),
|
||||
await createTestReservationEndpointsRepo(dbService.connection),
|
||||
);
|
||||
const todoService = new TodoService(dbService, permissionsService, realtimeService, await createTestUnitOfWork(dbService.connection));
|
||||
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection));
|
||||
@@ -208,6 +212,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
await createTestDaysRepo(dbService.connection),
|
||||
await createTestPlacesRepo(dbService.connection),
|
||||
await createTestTripMembersRepo(dbService.connection),
|
||||
await createTestRoadtripViasRepo(dbService.connection),
|
||||
);
|
||||
const accommodationsService = new AccommodationsService(dbService, permissionsService, realtimeService, assignmentsService, await createTestUnitOfWork(dbService.connection));
|
||||
// Built after it: deleting a place cancels the nights booked at it through this one.
|
||||
@@ -276,7 +281,20 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards),
|
||||
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards),
|
||||
new DaysMcp(daysService, authService, guards),
|
||||
new RoadtripMcp(new RoadtripService(dbService, realtimeService, await createTestUnitOfWork(dbService.connection)), dbService, guards, authService, addonsService),
|
||||
new RoadtripMcp(
|
||||
new RoadtripService(
|
||||
realtimeService,
|
||||
await createTestUnitOfWork(dbService.connection),
|
||||
await createTestDaysRepo(dbService.connection),
|
||||
await createTestPlacesRepo(dbService.connection),
|
||||
await createTestRoadtripViasRepo(dbService.connection),
|
||||
await createTestRoadtripDayTracksRepo(dbService.connection),
|
||||
),
|
||||
await createTestTripsRepo(dbService.connection),
|
||||
guards,
|
||||
authService,
|
||||
addonsService,
|
||||
),
|
||||
new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage, mcpOrm.em), authService, guards),
|
||||
new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards, await createTestUnitOfWork(dbService.connection)),
|
||||
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
|
||||
|
||||
@@ -76,7 +76,9 @@ import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo, createTestTagsRepo, createTestSettingsRepo, sharedTestOrm,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTripMembersRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestRoadtripViasRepo,
|
||||
createTestReservationsRepo,
|
||||
createTestReservationEndpointsRepo,
|
||||
} from './test-uow';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
@@ -117,6 +119,8 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
|
||||
await createTestDayAssignmentsRepo(dbs.connection),
|
||||
await createTestDayNotesRepo(dbs.connection),
|
||||
await createTestTripsRepo(dbs.connection),
|
||||
await createTestReservationsRepo(dbs.connection),
|
||||
await createTestReservationEndpointsRepo(dbs.connection),
|
||||
);
|
||||
const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage, await createTestGooglePlacePhotoMetaRepo(dbs.connection), await createTestPlacesRepo(dbs.connection));
|
||||
const unsplash = new UnsplashService(appSettings, usersRepo, new RuntimeEnvService(), generalStorage);
|
||||
@@ -131,6 +135,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
|
||||
await createTestDaysRepo(dbs.connection),
|
||||
await createTestPlacesRepo(dbs.connection),
|
||||
await createTestTripMembersRepo(dbs.connection),
|
||||
await createTestRoadtripViasRepo(dbs.connection),
|
||||
);
|
||||
const membership = new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection));
|
||||
const notifications = await makeNotificationsService(dbs, realtime);
|
||||
|
||||
@@ -47,6 +47,24 @@ import { GooglePlacePhotoMeta } from '../../src/db/entities/GooglePlacePhotoMeta
|
||||
import type { GooglePlacePhotoMetaRepository } from '../../src/db/repositories/GooglePlacePhotoMeta.repository';
|
||||
import { PlaceDetailsCache } from '../../src/db/entities/PlaceDetailsCache.entity';
|
||||
import type { PlaceDetailsCacheRepository } from '../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
import { Reservations } from '../../src/db/entities/Reservations.entity';
|
||||
import type { ReservationsRepository } from '../../src/db/repositories/Reservations.repository';
|
||||
import { ReservationEndpoints } from '../../src/db/entities/ReservationEndpoints.entity';
|
||||
import type { ReservationEndpointsRepository } from '../../src/db/repositories/ReservationEndpoints.repository';
|
||||
import { ReservationTravelers } from '../../src/db/entities/ReservationTravelers.entity';
|
||||
import type { ReservationTravelersRepository } from '../../src/db/repositories/ReservationTravelers.repository';
|
||||
import { ReservationDayPositions } from '../../src/db/entities/ReservationDayPositions.entity';
|
||||
import type { ReservationDayPositionsRepository } from '../../src/db/repositories/ReservationDayPositions.repository';
|
||||
import { DayAccommodations } from '../../src/db/entities/DayAccommodations.entity';
|
||||
import type { DayAccommodationsRepository } from '../../src/db/repositories/DayAccommodations.repository';
|
||||
import { RoadtripVias } from '../../src/db/entities/RoadtripVias.entity';
|
||||
import type { RoadtripViasRepository } from '../../src/db/repositories/RoadtripVias.repository';
|
||||
import { RoadtripDayTracks } from '../../src/db/entities/RoadtripDayTracks.entity';
|
||||
import type { RoadtripDayTracksRepository } from '../../src/db/repositories/RoadtripDayTracks.repository';
|
||||
import { RoadtripPreferences } from '../../src/db/entities/RoadtripPreferences.entity';
|
||||
import type { RoadtripPreferencesRepository } from '../../src/db/repositories/RoadtripPreferences.repository';
|
||||
import { RoadtripDayBoundaries } from '../../src/db/entities/RoadtripDayBoundaries.entity';
|
||||
import type { RoadtripDayBoundariesRepository } from '../../src/db/repositories/RoadtripDayBoundaries.repository';
|
||||
|
||||
const perHandle = new WeakMap<Database.Database, Promise<UnitOfWork>>();
|
||||
const appSettingsPerHandle = new WeakMap<Database.Database, Promise<AppSettingsRepository>>();
|
||||
@@ -459,3 +477,112 @@ export function createTestPlaceDetailsCacheRepo(db: Database.Database): Promise<
|
||||
placeDetailsCacheRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3d Task 2 (`ReservationsService`/`DaysService`'s DY14–DY18/DY23 onto
|
||||
// `ReservationsRepository`/`ReservationEndpointsRepository`/
|
||||
// `ReservationTravelersRepository`/`ReservationDayPositionsRepository`/
|
||||
// `DayAccommodationsRepository`) — appended at the end per the task's own
|
||||
// file-ownership rule (append only). Same memoisation-per-handle pattern as
|
||||
// every helper above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const reservationsRepoPerHandle = new WeakMap<Database.Database, Promise<ReservationsRepository>>();
|
||||
const reservationEndpointsRepoPerHandle = new WeakMap<Database.Database, Promise<ReservationEndpointsRepository>>();
|
||||
const reservationTravelersRepoPerHandle = new WeakMap<Database.Database, Promise<ReservationTravelersRepository>>();
|
||||
const reservationDayPositionsRepoPerHandle = new WeakMap<Database.Database, Promise<ReservationDayPositionsRepository>>();
|
||||
const dayAccommodationsRepoPerHandle = new WeakMap<Database.Database, Promise<DayAccommodationsRepository>>();
|
||||
|
||||
/** The `ReservationsRepository` a hand-constructed `ReservationsService`/`DaysService` needs. */
|
||||
export function createTestReservationsRepo(db: Database.Database): Promise<ReservationsRepository> {
|
||||
const existing = reservationsRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(Reservations));
|
||||
reservationsRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `ReservationEndpointsRepository` a hand-constructed `ReservationsService`/`DaysService` needs. */
|
||||
export function createTestReservationEndpointsRepo(db: Database.Database): Promise<ReservationEndpointsRepository> {
|
||||
const existing = reservationEndpointsRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(ReservationEndpoints));
|
||||
reservationEndpointsRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `ReservationTravelersRepository` a hand-constructed `ReservationsService` needs. */
|
||||
export function createTestReservationTravelersRepo(db: Database.Database): Promise<ReservationTravelersRepository> {
|
||||
const existing = reservationTravelersRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(ReservationTravelers));
|
||||
reservationTravelersRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `ReservationDayPositionsRepository` a hand-constructed `ReservationsService` needs. */
|
||||
export function createTestReservationDayPositionsRepo(db: Database.Database): Promise<ReservationDayPositionsRepository> {
|
||||
const existing = reservationDayPositionsRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(ReservationDayPositions));
|
||||
reservationDayPositionsRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `DayAccommodationsRepository` a hand-constructed `ReservationsService` needs. */
|
||||
export function createTestDayAccommodationsRepo(db: Database.Database): Promise<DayAccommodationsRepository> {
|
||||
const existing = dayAccommodationsRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(DayAccommodations));
|
||||
dayAccommodationsRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3d Task 1 (`RoadtripService`/`RoadtripPlanService`/
|
||||
// `RoadtripPreferencesService`/`DayBoundariesService`/`ChargingService`/
|
||||
// `GoogleRouteService`, `AssignmentsService`'s AS20–AS23) — appended at the
|
||||
// end per this task's own file-ownership rule (append only). Same
|
||||
// memoisation-per-handle pattern as every helper above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const roadtripViasRepoPerHandle = new WeakMap<Database.Database, Promise<RoadtripViasRepository>>();
|
||||
const roadtripDayTracksRepoPerHandle = new WeakMap<Database.Database, Promise<RoadtripDayTracksRepository>>();
|
||||
const roadtripPreferencesRepoPerHandle = new WeakMap<Database.Database, Promise<RoadtripPreferencesRepository>>();
|
||||
const roadtripDayBoundariesRepoPerHandle = new WeakMap<Database.Database, Promise<RoadtripDayBoundariesRepository>>();
|
||||
|
||||
/** The `RoadtripViasRepository` a hand-constructed `RoadtripService`/`AssignmentsService` needs. */
|
||||
export function createTestRoadtripViasRepo(db: Database.Database): Promise<RoadtripViasRepository> {
|
||||
const existing = roadtripViasRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(RoadtripVias));
|
||||
roadtripViasRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `RoadtripDayTracksRepository` a hand-constructed `RoadtripService` needs. */
|
||||
export function createTestRoadtripDayTracksRepo(db: Database.Database): Promise<RoadtripDayTracksRepository> {
|
||||
const existing = roadtripDayTracksRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(RoadtripDayTracks));
|
||||
roadtripDayTracksRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `RoadtripPreferencesRepository` a hand-constructed `RoadtripPreferencesService` needs. */
|
||||
export function createTestRoadtripPreferencesRepo(db: Database.Database): Promise<RoadtripPreferencesRepository> {
|
||||
const existing = roadtripPreferencesRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(RoadtripPreferences));
|
||||
roadtripPreferencesRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `RoadtripDayBoundariesRepository` a hand-constructed `DayBoundariesService` needs. */
|
||||
export function createTestRoadtripDayBoundariesRepo(db: Database.Database): Promise<RoadtripDayBoundariesRepository> {
|
||||
const existing = roadtripDayBoundariesRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(RoadtripDayBoundaries));
|
||||
roadtripDayBoundariesRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* `RoadtripDayBoundariesRepository` (Plan 3d Task 1). RB1 (`listForTrip`),
|
||||
* RB3 (`upsertBoundary`, `ON CONFLICT (trip_id, day_number) DO UPDATE SET
|
||||
* from_assignment_id = excluded.from_assignment_id, to_assignment_id =
|
||||
* excluded.to_assignment_id, fraction = excluded.fraction`) and RB4
|
||||
* (`deleteForDay`).
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { RoadtripDayBoundaries } from '../../../../src/db/entities/RoadtripDayBoundaries.entity';
|
||||
import type { RoadtripDayBoundariesRepository } from '../../../../src/db/repositories/RoadtripDayBoundaries.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let repo: RoadtripDayBoundariesRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
repo = t.repo(RoadtripDayBoundaries);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function fixture() {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const from = createDayAssignment(testDb, day.id, place.id);
|
||||
const to = createDayAssignment(testDb, day.id, place.id);
|
||||
return { trip, day, from, to };
|
||||
}
|
||||
|
||||
describe('RoadtripDayBoundariesRepository', () => {
|
||||
it('RB1REPO-001: listForTrip is empty for a trip with no boundaries', async () => {
|
||||
const { trip } = fixture();
|
||||
expect(await repo.listForTrip(trip.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('UPSERTBOUNDARYREPO-001: upsertBoundary inserts a fresh (trip, day_number) row with every column', async () => {
|
||||
const { trip, from, to } = fixture();
|
||||
await repo.upsertBoundary(trip.id, { day_number: 1, from_assignment_id: from.id, to_assignment_id: to.id, fraction: 0.4 });
|
||||
expect(await repo.listForTrip(trip.id)).toEqual([{ day_number: 1, from_assignment_id: from.id, to_assignment_id: to.id, fraction: 0.4 }]);
|
||||
});
|
||||
|
||||
it('UPSERTBOUNDARYREPO-002: re-upserting the SAME (trip, day_number) replaces in place — one row, never touching the key', async () => {
|
||||
const { trip, from, to } = fixture();
|
||||
await repo.upsertBoundary(trip.id, { day_number: 1, from_assignment_id: from.id, to_assignment_id: to.id, fraction: 0.4 });
|
||||
await repo.upsertBoundary(trip.id, { day_number: 1, from_assignment_id: to.id, to_assignment_id: null, fraction: 1 });
|
||||
|
||||
const rows = await repo.listForTrip(trip.id);
|
||||
expect(rows).toEqual([{ day_number: 1, from_assignment_id: to.id, to_assignment_id: null, fraction: 1 }]);
|
||||
});
|
||||
|
||||
it('RB1REPO-002: listForTrip orders by day_number ascending', async () => {
|
||||
const { trip, from, to } = fixture();
|
||||
await repo.upsertBoundary(trip.id, { day_number: 3, from_assignment_id: from.id, to_assignment_id: to.id, fraction: 0.5 });
|
||||
await repo.upsertBoundary(trip.id, { day_number: 1, from_assignment_id: from.id, to_assignment_id: to.id, fraction: 0.5 });
|
||||
await repo.upsertBoundary(trip.id, { day_number: 2, from_assignment_id: from.id, to_assignment_id: to.id, fraction: 0.5 });
|
||||
|
||||
expect((await repo.listForTrip(trip.id)).map((b) => b.day_number)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('RB4REPO-001: deleteForDay removes only the named day_number, silent on a miss', async () => {
|
||||
const { trip, from, to } = fixture();
|
||||
await repo.upsertBoundary(trip.id, { day_number: 1, from_assignment_id: from.id, to_assignment_id: to.id, fraction: 0.4 });
|
||||
await repo.upsertBoundary(trip.id, { day_number: 2, from_assignment_id: from.id, to_assignment_id: to.id, fraction: 0.4 });
|
||||
|
||||
await expect(repo.deleteForDay(trip.id, 99)).resolves.toBeUndefined();
|
||||
expect(await repo.listForTrip(trip.id)).toHaveLength(2);
|
||||
|
||||
await repo.deleteForDay(trip.id, 1);
|
||||
expect((await repo.listForTrip(trip.id)).map((b) => b.day_number)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('boundaries of one trip are never boundaries of another', async () => {
|
||||
const { trip: tripA, from: fromA, to: toA } = fixture();
|
||||
const { trip: tripB, from: fromB, to: toB } = fixture();
|
||||
await repo.upsertBoundary(tripA.id, { day_number: 1, from_assignment_id: fromA.id, to_assignment_id: toA.id, fraction: 0.4 });
|
||||
await repo.upsertBoundary(tripB.id, { day_number: 1, from_assignment_id: fromB.id, to_assignment_id: toB.id, fraction: 0.9 });
|
||||
|
||||
expect(await repo.listForTrip(tripA.id)).toEqual([{ day_number: 1, from_assignment_id: fromA.id, to_assignment_id: toA.id, fraction: 0.4 }]);
|
||||
expect(await repo.listForTrip(tripB.id)).toEqual([{ day_number: 1, from_assignment_id: fromB.id, to_assignment_id: toB.id, fraction: 0.9 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `RoadtripDayTracksRepository` (Plan 3d Task 1). RT7 (`deleteForDay`), RT8
|
||||
* (`upsertTrack`) and RT12 (`listForTrip`).
|
||||
*
|
||||
* RT8's `onConflictFields: ['day']` targets `RoadtripDayTracks.day` — this
|
||||
* table's PRIMARY KEY, a NULLABLE one-to-one relation (Task 0's RULE12/
|
||||
* generator finding, ruled NOT a defect). Task 0's review carried a live
|
||||
* finding forward to this task: SQLite accepts `INSERT … VALUES (NULL, …)`
|
||||
* on an INTEGER PRIMARY KEY column and silently assigns the next free
|
||||
* rowid instead of refusing — so an unguarded `day_id` reaching this
|
||||
* upsert could silently write to the WRONG day (another trip's). Two
|
||||
* things prove this is safe here: (1) `upsertTrack`'s own signature is
|
||||
* `day_id: number`, never nullable — TypeScript refuses a `null`/
|
||||
* `undefined` call at compile time; (2) `RoadtripService.requireDayId`
|
||||
* (the caller) parses every `dayId` with `toRowId` BEFORE it reaches this
|
||||
* repository (RT8-GUARD-001 below, at the service level — a repository
|
||||
* unit test cannot express a `null` argument a typed signature rejects).
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createDay, createPlace, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { RoadtripDayTracks } from '../../../../src/db/entities/RoadtripDayTracks.entity';
|
||||
import type { RoadtripDayTracksRepository } from '../../../../src/db/repositories/RoadtripDayTracks.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let tracks: RoadtripDayTracksRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
tracks = t.repo(RoadtripDayTracks);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function fixture() {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const dayA = createDay(testDb, trip.id);
|
||||
const placeA = createPlace(testDb, trip.id, { name: 'Track A' });
|
||||
const placeB = createPlace(testDb, trip.id, { name: 'Track B' });
|
||||
return { trip, dayA, placeA, placeB };
|
||||
}
|
||||
|
||||
function rawRows(): { day_id: number; place_id: number; stray_km: number | null; created_at: string | null }[] {
|
||||
return testDb.prepare('SELECT day_id, place_id, stray_km, created_at FROM roadtrip_day_tracks ORDER BY day_id').all() as never[];
|
||||
}
|
||||
|
||||
describe('RoadtripDayTracksRepository', () => {
|
||||
it('RT7REPO-001: deleteForDay removes the one row for that day', async () => {
|
||||
const { dayA, placeA } = fixture();
|
||||
await tracks.upsertTrack(dayA.id, placeA.id, null);
|
||||
expect(rawRows()).toHaveLength(1);
|
||||
await tracks.deleteForDay(dayA.id);
|
||||
expect(rawRows()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('RT7REPO-002: deleteForDay on a day with no track is a silent no-op', async () => {
|
||||
const { dayA } = fixture();
|
||||
await expect(tracks.deleteForDay(dayA.id)).resolves.toBeUndefined();
|
||||
expect(rawRows()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('UPSERTTRACKREPO-001: a fresh day_id inserts one row', async () => {
|
||||
const { dayA, placeA } = fixture();
|
||||
await tracks.upsertTrack(dayA.id, placeA.id, 1.5);
|
||||
const rows = rawRows();
|
||||
expect(rows).toEqual([{ day_id: dayA.id, place_id: placeA.id, stray_km: 1.5, created_at: expect.any(String) }]);
|
||||
});
|
||||
|
||||
it('UPSERTTRACKREPO-002: re-upserting the SAME day_id updates in place — one row, the second call\'s place_id/stray_km, created_at untouched', async () => {
|
||||
const { dayA, placeA, placeB } = fixture();
|
||||
await tracks.upsertTrack(dayA.id, placeA.id, 1.5);
|
||||
const before = rawRows()[0]!;
|
||||
|
||||
await tracks.upsertTrack(dayA.id, placeB.id, 9.25);
|
||||
const after = rawRows();
|
||||
|
||||
// Exactly one row — if `onConflictFields: ['day']` resolved to anything
|
||||
// other than the physical `day_id` PK, the second call would either
|
||||
// throw ("ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE
|
||||
// constraint") or raise a UNIQUE-constraint violation trying to insert
|
||||
// a second row with the same PK. Neither happens here — proof, not
|
||||
// assumption, per the Task 0 review carry.
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]).toEqual({ day_id: dayA.id, place_id: placeB.id, stray_km: 9.25, created_at: before.created_at });
|
||||
});
|
||||
|
||||
it('UPSERTTRACKREPO-003: a track for a different day never touches this one\'s row', async () => {
|
||||
const { trip, dayA, placeA } = fixture();
|
||||
const dayB = createDay(testDb, trip.id);
|
||||
await tracks.upsertTrack(dayA.id, placeA.id, null);
|
||||
await tracks.upsertTrack(dayB.id, placeA.id, null);
|
||||
expect(rawRows()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('UPSERTTRACKREPO-004: the rendered upsert SQL names the physical day_id column as its conflict target', async () => {
|
||||
const { dayA, placeA } = fixture();
|
||||
const connection = t.orm.em.getConnection();
|
||||
const spy = vi.spyOn(connection, 'execute');
|
||||
await tracks.upsertTrack(dayA.id, placeA.id, null);
|
||||
// Read `spy.mock.calls` BEFORE `mockRestore()` — vitest's `mockRestore`
|
||||
// does everything `mockReset` does (clearing recorded calls) in
|
||||
// addition to restoring the original implementation, so a `finally {
|
||||
// spy.mockRestore() }` ahead of this read empties `.calls` first.
|
||||
const calls = [...spy.mock.calls];
|
||||
spy.mockRestore();
|
||||
const upsertCall = calls.find((call) => typeof call[0] === 'string' && call[0].includes('roadtrip_day_tracks') && /insert/i.test(call[0]));
|
||||
expect(upsertCall).toBeDefined();
|
||||
const sql = String(upsertCall![0]);
|
||||
expect(sql).toMatch(/on conflict\s*\(\s*`?day_id`?\s*\)/i);
|
||||
});
|
||||
|
||||
it('RT12REPO-001: listForTrip reads every day-track of the trip, ordered by day, day_id/place_id as bare scalars', async () => {
|
||||
const { trip, dayA, placeA } = fixture();
|
||||
const dayB = createDay(testDb, trip.id);
|
||||
const placeB = createPlace(testDb, trip.id);
|
||||
await tracks.upsertTrack(dayB.id, placeB.id, 2);
|
||||
await tracks.upsertTrack(dayA.id, placeA.id, null);
|
||||
|
||||
const rows = await tracks.listForTrip(trip.id);
|
||||
expect(rows).toEqual([
|
||||
{ day_id: dayA.id, place_id: placeA.id, stray_km: null },
|
||||
{ day_id: dayB.id, place_id: placeB.id, stray_km: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('RT12REPO-002: listForTrip never returns another trip\'s tracks', async () => {
|
||||
const { dayA, placeA } = fixture();
|
||||
const { user: otherUser } = createUser(testDb);
|
||||
const otherTrip = createTrip(testDb, otherUser.id);
|
||||
const otherDay = createDay(testDb, otherTrip.id);
|
||||
const otherPlace = createPlace(testDb, otherTrip.id);
|
||||
await tracks.upsertTrack(dayA.id, placeA.id, null);
|
||||
await tracks.upsertTrack(otherDay.id, otherPlace.id, null);
|
||||
|
||||
const rows = await tracks.listForTrip(otherTrip.id);
|
||||
expect(rows).toEqual([{ day_id: otherDay.id, place_id: otherPlace.id, stray_km: null }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* `RoadtripPreferencesRepository` (Plan 3d Task 1). RPF1 (`listForTrip`) and
|
||||
* RPF3 (`upsertValue`, `ON CONFLICT(trip_id, key) DO UPDATE SET value =
|
||||
* excluded.value`).
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTrip, createUser } from '../../../helpers/factories';
|
||||
import { RoadtripPreferences } from '../../../../src/db/entities/RoadtripPreferences.entity';
|
||||
import type { RoadtripPreferencesRepository } from '../../../../src/db/repositories/RoadtripPreferences.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let repo: RoadtripPreferencesRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
repo = t.repo(RoadtripPreferences);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
describe('RoadtripPreferencesRepository', () => {
|
||||
it('RPF1REPO-001: listForTrip is empty for a trip with no saved preferences', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(await repo.listForTrip(trip.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('UPSERTPREFREPO-001: upsertValue inserts a fresh (trip, key) pair', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await repo.upsertValue(trip.id, 'roadtrip_range_km', '100');
|
||||
expect(await repo.listForTrip(trip.id)).toEqual([{ key: 'roadtrip_range_km', value: '100' }]);
|
||||
});
|
||||
|
||||
it('UPSERTPREFREPO-002: re-upserting the SAME (trip, key) replaces the value in place — one row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await repo.upsertValue(trip.id, 'roadtrip_range_km', '100');
|
||||
await repo.upsertValue(trip.id, 'roadtrip_range_km', '250');
|
||||
const rows = await repo.listForTrip(trip.id);
|
||||
expect(rows).toEqual([{ key: 'roadtrip_range_km', value: '250' }]);
|
||||
});
|
||||
|
||||
it('UPSERTPREFREPO-003: two different keys on the same trip coexist as two rows', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await repo.upsertValue(trip.id, 'roadtrip_range_km', '100');
|
||||
await repo.upsertValue(trip.id, 'roadtrip_vehicle', '"electric"');
|
||||
const rows = await repo.listForTrip(trip.id);
|
||||
expect(rows.map((r) => r.key).sort()).toEqual(['roadtrip_range_km', 'roadtrip_vehicle']);
|
||||
});
|
||||
|
||||
it('UPSERTPREFREPO-004: the same key on two different trips is two independent rows', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tripA = createTrip(testDb, user.id);
|
||||
const tripB = createTrip(testDb, user.id);
|
||||
await repo.upsertValue(tripA.id, 'roadtrip_range_km', '100');
|
||||
await repo.upsertValue(tripB.id, 'roadtrip_range_km', '999');
|
||||
expect(await repo.listForTrip(tripA.id)).toEqual([{ key: 'roadtrip_range_km', value: '100' }]);
|
||||
expect(await repo.listForTrip(tripB.id)).toEqual([{ key: 'roadtrip_range_km', value: '999' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* `RoadtripViasRepository` (Plan 3d Task 1). RT2 (`listForDay`, the ONE copy
|
||||
* — `AccommodationsService`/`AssignmentsService` both consume it now), RT3
|
||||
* (`listForTrip`), RT4/RT10 (`nextSequence`), RT5/RT11 (`insertVia`), RT9
|
||||
* (`deleteLeg`), RT14 (`existsInDay`), RT15/RT16 (`moveCoordinates`/
|
||||
* `moveCoordinatesAndAnchor`), RT18 (`listAnchors`), RT19/RT23
|
||||
* (`deleteInDay`/`deleteInDayCounted`), RT20 (`setAnchor`), RT21
|
||||
* (`listLegs`), RT22 (`setSequence`), RT24 (`findById`), AS20
|
||||
* (`listForReanchor`).
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createDay, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { RoadtripVias } from '../../../../src/db/entities/RoadtripVias.entity';
|
||||
import type { RoadtripViasRepository } from '../../../../src/db/repositories/RoadtripVias.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let vias: RoadtripViasRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
vias = t.repo(RoadtripVias);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function fixture() {
|
||||
const { user } = createUser(testDb);
|
||||
const tripA = createTrip(testDb, user.id);
|
||||
const tripB = createTrip(testDb, user.id);
|
||||
const dayA1 = createDay(testDb, tripA.id);
|
||||
const dayA2 = createDay(testDb, tripA.id);
|
||||
const dayB1 = createDay(testDb, tripB.id);
|
||||
return { tripA, tripB, dayA1, dayA2, dayB1 };
|
||||
}
|
||||
|
||||
describe('RoadtripViasRepository', () => {
|
||||
it('RT5REPO-001: insertVia writes every column and returns the new id', async () => {
|
||||
const { dayA1 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 53.1, lng: 10.2 });
|
||||
const row = await vias.findById(id);
|
||||
expect(row).toEqual({ id, day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 53.1, lng: 10.2, created_at: expect.any(String) });
|
||||
});
|
||||
|
||||
it('RT24REPO-001: findById on a missing id is null', async () => {
|
||||
expect(await vias.findById(999999)).toBeNull();
|
||||
});
|
||||
|
||||
it('RT2REPO-001: listForDay orders by after_order_index, sequence, id and is scoped to the day', async () => {
|
||||
const { dayA1, dayA2 } = fixture();
|
||||
const b = await vias.insertVia({ day_id: dayA1.id, after_order_index: 1, sequence: 0, lat: 1, lng: 1 });
|
||||
const a1 = await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 1, lat: 1, lng: 1 });
|
||||
const a0 = await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
await vias.insertVia({ day_id: dayA2.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
|
||||
const rows = await vias.listForDay(dayA1.id);
|
||||
expect(rows.map((v) => v.id)).toEqual([a0, a1, b]);
|
||||
});
|
||||
|
||||
it('RT3REPO-001: listForTrip spans every day of the trip, ordered by day/after_order_index/sequence/id, and never crosses trips', async () => {
|
||||
const { tripA, tripB, dayA1, dayA2, dayB1 } = fixture();
|
||||
const v2 = await vias.insertVia({ day_id: dayA2.id, after_order_index: 0, sequence: 0, lat: 2, lng: 2 });
|
||||
const v1 = await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
await vias.insertVia({ day_id: dayB1.id, after_order_index: 0, sequence: 0, lat: 3, lng: 3 });
|
||||
|
||||
const rows = await vias.listForTrip(tripA.id);
|
||||
expect(rows).toEqual([
|
||||
{ id: v1, day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1, created_at: expect.any(String) },
|
||||
{ id: v2, day_id: dayA2.id, after_order_index: 0, sequence: 0, lat: 2, lng: 2, created_at: expect.any(String) },
|
||||
]);
|
||||
expect((await vias.listForTrip(tripB.id)).length).toBe(1);
|
||||
});
|
||||
|
||||
it('RT4REPO-001: nextSequence is 0 for an empty leg, and MAX+1 for a populated one', async () => {
|
||||
const { dayA1 } = fixture();
|
||||
expect(await vias.nextSequence(dayA1.id, 0)).toBe(0);
|
||||
await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 3, lat: 1, lng: 1 });
|
||||
expect(await vias.nextSequence(dayA1.id, 0)).toBe(4);
|
||||
// A different leg on the same day starts fresh.
|
||||
expect(await vias.nextSequence(dayA1.id, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('RT9REPO-001: deleteLeg removes only the named leg’s vias', async () => {
|
||||
const { dayA1 } = fixture();
|
||||
const keep = await vias.insertVia({ day_id: dayA1.id, after_order_index: 1, sequence: 0, lat: 1, lng: 1 });
|
||||
await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
|
||||
await vias.deleteLeg(dayA1.id, 0);
|
||||
|
||||
const rows = await vias.listForDay(dayA1.id);
|
||||
expect(rows.map((v) => v.id)).toEqual([keep]);
|
||||
});
|
||||
|
||||
it('RT14REPO-001: existsInDay is true only for the exact (id, day) pair', async () => {
|
||||
const { dayA1, dayA2 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
expect(await vias.existsInDay(id, dayA1.id)).toBe(true);
|
||||
expect(await vias.existsInDay(id, dayA2.id)).toBe(false);
|
||||
expect(await vias.existsInDay(999999, dayA1.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('RT15REPO-001: moveCoordinates writes lat/lng only, unscoped by day (matches the legacy statement exactly)', async () => {
|
||||
const { dayA1, dayA2 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 3, sequence: 0, lat: 1, lng: 1 });
|
||||
await vias.moveCoordinates(id, 9, 9);
|
||||
const row = await vias.findById(id);
|
||||
expect(row).toMatchObject({ lat: 9, lng: 9, after_order_index: 3, day_id: dayA1.id });
|
||||
expect(dayA2.id).not.toBe(dayA1.id);
|
||||
});
|
||||
|
||||
it('RT16REPO-001: moveCoordinatesAndAnchor writes lat/lng/after_order_index, unscoped by day', async () => {
|
||||
const { dayA1 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
await vias.moveCoordinatesAndAnchor(id, 9, 9, 5);
|
||||
expect(await vias.findById(id)).toMatchObject({ lat: 9, lng: 9, after_order_index: 5 });
|
||||
});
|
||||
|
||||
it('RT18REPO-001: listAnchors returns id/after_order_index/sequence for the day only', async () => {
|
||||
const { dayA1, dayA2 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 2, sequence: 1, lat: 1, lng: 1 });
|
||||
await vias.insertVia({ day_id: dayA2.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
|
||||
const rows = await vias.listAnchors(dayA1.id);
|
||||
expect(rows).toEqual([{ id, after_order_index: 2, sequence: 1 }]);
|
||||
});
|
||||
|
||||
it('RT21REPO-001: listLegs returns id/after_order_index only, for the day', async () => {
|
||||
const { dayA1 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 2, sequence: 1, lat: 1, lng: 1 });
|
||||
expect(await vias.listLegs(dayA1.id)).toEqual([{ id, after_order_index: 2 }]);
|
||||
});
|
||||
|
||||
it('RT19REPO-001/RT23REPO-001: deleteInDay is silent, deleteInDayCounted reports the row count (0 on a miss)', async () => {
|
||||
const { dayA1, dayA2 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
|
||||
// A foreign day scope never deletes.
|
||||
expect(await vias.deleteInDayCounted(id, dayA2.id)).toBe(0);
|
||||
expect(await vias.findById(id)).not.toBeNull();
|
||||
|
||||
await expect(vias.deleteInDay(999999, dayA1.id)).resolves.toBeUndefined();
|
||||
|
||||
expect(await vias.deleteInDayCounted(id, dayA1.id)).toBe(1);
|
||||
expect(await vias.findById(id)).toBeNull();
|
||||
expect(await vias.deleteInDayCounted(id, dayA1.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('RT20REPO-001: setAnchor writes after_order_index only, scoped to (id, day)', async () => {
|
||||
const { dayA1, dayA2 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
await vias.setAnchor(id, dayA2.id, 9);
|
||||
expect((await vias.findById(id))?.after_order_index).toBe(0);
|
||||
await vias.setAnchor(id, dayA1.id, 9);
|
||||
expect((await vias.findById(id))?.after_order_index).toBe(9);
|
||||
});
|
||||
|
||||
it('RT22REPO-001: setSequence writes sequence only, scoped to (id, day)', async () => {
|
||||
const { dayA1 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 0, sequence: 0, lat: 1, lng: 1 });
|
||||
await vias.setSequence(id, dayA1.id, 7);
|
||||
expect((await vias.findById(id))?.sequence).toBe(7);
|
||||
});
|
||||
|
||||
it('AS20REPO-001: listForReanchor returns id/after_order_index/lat/lng, the AnchoredVia shape', async () => {
|
||||
const { dayA1 } = fixture();
|
||||
const id = await vias.insertVia({ day_id: dayA1.id, after_order_index: 1, sequence: 0, lat: 12.5, lng: -3.2 });
|
||||
expect(await vias.listForReanchor(dayA1.id)).toEqual([{ id, after_order_index: 1, lat: 12.5, lng: -3.2 }]);
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repos
|
||||
import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo,
|
||||
createTestAssignmentParticipantsRepo, createTestDayAssignmentsRepo, createTestDaysRepo, createTestPlacesRepo,
|
||||
createTestTripMembersRepo, createTestDatabaseService,
|
||||
createTestTripMembersRepo, createTestDatabaseService, createTestRoadtripViasRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
let svc: AssignmentsService;
|
||||
@@ -84,6 +84,7 @@ beforeAll(async () => {
|
||||
await createTestDaysRepo(dbs.connection),
|
||||
await createTestPlacesRepo(dbs.connection),
|
||||
await createTestTripMembersRepo(dbs.connection),
|
||||
await createTestRoadtripViasRepo(dbs.connection),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,11 +42,11 @@ describe('Open charging data', () => {
|
||||
expect(matchChargingLocation([station], 48, 11, 'Test')).toEqual(station);
|
||||
});
|
||||
it('checks trip/place scope and ignores non-charging places', async () => {
|
||||
const get = vi.fn().mockReturnValueOnce(null).mockReturnValueOnce({ name: 'Hotel', lat: 48, lng: 11, stop_type: 'hotel' });
|
||||
const service = new ChargingService({ get } as never);
|
||||
const findChargingProbe = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce({ name: 'Hotel', lat: 48, lng: 11, stop_type: 'hotel' });
|
||||
const service = new ChargingService({ findChargingProbe } as never);
|
||||
await expect(service.read(1, 2)).rejects.toThrow();
|
||||
expect((await service.read(1, 2)).status).toBe('unknown');
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('trip_id = ?'), 2, 1);
|
||||
expect(findChargingProbe).toHaveBeenCalledWith(2, 1);
|
||||
});
|
||||
it('coalesces station requests and preserves availability if tariffs fail', async () => {
|
||||
const fetcher = vi.fn(async (url: string) => {
|
||||
@@ -55,7 +55,7 @@ describe('Open charging data', () => {
|
||||
return new Response('', { status: 503 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
const service = new ChargingService({ get: () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never);
|
||||
const service = new ChargingService({ findChargingProbe: async () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never);
|
||||
const [first, second] = await Promise.all([service.read(1, 2), service.read(1, 2)]);
|
||||
expect(first).toEqual(second);
|
||||
expect(first.status).toBe('ok');
|
||||
@@ -94,7 +94,7 @@ describe('Open charging data before the stop exists', () => {
|
||||
it('answers for a coordinate and lets the saved stop reuse that answer', async () => {
|
||||
const fetcher = answers({ items: [tariff], total_count: 1 });
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
const service = new ChargingService({ get: () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never);
|
||||
const service = new ChargingService({ findChargingProbe: async () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never);
|
||||
|
||||
const ahead = await service.lookup(48, 11, 'Test');
|
||||
expect(ahead.status).toBe('ok');
|
||||
@@ -114,35 +114,35 @@ describe('Open charging data before the stop exists', () => {
|
||||
if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] }));
|
||||
return new Response(JSON.stringify({ items: [station, { ...station, id: 'other', operator: { name: 'Other' } }], total_count: 2 }));
|
||||
}));
|
||||
const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Charging');
|
||||
const info = await new ChargingService({ findChargingProbe: async () => undefined } as never).lookup(48, 11, 'Charging');
|
||||
expect(info.status).toBe('ambiguous');
|
||||
expect(info.available).toBeNull();
|
||||
});
|
||||
|
||||
it('turns an unreachable source into an empty answer, not a failed request', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 503 })));
|
||||
const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Test');
|
||||
const info = await new ChargingService({ findChargingProbe: async () => undefined } as never).lookup(48, 11, 'Test');
|
||||
expect(info).toEqual({ ...empty('unavailable'), checkedAt: info.checkedAt });
|
||||
});
|
||||
|
||||
it('does not go to the database for a station that is not in it', async () => {
|
||||
vi.stubGlobal('fetch', answers({ items: [], total_count: 0 }));
|
||||
const get = vi.fn();
|
||||
await new ChargingService({ get } as never).lookup(48, 11, 'Test');
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
const findChargingProbe = vi.fn();
|
||||
await new ChargingService({ findChargingProbe } as never).lookup(48, 11, 'Test');
|
||||
expect(findChargingProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves the route and the MCP tool from one contract and one access check', async () => {
|
||||
const charging = { read: vi.fn(), lookup: vi.fn(async () => empty('unknown')) };
|
||||
const db = { canAccessTrip: vi.fn(async () => false) };
|
||||
const tool = new ChargingMcp(charging as never, db as never, {} as never);
|
||||
const tripsRepo = { findAccessible: vi.fn(async () => false) };
|
||||
const tool = new ChargingMcp(charging as never, tripsRepo as never, {} as never);
|
||||
const input = { lat: 48.1, lng: 11.5, name: 'Ladepark Nord' };
|
||||
|
||||
// A trip the caller cannot reach is refused before anything is fetched, exactly as
|
||||
// TripAccessGuard refuses it on the route.
|
||||
expect((await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never)).isError).toBe(true);
|
||||
expect(charging.lookup).not.toHaveBeenCalled();
|
||||
db.canAccessTrip.mockResolvedValue(true);
|
||||
tripsRepo.findAccessible.mockResolvedValue(true);
|
||||
await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never);
|
||||
await new ChargingLookupController(charging as never).lookup(input as unknown as ChargingLookupDto);
|
||||
|
||||
|
||||
@@ -1,82 +1,125 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
/**
|
||||
* `DayBoundariesService`/`DayBoundariesController`/`DayBoundariesMcp` (Plan
|
||||
* 3d Task 1). Rewritten off the hand-DDL `DatabaseService`-shaped facade
|
||||
* (§15a/§15c test risk 2) onto real rows through `DayAssignmentsRepository`/
|
||||
* `RoadtripDayBoundariesRepository` — the real schema, not a 4-column
|
||||
* hand-written subset.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { DayBoundariesService } from '../../../src/nest/roadtrip/day-boundaries.service';
|
||||
import { DayBoundariesController } from '../../../src/nest/roadtrip/day-boundaries.controller';
|
||||
import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
||||
import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
|
||||
const open: Database.Database[] = [];
|
||||
afterEach(() => open.splice(0).forEach(db => db.close()));
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
beforeAll(async () => { t = await createTestOrm(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function setup() {
|
||||
const raw = new Database(':memory:');
|
||||
open.push(raw);
|
||||
raw.pragma('foreign_keys = ON');
|
||||
raw.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER);
|
||||
CREATE TABLE day_assignments (id INTEGER PRIMARY KEY, day_id INTEGER REFERENCES days(id) ON DELETE CASCADE);
|
||||
CREATE TABLE roadtrip_day_boundaries (trip_id INTEGER, day_number INTEGER, from_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||
to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, fraction REAL, PRIMARY KEY(trip_id, day_number));
|
||||
INSERT INTO days VALUES (1, 10), (2, 20);
|
||||
INSERT INTO day_assignments VALUES (11, 1), (12, 1), (21, 2);`);
|
||||
const db = {
|
||||
get: (sql: string, ...params: unknown[]) => raw.prepare(sql).get(...params),
|
||||
all: (sql: string, ...params: unknown[]) => raw.prepare(sql).all(...params),
|
||||
run: (sql: string, ...params: unknown[]) => raw.prepare(sql).run(...params),
|
||||
canAccessTrip: vi.fn(async () => true),
|
||||
};
|
||||
const service = new DayBoundariesService(db as unknown as DatabaseService);
|
||||
const { user } = createUser(testDb);
|
||||
const tripA = createTrip(testDb, user.id);
|
||||
const tripB = createTrip(testDb, user.id);
|
||||
const dayA = createDay(testDb, tripA.id);
|
||||
const dayB = createDay(testDb, tripB.id);
|
||||
const placeA = createPlace(testDb, tripA.id);
|
||||
const placeB = createPlace(testDb, tripB.id);
|
||||
const from = createDayAssignment(testDb, dayA.id, placeA.id);
|
||||
const to = createDayAssignment(testDb, dayA.id, placeA.id);
|
||||
const foreign = createDayAssignment(testDb, dayB.id, placeB.id);
|
||||
|
||||
const dayAssignmentsRepo = t.repo(DayAssignments);
|
||||
const boundariesRepo = t.repo(RoadtripDayBoundaries);
|
||||
const service = new DayBoundariesService(dayAssignmentsRepo, boundariesRepo);
|
||||
const realtime = { broadcast: vi.fn() };
|
||||
const auth = { isDemoUser: vi.fn(() => false) };
|
||||
const guards = { hasTripPermission: vi.fn(() => true) };
|
||||
const mcp = new DayBoundariesMcp(service, db as unknown as DatabaseService, auth as never, guards as never, realtime as never, {} as never);
|
||||
return { raw, service, controller: new DayBoundariesController(service, realtime as never), mcp, db, auth, guards, realtime };
|
||||
const tripsRepo = { findAccessible: vi.fn(async () => ({ id: tripA.id, user_id: user.id })) };
|
||||
const mcp = new DayBoundariesMcp(service, tripsRepo as never, auth as never, guards as never, realtime as never, {} as never);
|
||||
return {
|
||||
service,
|
||||
controller: new DayBoundariesController(service, realtime as never),
|
||||
mcp,
|
||||
tripsRepo,
|
||||
auth,
|
||||
guards,
|
||||
realtime,
|
||||
tripA,
|
||||
tripB,
|
||||
fromId: from.id,
|
||||
toId: to.id,
|
||||
foreignId: foreign.id,
|
||||
};
|
||||
}
|
||||
const boundary = { day_number: 1, from_assignment_id: 11, to_assignment_id: 12, fraction: 0.4 };
|
||||
|
||||
it('replaces one day only, persists place snapping and cascades deleted visits', async () => {
|
||||
const { service, raw } = setup();
|
||||
await service.save(10, boundary);
|
||||
await service.save(10, { ...boundary, day_number: 2, fraction: 0.8 });
|
||||
expect(await service.save(10, { ...boundary, to_assignment_id: null })).toEqual([
|
||||
const { service, tripA, tripB, fromId, toId } = setup();
|
||||
const boundary = { day_number: 1, from_assignment_id: fromId, to_assignment_id: toId, fraction: 0.4 };
|
||||
await service.save(tripA.id, boundary);
|
||||
await service.save(tripA.id, { ...boundary, day_number: 2, fraction: 0.8 });
|
||||
expect(await service.save(tripA.id, { ...boundary, to_assignment_id: null })).toEqual([
|
||||
{ ...boundary, to_assignment_id: null, fraction: 1 }, { ...boundary, day_number: 2, fraction: 0.8 },
|
||||
]);
|
||||
expect(await service.list(20)).toEqual([]);
|
||||
await service.remove(20, 1);
|
||||
expect(await service.list(10)).toHaveLength(2);
|
||||
raw.prepare('DELETE FROM day_assignments WHERE id = 12').run();
|
||||
expect(await service.list(10)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]);
|
||||
expect(await service.list(tripB.id)).toEqual([]);
|
||||
await service.remove(tripB.id, 1);
|
||||
expect(await service.list(tripA.id)).toHaveLength(2);
|
||||
testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(toId);
|
||||
expect(await service.list(tripA.id)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]);
|
||||
});
|
||||
|
||||
it('rejects either endpoint from a different trip without writing', async () => {
|
||||
const { service } = setup();
|
||||
await expect(service.save(10, { ...boundary, from_assignment_id: 21 })).rejects.toThrow(HttpException);
|
||||
await expect(service.save(10, { ...boundary, to_assignment_id: 21 })).rejects.toThrow(HttpException);
|
||||
expect(await service.list(10)).toEqual([]);
|
||||
it('rejects either endpoint from a different trip without writing (RB2)', async () => {
|
||||
const { service, tripA, fromId, foreignId } = setup();
|
||||
const boundary = { day_number: 1, from_assignment_id: fromId, to_assignment_id: foreignId, fraction: 0.4 };
|
||||
await expect(service.save(tripA.id, { ...boundary, from_assignment_id: foreignId })).rejects.toThrow(HttpException);
|
||||
await expect(service.save(tripA.id, boundary)).rejects.toThrow(HttpException);
|
||||
expect(await service.list(tripA.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('RB2 (`belongs`): a foreign `to_assignment_id` is refused with the exact legacy message', async () => {
|
||||
// The async-closure-truthiness-guards ratchet
|
||||
// (`tests/integration/async-closure-truthiness-guards.test.ts`, RB2-001)
|
||||
// drives the real route end to end and is mutation-proved there (Task 0's
|
||||
// report: dropping the `await` on `belongs` turns a 404 into a 200). This
|
||||
// unit case pins the exact error string at the service boundary.
|
||||
const { service, tripA, fromId, foreignId } = setup();
|
||||
await expect(
|
||||
service.save(tripA.id, { day_number: 1, from_assignment_id: fromId, to_assignment_id: foreignId, fraction: 0.4 }),
|
||||
).rejects.toMatchObject({ response: { error: 'Stop not found' }, status: 404 });
|
||||
});
|
||||
|
||||
it('REST changes broadcast the complete state and exclude the saving socket', async () => {
|
||||
const { controller, realtime } = setup();
|
||||
await controller.save('10', boundary, 'self');
|
||||
expect(realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripBoundary:changed', { boundaries: [boundary] }, 'self');
|
||||
expect(await controller.remove('10', 1)).toEqual({ boundaries: [] });
|
||||
const { controller, realtime, tripA, fromId, toId } = setup();
|
||||
const boundary = { day_number: 1, from_assignment_id: fromId, to_assignment_id: toId, fraction: 0.4 };
|
||||
await controller.save(String(tripA.id), boundary, 'self');
|
||||
expect(realtime.broadcast).toHaveBeenCalledWith(String(tripA.id), 'roadtripBoundary:changed', { boundaries: [boundary] }, 'self');
|
||||
expect(await controller.remove(String(tripA.id), 1)).toEqual({ boundaries: [] });
|
||||
});
|
||||
|
||||
it('MCP checks demo, trip access and edit permission before saving', async () => {
|
||||
const s = setup(), ctx = { userId: 1 } as McpContext;
|
||||
const request = { tripId: 10, dayNumber: 1, boundary };
|
||||
const s = setup();
|
||||
const ctx = { userId: 1 } as McpContext;
|
||||
const boundary = { day_number: 1, from_assignment_id: s.fromId, to_assignment_id: s.toId, fraction: 0.4 };
|
||||
const request = { tripId: s.tripA.id, dayNumber: 1, boundary };
|
||||
s.auth.isDemoUser.mockReturnValue(true);
|
||||
await s.mcp.save(request, ctx);
|
||||
s.auth.isDemoUser.mockReturnValue(false);
|
||||
s.db.canAccessTrip.mockResolvedValue(false);
|
||||
s.tripsRepo.findAccessible.mockResolvedValue(undefined);
|
||||
await s.mcp.save(request, ctx);
|
||||
s.db.canAccessTrip.mockResolvedValue(true);
|
||||
s.tripsRepo.findAccessible.mockResolvedValue({ id: s.tripA.id, user_id: 1 });
|
||||
s.guards.hasTripPermission.mockReturnValue(false);
|
||||
await s.mcp.save(request, ctx);
|
||||
expect(await s.service.list(10)).toEqual([]);
|
||||
expect(await s.service.list(s.tripA.id)).toEqual([]);
|
||||
s.guards.hasTripPermission.mockReturnValue(true);
|
||||
await s.mcp.save(request, ctx);
|
||||
expect(await s.service.list(10)).toEqual([boundary]);
|
||||
expect(await s.service.list(s.tripA.id)).toEqual([boundary]);
|
||||
await s.mcp.save({ ...request, boundary: null }, ctx);
|
||||
expect(await s.service.list(10)).toEqual([]);
|
||||
expect(await s.service.list(s.tripA.id)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -12,12 +12,21 @@ const routeTestDb = new Database(':memory:');
|
||||
|
||||
async function setup() {
|
||||
const maps = { geocodeQuery: vi.fn(), reverseGeocode: vi.fn().mockResolvedValue({ name: null, address: null }) };
|
||||
const db = { canAccessTrip: vi.fn(async () => ({ user_id: 7 })), get: () => ({ role: 'user' }) };
|
||||
const tripsRepo = { findAccessible: vi.fn(async () => ({ user_id: 7 })) };
|
||||
const usersRepo = { getRole: vi.fn(async () => 'user') };
|
||||
const places = { create: vi.fn((_trip: string, stop: { name: string }) => ({ id: stop.name })), broadcast: vi.fn() };
|
||||
const assignments = { dayExists: vi.fn(() => true), createAssignment: vi.fn((dayId: number, placeId: string) => ({ dayId, placeId })), broadcast: vi.fn(), reconcile: vi.fn() };
|
||||
const permissions = { checkPermission: vi.fn(() => true) };
|
||||
const service = new GoogleRouteService(maps as never, db as never, places as never, assignments as never, permissions as never, await createTestUnitOfWork(routeTestDb));
|
||||
return { service, maps, db, places, assignments, permissions };
|
||||
const service = new GoogleRouteService(
|
||||
maps as never,
|
||||
places as never,
|
||||
assignments as never,
|
||||
permissions as never,
|
||||
await createTestUnitOfWork(routeTestDb),
|
||||
tripsRepo as never,
|
||||
usersRepo as never,
|
||||
);
|
||||
return { service, maps, tripsRepo, usersRepo, places, assignments, permissions };
|
||||
}
|
||||
|
||||
const input = { dayId: 3, stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Rome', lat: 41, lng: 12 }] };
|
||||
@@ -67,8 +76,8 @@ describe('Google route import', () => {
|
||||
});
|
||||
|
||||
it('checks membership, both editing permissions and the target day before writing', async () => {
|
||||
const { service, db, permissions, assignments, places } = await setup();
|
||||
db.canAccessTrip.mockResolvedValueOnce(null as never);
|
||||
const { service, tripsRepo, permissions, assignments, places } = await setup();
|
||||
tripsRepo.findAccessible.mockResolvedValueOnce(undefined as never);
|
||||
await expect(service.import(1, 7, input)).rejects.toThrow();
|
||||
permissions.checkPermission.mockReturnValueOnce(false);
|
||||
await expect(service.import(1, 7, input)).rejects.toThrow();
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
* column the statement never selects would go unnoticed there. These run the statement.
|
||||
*/
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestTripsRepo, createTestDaysRepo, createTestDayAssignmentsRepo } from '../../helpers/test-uow';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -51,12 +50,14 @@ async function setup() {
|
||||
});
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '09:00' WHERE id = ?").run(visits[0].id);
|
||||
const plans = new RoadtripPlanService(
|
||||
new DatabaseService(db, (await sharedTestOrm(db)).em),
|
||||
{ getUserSettings: () => ({}) } as never,
|
||||
{ read: () => ({}) } as never,
|
||||
hourlyRouter() as never,
|
||||
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
|
||||
{ list: () => [] } as never,
|
||||
await createTestTripsRepo(db),
|
||||
await createTestDaysRepo(db),
|
||||
await createTestDayAssignmentsRepo(db),
|
||||
);
|
||||
return { user, trip, visits, plans };
|
||||
}
|
||||
|
||||
@@ -35,13 +35,13 @@ function setup() {
|
||||
const realtime = { broadcast: vi.fn() };
|
||||
const tripSettings = new Map<number, Record<string, unknown>>([[10, { ...settings }]]);
|
||||
const preferenceDb = {
|
||||
all: (_sql: string, tripId: number) =>
|
||||
listForTrip: (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) =>
|
||||
upsertValue: vi.fn((tripId: number, key: string, value: string) =>
|
||||
tripSettings.set(tripId, { ...tripSettings.get(tripId), [key]: JSON.parse(value) }),
|
||||
),
|
||||
};
|
||||
const preferences = new RoadtripPreferencesService(preferenceDb as never, realtime as never, uowStub);
|
||||
const preferences = new RoadtripPreferencesService(realtime as never, uowStub, preferenceDb 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,
|
||||
@@ -58,10 +58,9 @@ function setup() {
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
}));
|
||||
const db = {
|
||||
canAccessTrip: vi.fn(async () => true),
|
||||
all: vi.fn((sql: string) => (sql.includes('FROM day_assignments') ? visits : days)),
|
||||
};
|
||||
const tripsRepo = { findAccessible: vi.fn(async () => true) };
|
||||
const daysRepo = { listPlanDays: vi.fn(async () => days) };
|
||||
const dayAssignmentsRepo = { listRoadtripVisits: vi.fn(async () => visits) };
|
||||
const router = {
|
||||
profiles: () => ['driving'],
|
||||
route: vi.fn(async (_user: number, _trip: number, _day: number, points: { lat: number; lng: number }[]) => ({
|
||||
@@ -87,14 +86,16 @@ function setup() {
|
||||
const roadtrip = { listForTrip: vi.fn(() => []), tracksForTrip: vi.fn(() => []) };
|
||||
const boundaries = { list: vi.fn(() => []) };
|
||||
const plans = new RoadtripPlanService(
|
||||
db as never,
|
||||
store as never,
|
||||
preferences,
|
||||
router as never,
|
||||
roadtrip as never,
|
||||
boundaries as never,
|
||||
tripsRepo as never,
|
||||
daysRepo as never,
|
||||
dayAssignmentsRepo as never,
|
||||
);
|
||||
return { preferenceDb, preferences, store, realtime, db, plans, router, visits, days, boundaries };
|
||||
return { preferenceDb, preferences, store, realtime, tripsRepo, daysRepo, dayAssignmentsRepo, plans, router, visits, days, boundaries };
|
||||
}
|
||||
|
||||
describe('roadtrip preferences', () => {
|
||||
@@ -106,7 +107,7 @@ describe('roadtrip preferences', () => {
|
||||
it('validates the complete window before an atomic write and broadcasts to every member of its trip', async () => {
|
||||
const s = setup();
|
||||
await expect(s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).rejects.toThrow();
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
expect(s.preferenceDb.upsertValue).not.toHaveBeenCalled();
|
||||
await s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' });
|
||||
// The fourth argument is the socket that saved, so the tab that made the
|
||||
// change is left out of its own echo. Undefined here: no header was sent.
|
||||
@@ -133,12 +134,12 @@ describe('roadtrip preferences', () => {
|
||||
s.preferences,
|
||||
{ isDemoUser: () => true } as never,
|
||||
{} as never,
|
||||
s.db as never,
|
||||
s.tripsRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
s.preferenceDb.run.mockClear();
|
||||
s.preferenceDb.upsertValue.mockClear();
|
||||
await mcp.update({ tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx);
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
expect(s.preferenceDb.upsertValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it('tells the assistant why a window was refused instead of the exception class name', async () => {
|
||||
// The service refuses an inverted window with the `{ error }` body the route
|
||||
@@ -148,7 +149,7 @@ describe('roadtrip preferences', () => {
|
||||
s.preferences,
|
||||
{ isDemoUser: () => false } as never,
|
||||
{} as never,
|
||||
s.db as never,
|
||||
s.tripsRepo as never,
|
||||
{ hasTripPermission: () => true } as never,
|
||||
);
|
||||
const res = await mcp.update({ tripId: 10, settings: { roadtrip_day_end: '06:00' } }, ctx);
|
||||
@@ -173,13 +174,14 @@ describe('browser-independent roadtrip calculation', () => {
|
||||
const s = setup();
|
||||
await s.plans.calculate(10, 5, { roadtrip_day_end: '20:00' });
|
||||
expect((await s.preferences.read(10)).roadtrip_day_end).toBe('10:00');
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
expect(s.preferenceDb.upsertValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it('checks trip access before reading or routing', async () => {
|
||||
const s = setup();
|
||||
s.db.canAccessTrip.mockResolvedValue(false);
|
||||
s.tripsRepo.findAccessible.mockResolvedValue(false);
|
||||
await expect(s.plans.calculate(20, 5)).rejects.toThrow();
|
||||
expect(s.db.all).not.toHaveBeenCalled();
|
||||
expect(s.daysRepo.listPlanDays).not.toHaveBeenCalled();
|
||||
expect(s.dayAssignmentsRepo.listRoadtripVisits).not.toHaveBeenCalled();
|
||||
expect(s.router.route).not.toHaveBeenCalled();
|
||||
});
|
||||
it('tells the assistant why a read, a calculation or a corridor search was refused', async () => {
|
||||
@@ -189,11 +191,11 @@ describe('browser-independent roadtrip calculation', () => {
|
||||
const s = setup();
|
||||
const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
|
||||
const reason = (res: { content: { text: string }[]; isError?: boolean }) => [res.isError, res.content[0].text];
|
||||
s.db.canAccessTrip.mockResolvedValue(false);
|
||||
s.tripsRepo.findAccessible.mockResolvedValue(false);
|
||||
expect(reason(await mcp.context({ tripId: 20 }, ctx))).toEqual([true, 'Trip not found']);
|
||||
expect(reason(await mcp.calculate({ tripId: 20, includeGeometry: false }, ctx))).toEqual([true, 'Trip not found']);
|
||||
expect(reason(await mcp.corridor({ tripId: 20, dayNumber: 1, category: 'fuel', widthKm: 5, offset: 0 } as never, ctx))).toEqual([true, 'Trip not found']);
|
||||
s.db.canAccessTrip.mockResolvedValue(true);
|
||||
s.tripsRepo.findAccessible.mockResolvedValue(true);
|
||||
const window = await mcp.calculate({ tripId: 10, includeGeometry: false, settings: { roadtrip_day_start: '18:00', roadtrip_day_end: '08:00' } }, ctx);
|
||||
expect(reason(window)).toEqual([true, 'Day end must be later than day start.']);
|
||||
expect(s.router.route).not.toHaveBeenCalled();
|
||||
@@ -281,7 +283,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, s.db as never, {} as never),
|
||||
new RoadtripPreferencesMcp(s.preferences, {} as never, addons as never, s.tripsRepo as never, {} as never),
|
||||
new RoadtripPlanningMcp(s.plans, {} as never, addons as never),
|
||||
],
|
||||
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
|
||||
@@ -352,7 +354,7 @@ describe('Roadtrip MCP registration and search', () => {
|
||||
const guards = { hasTripPermission: vi.fn(() => false) };
|
||||
const mcp = new RoadtripMcp(
|
||||
service as never,
|
||||
{ canAccessTrip: () => true } as never,
|
||||
{ findAccessible: () => true } as never,
|
||||
guards as never,
|
||||
{ isDemoUser: () => false } as never,
|
||||
{} as never,
|
||||
@@ -374,9 +376,9 @@ describe('Roadtrip MCP registration and search', () => {
|
||||
describe('MCP trip preferences authorization', () => {
|
||||
it('reads shared preferences and refuses inaccessible trips', async () => {
|
||||
const s = setup();
|
||||
const tool = new RoadtripPreferencesMcp(s.preferences, { isDemoUser: () => false } as never, {} as never, s.db as never, { hasTripPermission: () => true } as never);
|
||||
const tool = new RoadtripPreferencesMcp(s.preferences, { isDemoUser: () => false } as never, {} as never, s.tripsRepo as never, { hasTripPermission: () => true } as never);
|
||||
expect(JSON.stringify(await tool.read({ tripId: 10 }, ctx))).toContain('100');
|
||||
s.db.canAccessTrip.mockResolvedValue(false);
|
||||
s.tripsRepo.findAccessible.mockResolvedValue(false);
|
||||
expect((await tool.read({ tripId: 10 }, ctx)).isError).toBe(true);
|
||||
expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true);
|
||||
});
|
||||
@@ -384,7 +386,7 @@ describe('MCP trip preferences authorization', () => {
|
||||
const s = setup();
|
||||
const auth = { isDemoUser: vi.fn(() => true) };
|
||||
const guards = { hasTripPermission: vi.fn(() => false) };
|
||||
const tool = new RoadtripPreferencesMcp(s.preferences, auth as never, {} as never, s.db as never, guards as never);
|
||||
const tool = new RoadtripPreferencesMcp(s.preferences, auth as never, {} as never, s.tripsRepo as never, guards as never);
|
||||
expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true);
|
||||
auth.isDemoUser.mockReturnValue(false);
|
||||
expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true);
|
||||
|
||||
@@ -9,14 +9,14 @@ import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp'
|
||||
const ctx = { userId: 5 } as McpContext;
|
||||
describe('roadtrip read tools', () => {
|
||||
it('checks trip access before querying charging and hazards', async () => {
|
||||
const db = { canAccessTrip: vi.fn(async () => false) };
|
||||
const tripsRepo = { findAccessible: vi.fn(async () => false) };
|
||||
const source = { read: vi.fn(async () => ({ sources: ['public'] })) };
|
||||
const charging = new ChargingMcp(source as never, db as never, {} as never);
|
||||
const hazards = new RoadtripHazardsMcp(source as never, db as never, {} as never);
|
||||
const charging = new ChargingMcp(source as never, tripsRepo as never, {} as never);
|
||||
const hazards = new RoadtripHazardsMcp(source as never, tripsRepo as never, {} as never);
|
||||
expect((await charging.read({ tripId: 10, placeId: 2 }, ctx)).isError).toBe(true);
|
||||
expect((await hazards.read({ tripId: 10 }, ctx)).isError).toBe(true);
|
||||
expect(source.read).not.toHaveBeenCalled();
|
||||
db.canAccessTrip.mockResolvedValue(true);
|
||||
tripsRepo.findAccessible.mockResolvedValue(true);
|
||||
expect(JSON.stringify(await charging.read({ tripId: 10, placeId: 2 }, ctx))).toContain('public');
|
||||
expect(source.read).toHaveBeenCalledWith(10, 2);
|
||||
expect(JSON.stringify(await hazards.read({ tripId: 10 }, ctx))).toContain('public');
|
||||
@@ -51,8 +51,8 @@ describe('roadtrip read tools', () => {
|
||||
const refuse = (error: string, status: number) => () => { throw new HttpException({ error }, status); };
|
||||
const text = (res: { content: { text: string }[]; isError?: boolean }) => [res.isError, res.content[0].text];
|
||||
|
||||
const db = { canAccessTrip: vi.fn(async () => true) };
|
||||
const charging = new ChargingMcp({ read: refuse('Place not found', 404) } as never, db as never, {} as never);
|
||||
const tripsRepo = { findAccessible: vi.fn(async () => true) };
|
||||
const charging = new ChargingMcp({ read: refuse('Place not found', 404) } as never, tripsRepo as never, {} as never);
|
||||
expect(text(await charging.read({ tripId: 10, placeId: 2 }, ctx))).toEqual([true, 'Place not found']);
|
||||
|
||||
const auth = { isDemoUser: vi.fn(async () => false) };
|
||||
@@ -63,7 +63,7 @@ describe('roadtrip read tools', () => {
|
||||
|
||||
const realtime = { broadcast: vi.fn() };
|
||||
const guards = { hasTripPermission: vi.fn(() => true) };
|
||||
const boundaries = new DayBoundariesMcp({ save: refuse('Stop not found', 404) } as never, db as never, auth as never, guards as never, realtime as never, {} as never);
|
||||
const boundaries = new DayBoundariesMcp({ save: refuse('Stop not found', 404) } as never, tripsRepo as never, auth as never, guards as never, realtime as never, {} as never);
|
||||
const boundary = { day_number: 1, from_assignment_id: 11, to_assignment_id: 12, fraction: 0.4 };
|
||||
expect(text(await boundaries.save({ tripId: 10, dayNumber: 1, boundary }, ctx))).toEqual([true, 'Stop not found']);
|
||||
expect(realtime.broadcast).not.toHaveBeenCalled();
|
||||
@@ -71,11 +71,11 @@ describe('roadtrip read tools', () => {
|
||||
|
||||
it('does not expose manual boundaries to nonmembers', async () => {
|
||||
const boundaries = { list: vi.fn(() => [{ day_number: 1 }]) };
|
||||
const db = { canAccessTrip: vi.fn(async () => false) };
|
||||
const tool = new DayBoundariesMcp(boundaries as never, db as never, {} as never, {} as never, {} as never, {} as never);
|
||||
const tripsRepo = { findAccessible: vi.fn(async () => false) };
|
||||
const tool = new DayBoundariesMcp(boundaries as never, tripsRepo as never, {} as never, {} as never, {} as never, {} as never);
|
||||
expect((await tool.list({ tripId: 10 }, ctx)).isError).toBe(true);
|
||||
expect(boundaries.list).not.toHaveBeenCalled();
|
||||
db.canAccessTrip.mockResolvedValue(true);
|
||||
tripsRepo.findAccessible.mockResolvedValue(true);
|
||||
expect(JSON.stringify(await tool.list({ tripId: 10 }, ctx))).toContain('day_number');
|
||||
expect(boundaries.list).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
@@ -1,320 +1,306 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createDay, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
|
||||
import { Days } from '../../../src/db/entities/Days.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
|
||||
import { RoadtripDayTracks } from '../../../src/db/entities/RoadtripDayTracks.entity';
|
||||
|
||||
/**
|
||||
* Via points, against a real SQLite. The table is tiny and the interesting parts are the
|
||||
* ordering and the day scoping, both of which are SQL — mocking the DB would test the mock.
|
||||
* Via points and tracks, against real rows through
|
||||
* `RoadtripViasRepository`/`RoadtripDayTracksRepository`/`DaysRepository`/
|
||||
* `PlacesRepository` (Plan 3d Task 1) — moved off the hand-DDL, raw
|
||||
* `DatabaseService`-shaped facade (§15a/§15c test risk 2) onto real schema.
|
||||
*/
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
beforeAll(async () => { t = await createTestOrm(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
async function makeService() {
|
||||
const raw = new Database(':memory:');
|
||||
raw.exec(`
|
||||
CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER NOT NULL);
|
||||
CREATE TABLE roadtrip_vias (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
after_order_index INTEGER NOT NULL,
|
||||
sequence INTEGER NOT NULL DEFAULT 0,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE places (
|
||||
id INTEGER PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
route_geometry TEXT
|
||||
);
|
||||
CREATE TABLE roadtrip_day_tracks (
|
||||
day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE,
|
||||
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||
stray_km REAL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO days (id, trip_id) VALUES (1, 7), (2, 7), (3, 99);
|
||||
INSERT INTO places (id, trip_id, name, route_geometry) VALUES
|
||||
(10, 7, 'Atlantic Road', '[[52,13],[52,14]]'),
|
||||
(11, 7, 'A hotel', NULL),
|
||||
(12, 99, 'Somebody else\u2019s track', '[[40,2],[40,3]]');
|
||||
`);
|
||||
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[]),
|
||||
};
|
||||
// The realtime side is exercised through the controller and the MCP layer; here the
|
||||
// service is under test for what it writes, so a broadcast that goes nowhere is right.
|
||||
return new RoadtripService(db as never, { broadcast: () => {} } as never, await createTestUnitOfWork(raw));
|
||||
return new RoadtripService(
|
||||
{ broadcast: () => {} } as never,
|
||||
await createTestUnitOfWork(testDb),
|
||||
t.repo(Days),
|
||||
t.repo(Places),
|
||||
t.repo(RoadtripVias),
|
||||
t.repo(RoadtripDayTracks),
|
||||
);
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const { user } = createUser(testDb);
|
||||
const tripA = createTrip(testDb, user.id);
|
||||
const tripB = createTrip(testDb, user.id);
|
||||
const dayA1 = createDay(testDb, tripA.id);
|
||||
const dayA2 = createDay(testDb, tripA.id);
|
||||
const dayB1 = createDay(testDb, tripB.id);
|
||||
const track = createPlace(testDb, tripA.id, { name: 'Atlantic Road' });
|
||||
const hotel = createPlace(testDb, tripA.id, { name: 'A hotel' });
|
||||
const otherTrack = createPlace(testDb, tripB.id, { name: 'Somebody else’s track' });
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[52,13],[52,14]]', track.id);
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[40,2],[40,3]]', otherTrack.id);
|
||||
return { tripA, tripB, dayA1, dayA2, dayB1, track, hotel, otherTrack };
|
||||
}
|
||||
|
||||
describe('RoadtripService', () => {
|
||||
let service: RoadtripService;
|
||||
beforeEach(async () => { service = await makeService(); });
|
||||
let f: ReturnType<typeof fixture>;
|
||||
beforeEach(async () => {
|
||||
service = await makeService();
|
||||
f = fixture();
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', async () => {
|
||||
// The trip guard proves access to the trip; this is what stops a valid day id from
|
||||
// someone else’s trip being edited through one the caller can reach.
|
||||
expect((await service.dayExists(1, 7))).toBe(true);
|
||||
expect((await service.dayExists(3, 7))).toBe(false);
|
||||
expect(await service.dayExists(f.dayA1.id, f.tripA.id)).toBe(true);
|
||||
expect(await service.dayExists(f.dayB1.id, f.tripA.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-002: vias appended after the same stop keep the order they were added in', async () => {
|
||||
const first = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const second = (await service.create(1, { after_order_index: 0, lat: 53.5, lng: 10.5 }));
|
||||
const first = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const second = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53.5, lng: 10.5 });
|
||||
|
||||
expect(first.sequence).toBe(0);
|
||||
expect(second.sequence).toBe(1);
|
||||
expect((await service.listForDay(1)).map(v => v.id)).toEqual([first.id, second.id]);
|
||||
expect((await service.listForDay(f.dayA1.id)).map(v => v.id)).toEqual([first.id, second.id]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-003: the list is ordered by stop first, then by sequence', async () => {
|
||||
const late = (await service.create(1, { after_order_index: 2, lat: 51, lng: 12 }));
|
||||
const early = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const late = await service.create(f.dayA1.id, { after_order_index: 2, lat: 51, lng: 12 });
|
||||
const early = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
expect((await service.listForDay(1)).map(v => v.id)).toEqual([early.id, late.id]);
|
||||
expect((await service.listForDay(f.dayA1.id)).map(v => v.id)).toEqual([early.id, late.id]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-004: the trip listing spans its days and stops at the trip boundary', async () => {
|
||||
(await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
(await service.create(2, { after_order_index: 0, lat: 52, lng: 11 }));
|
||||
(await service.create(3, { after_order_index: 0, lat: 50, lng: 14 }));
|
||||
await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
await service.create(f.dayA2.id, { after_order_index: 0, lat: 52, lng: 11 });
|
||||
await service.create(f.dayB1.id, { after_order_index: 0, lat: 50, lng: 14 });
|
||||
|
||||
// Day 3 belongs to trip 99 and must not appear in trip 7.
|
||||
expect((await service.listForTrip(7)).map(v => v.day_id)).toEqual([1, 2]);
|
||||
// dayB1 belongs to tripB and must not appear in tripA's listing.
|
||||
expect((await service.listForTrip(f.tripA.id)).map(v => v.day_id).sort((a, b) => a - b)).toEqual(
|
||||
[f.dayA1.id, f.dayA2.id].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-005: a move with no new anchor leaves the chain alone', async () => {
|
||||
// The drag stayed between the same two stops, so there is nothing to re-pin. The
|
||||
// sequence is never touched by a move either way: it orders the vias that share one
|
||||
// anchor, and dragging one does not reorder its neighbours.
|
||||
const via = (await service.create(1, { after_order_index: 1, lat: 53, lng: 10 }));
|
||||
const via = await service.create(f.dayA1.id, { after_order_index: 1, lat: 53, lng: 10 });
|
||||
|
||||
const moved = (await service.move(via.id, 1, 52.5, 11.5));
|
||||
const moved = await service.move(via.id, f.dayA1.id, 52.5, 11.5);
|
||||
|
||||
expect(moved).toMatchObject({ lat: 52.5, lng: 11.5, after_order_index: 1, sequence: via.sequence });
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-007: a via dragged past a stop is re-pinned to the leg it landed on', async () => {
|
||||
// The bug this exists for: a drag used to carry only the coordinates, so a via pulled
|
||||
// beyond the stop it used to precede kept claiming the earlier leg. The route then ran
|
||||
// out to the point and back before carrying on, which reads as the drag doing nothing.
|
||||
const via = (await service.create(1, { after_order_index: 0, lat: 53.87, lng: 10.7 }));
|
||||
const via = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53.87, lng: 10.7 });
|
||||
|
||||
const moved = (await service.move(via.id, 1, 53.87, 11.53, 1));
|
||||
const moved = await service.move(via.id, f.dayA1.id, 53.87, 11.53, 1);
|
||||
|
||||
expect(moved).toMatchObject({ lat: 53.87, lng: 11.53, after_order_index: 1 });
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-008: a re-pin through the wrong day changes nothing at all', async () => {
|
||||
// The day check has to guard the anchor as well as the coordinates, or an id from
|
||||
// another day could be renumbered through a day the caller does happen to reach.
|
||||
const via = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const via = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
expect((await service.move(via.id, 2, 51, 12, 3))).toBeNull();
|
||||
expect((await service.listForDay(1))[0]).toMatchObject({ lat: 53, lng: 10, after_order_index: 0 });
|
||||
expect(await service.move(via.id, f.dayA2.id, 51, 12, 3)).toBeNull();
|
||||
expect((await service.listForDay(f.dayA1.id))[0]).toMatchObject({ lat: 53, lng: 10, after_order_index: 0 });
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-006: a via cannot be moved or removed through the wrong day', async () => {
|
||||
const via = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const via = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
expect((await service.move(via.id, 2, 0, 0))).toBeNull();
|
||||
expect((await service.remove(via.id, 2))).toBe(false);
|
||||
// Still where it was.
|
||||
expect((await service.listForDay(1))).toHaveLength(1);
|
||||
expect(await service.move(via.id, f.dayA2.id, 0, 0)).toBeNull();
|
||||
expect(await service.remove(via.id, f.dayA2.id)).toBe(false);
|
||||
expect(await service.listForDay(f.dayA1.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-008: re-anchoring moves the vias it names and leaves the rest alone', async () => {
|
||||
const a = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const b = (await service.create(1, { after_order_index: 1, lat: 54, lng: 11 }));
|
||||
const c = (await service.create(1, { after_order_index: 2, lat: 55, lng: 12 }));
|
||||
it('ROADTRIP-SVC-008b: re-anchoring moves the vias it names and leaves the rest alone', async () => {
|
||||
const a = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const b = await service.create(f.dayA1.id, { after_order_index: 1, lat: 54, lng: 11 });
|
||||
const c = await service.create(f.dayA1.id, { after_order_index: 2, lat: 55, lng: 12 });
|
||||
|
||||
const after = (await service.reanchor(1, { vias: [{ id: b.id, after_order_index: 2 }, { id: c.id, after_order_index: 3 }] }));
|
||||
const after = await service.reanchor(f.dayA1.id, { vias: [{ id: b.id, after_order_index: 2 }, { id: c.id, after_order_index: 3 }] });
|
||||
|
||||
const byId = new Map(after.map(v => [v.id, v.after_order_index]));
|
||||
expect(byId.get(a.id)).toBe(0);
|
||||
expect(byId.get(b.id)).toBe(2);
|
||||
expect(byId.get(c.id)).toBe(3);
|
||||
// Where a via sits is untouched — only which leg it belongs to changed.
|
||||
expect(after.find(v => v.id === b.id)?.lat).toBe(54);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-009: re-anchoring deletes the vias whose leg stopped existing', async () => {
|
||||
const gone = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const kept = (await service.create(1, { after_order_index: 2, lat: 54, lng: 11 }));
|
||||
const gone = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const kept = await service.create(f.dayA1.id, { after_order_index: 2, lat: 54, lng: 11 });
|
||||
|
||||
const after = (await service.reanchor(1, {
|
||||
const after = await service.reanchor(f.dayA1.id, {
|
||||
vias: [{ id: kept.id, after_order_index: 1 }],
|
||||
remove: [gone.id],
|
||||
}));
|
||||
});
|
||||
|
||||
expect(after.map(v => v.id)).toEqual([kept.id]);
|
||||
expect(after[0].after_order_index).toBe(1);
|
||||
expect(after[0]!.after_order_index).toBe(1);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-010: a via of another day cannot be renumbered through this one', async () => {
|
||||
// The batch is trusted to name ids, so `AND day_id = ?` is the only thing standing
|
||||
// between a day the caller can reach and every via of the trip.
|
||||
const other = (await service.create(2, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const other = await service.create(f.dayA2.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
(await service.reanchor(1, { vias: [{ id: other.id, after_order_index: 9 }], remove: [other.id] }));
|
||||
await service.reanchor(f.dayA1.id, { vias: [{ id: other.id, after_order_index: 9 }], remove: [other.id] });
|
||||
|
||||
const untouched = (await service.listForDay(2));
|
||||
const untouched = await service.listForDay(f.dayA2.id);
|
||||
expect(untouched.map(v => v.id)).toEqual([other.id]);
|
||||
expect(untouched[0].after_order_index).toBe(0);
|
||||
expect(untouched[0]!.after_order_index).toBe(0);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-011: an id that is gone does not fail the rest of the batch', async () => {
|
||||
// The caller computed the batch from a snapshot; a via somebody else deleted in the
|
||||
// meantime must not leave the remaining ones mis-pinned.
|
||||
const kept = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const kept = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
const after = (await service.reanchor(1, {
|
||||
const after = await service.reanchor(f.dayA1.id, {
|
||||
vias: [{ id: 9999, after_order_index: 4 }, { id: kept.id, after_order_index: 1 }],
|
||||
}));
|
||||
});
|
||||
|
||||
expect(after.map(v => v.after_order_index)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-016: merging two populated legs keeps the drive going one way', async () => {
|
||||
// The case none of the four above reach: they all re-anchor onto legs that
|
||||
// are empty. `sequence` is allocated per leg and starts at 0 in each, so
|
||||
// merging two populated legs put leg A's 0,1 beside leg B's 0,1 — and
|
||||
// everything that draws the route orders by sequence, so the drive ran A0,
|
||||
// B0, A1, B1: forward, back, and forward again. Persisted, surviving a
|
||||
// reload, and unrepairable except by deleting the vias.
|
||||
const a0 = (await service.create(1, { after_order_index: 1, lat: 50.0, lng: 10 }));
|
||||
const a1 = (await service.create(1, { after_order_index: 1, lat: 50.1, lng: 10 }));
|
||||
const b0 = (await service.create(1, { after_order_index: 2, lat: 50.2, lng: 10 }));
|
||||
const b1 = (await service.create(1, { after_order_index: 2, lat: 50.3, lng: 10 }));
|
||||
const a0 = await service.create(f.dayA1.id, { after_order_index: 1, lat: 50.0, lng: 10 });
|
||||
const a1 = await service.create(f.dayA1.id, { after_order_index: 1, lat: 50.1, lng: 10 });
|
||||
const b0 = await service.create(f.dayA1.id, { after_order_index: 2, lat: 50.2, lng: 10 });
|
||||
const b1 = await service.create(f.dayA1.id, { after_order_index: 2, lat: 50.3, lng: 10 });
|
||||
expect([a0.sequence, a1.sequence, b0.sequence, b1.sequence]).toEqual([0, 1, 0, 1]);
|
||||
|
||||
// The stop between the two legs is removed, so leg 2 merges into leg 1 —
|
||||
// exactly what reanchorAfterRemove computes on the client.
|
||||
const after = (await service.reanchor(1, {
|
||||
const after = await service.reanchor(f.dayA1.id, {
|
||||
vias: [{ id: b0.id, after_order_index: 1 }, { id: b1.id, after_order_index: 1 }],
|
||||
}));
|
||||
});
|
||||
|
||||
const onLeg = after.filter(v => v.after_order_index === 1).sort((x, y) => x.sequence - y.sequence);
|
||||
expect(onLeg.map(v => v.id)).toEqual([a0.id, a1.id, b0.id, b1.id]);
|
||||
// Strictly increasing across the whole leg, with no value used twice.
|
||||
expect(onLeg.map(v => v.sequence)).toEqual([0, 1, 2, 3]);
|
||||
// Which is the geographic order the day is actually driven in.
|
||||
expect(onLeg.map(v => v.lat)).toEqual([50.0, 50.1, 50.2, 50.3]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-017: a leg nothing merged into keeps the order it had', async () => {
|
||||
const first = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const second = (await service.create(1, { after_order_index: 0, lat: 54, lng: 11 }));
|
||||
const elsewhere = (await service.create(1, { after_order_index: 3, lat: 55, lng: 12 }));
|
||||
const first = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const second = await service.create(f.dayA1.id, { after_order_index: 0, lat: 54, lng: 11 });
|
||||
const elsewhere = await service.create(f.dayA1.id, { after_order_index: 3, lat: 55, lng: 12 });
|
||||
|
||||
const after = (await service.reanchor(1, { vias: [{ id: elsewhere.id, after_order_index: 2 }] }));
|
||||
const after = await service.reanchor(f.dayA1.id, { vias: [{ id: elsewhere.id, after_order_index: 2 }] });
|
||||
|
||||
const leg0 = after.filter(v => v.after_order_index === 0).sort((x, y) => x.sequence - y.sequence);
|
||||
expect(leg0.map(v => v.id)).toEqual([first.id, second.id]);
|
||||
expect(leg0.map(v => v.sequence)).toEqual([0, 1]);
|
||||
// And the one that moved is alone on its new leg, numbered from zero.
|
||||
expect(after.find(v => v.id === elsewhere.id)?.sequence).toBe(0);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-012: a chain lands in the order it was sent, per leg', async () => {
|
||||
const vias = (await service.createMany(1, { vias: [
|
||||
const vias = await service.createMany(f.dayA1.id, { vias: [
|
||||
{ after_order_index: 0, lat: 53.0, lng: 10.0 },
|
||||
{ after_order_index: 0, lat: 53.1, lng: 10.1 },
|
||||
{ after_order_index: 1, lat: 53.2, lng: 10.2 },
|
||||
] }));
|
||||
] });
|
||||
|
||||
expect(vias.map(v => [v.after_order_index, v.sequence])).toEqual([[0, 0], [0, 1], [1, 0]]);
|
||||
expect(vias.map(v => v.lat)).toEqual([53.0, 53.1, 53.2]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-013: a chain appends to what a leg already has', async () => {
|
||||
(await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const vias = (await service.createMany(1, { vias: [{ after_order_index: 0, lat: 54, lng: 11 }] }));
|
||||
await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const vias = await service.createMany(f.dayA1.id, { vias: [{ after_order_index: 0, lat: 54, lng: 11 }] });
|
||||
|
||||
expect(vias.map(v => v.sequence)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-014: replace_legs clears only the legs it names', async () => {
|
||||
// The point of naming legs rather than taking a flag for the day: a hand-placed
|
||||
// detour on another leg is not something adopting a track on this one asked to lose.
|
||||
const keep = (await service.create(1, { after_order_index: 5, lat: 50, lng: 8 }));
|
||||
(await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const keep = await service.create(f.dayA1.id, { after_order_index: 5, lat: 50, lng: 8 });
|
||||
await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
const vias = (await service.createMany(1, {
|
||||
const vias = await service.createMany(f.dayA1.id, {
|
||||
vias: [{ after_order_index: 0, lat: 54, lng: 11 }],
|
||||
replace_legs: [0],
|
||||
}));
|
||||
});
|
||||
|
||||
expect(vias.map(v => v.id)).toEqual([vias[0].id, keep.id]);
|
||||
expect(vias.map(v => v.id)).toEqual([vias[0]!.id, keep.id]);
|
||||
expect(vias.find(v => v.after_order_index === 0)?.lat).toBe(54);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-015: clearing a leg without adding anything is a legal batch', async () => {
|
||||
(await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
expect((await service.createMany(1, { vias: [], replace_legs: [0] }))).toEqual([]);
|
||||
expect(await service.createMany(f.dayA1.id, { vias: [], replace_legs: [0] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-016: a chain cannot be laid on a day it was not addressed to', async () => {
|
||||
(await service.createMany(2, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }));
|
||||
it('ROADTRIP-SVC-016b: a chain cannot be laid on a day it was not addressed to', async () => {
|
||||
await service.createMany(f.dayA2.id, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] });
|
||||
|
||||
expect((await service.listForDay(1))).toEqual([]);
|
||||
expect((await service.listForDay(2))).toHaveLength(1);
|
||||
expect(await service.listForDay(f.dayA1.id)).toEqual([]);
|
||||
expect(await service.listForDay(f.dayA2.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-017: the chain and the track it came from land in one write', async () => {
|
||||
// Same transaction on purpose: a day saying it follows a road whose vias never
|
||||
// arrived is worse than a day saying nothing at all.
|
||||
(await service.createMany(1, {
|
||||
it('ROADTRIP-SVC-017b: the chain and the track it came from land in one write', async () => {
|
||||
await service.createMany(f.dayA1.id, {
|
||||
vias: [{ after_order_index: 0, lat: 53, lng: 10 }],
|
||||
track: { place_id: 10, stray_km: 0.8 },
|
||||
}));
|
||||
track: { place_id: f.track.id, stray_km: 0.8 },
|
||||
});
|
||||
|
||||
expect((await service.tracksForTrip(7))).toEqual([{ day_id: 1, place_id: 10, stray_km: 0.8 }]);
|
||||
expect(await service.tracksForTrip(f.tripA.id)).toEqual([{ day_id: f.dayA1.id, place_id: f.track.id, stray_km: 0.8 }]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-018: laying a second track on a day replaces the first, never doubles it', async () => {
|
||||
(await service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 2 } }));
|
||||
(await service.createMany(1, { vias: [], track: { place_id: 12, stray_km: null } }));
|
||||
await service.createMany(f.dayA1.id, { vias: [], track: { place_id: f.track.id, stray_km: 2 } });
|
||||
await service.createMany(f.dayA1.id, { vias: [], track: { place_id: f.hotel.id, stray_km: null } });
|
||||
|
||||
expect((await service.tracksForTrip(7))).toEqual([{ day_id: 1, place_id: 12, stray_km: null }]);
|
||||
expect(await service.tracksForTrip(f.tripA.id)).toEqual([{ day_id: f.dayA1.id, place_id: f.hotel.id, stray_km: null }]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-019: three states, not two \u2014 absent keeps, null clears', async () => {
|
||||
(await service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 1 } }));
|
||||
it('ROADTRIP-SVC-019: three states, not two — absent keeps, null clears', async () => {
|
||||
await service.createMany(f.dayA1.id, { vias: [], track: { place_id: f.track.id, stray_km: 1 } });
|
||||
|
||||
// Absent: a hand-dragged via on the same day must not wipe the road it follows.
|
||||
(await service.createMany(1, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }));
|
||||
expect((await service.tracksForTrip(7))).toHaveLength(1);
|
||||
await service.createMany(f.dayA1.id, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] });
|
||||
expect(await service.tracksForTrip(f.tripA.id)).toHaveLength(1);
|
||||
|
||||
(await service.createMany(1, { vias: [], replace_legs: [0], track: null }));
|
||||
expect((await service.tracksForTrip(7))).toEqual([]);
|
||||
await service.createMany(f.dayA1.id, { vias: [], replace_legs: [0], track: null });
|
||||
expect(await service.tracksForTrip(f.tripA.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-020: the tracks of one trip are not the tracks of another', async () => {
|
||||
(await service.createMany(1, { vias: [], track: { place_id: 10, stray_km: null } }));
|
||||
(await service.createMany(3, { vias: [], track: { place_id: 12, stray_km: null } }));
|
||||
await service.createMany(f.dayA1.id, { vias: [], track: { place_id: f.track.id, stray_km: null } });
|
||||
await service.createMany(f.dayB1.id, { vias: [], track: { place_id: f.otherTrack.id, stray_km: null } });
|
||||
|
||||
expect((await service.tracksForTrip(7)).map(t => t.day_id)).toEqual([1]);
|
||||
expect((await service.tracksForTrip(99)).map(t => t.day_id)).toEqual([3]);
|
||||
expect((await service.tracksForTrip(f.tripA.id)).map(tr => tr.day_id)).toEqual([f.dayA1.id]);
|
||||
expect((await service.tracksForTrip(f.tripB.id)).map(tr => tr.day_id)).toEqual([f.dayB1.id]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-021: only a track of this trip can become a day\u2019s label', async () => {
|
||||
// Permission is not enough on its own: without this a place id from another trip
|
||||
// would become this day\u2019s label, and an ordinary place would become a label that
|
||||
// can never be drawn.
|
||||
expect((await service.trackExists(10, 7))).toBe(true);
|
||||
expect((await service.trackExists(11, 7))).toBe(false);
|
||||
expect((await service.trackExists(12, 7))).toBe(false);
|
||||
expect((await service.trackExists(999, 7))).toBe(false);
|
||||
it('ROADTRIP-SVC-021: only a track of this trip can become a day’s label', async () => {
|
||||
expect(await service.trackExists(f.track.id, f.tripA.id)).toBe(true);
|
||||
expect(await service.trackExists(f.hotel.id, f.tripA.id)).toBe(false);
|
||||
expect(await service.trackExists(f.otherTrack.id, f.tripA.id)).toBe(false);
|
||||
expect(await service.trackExists(999999, f.tripA.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-007: removing one reports whether there was anything to remove', async () => {
|
||||
const via = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
it('ROADTRIP-SVC-007b: removing one reports whether there was anything to remove', async () => {
|
||||
const via = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
expect((await service.remove(via.id, 1))).toBe(true);
|
||||
expect((await service.remove(via.id, 1))).toBe(false);
|
||||
expect((await service.listForDay(1))).toEqual([]);
|
||||
expect(await service.remove(via.id, f.dayA1.id)).toBe(true);
|
||||
expect(await service.remove(via.id, f.dayA1.id)).toBe(false);
|
||||
expect(await service.listForDay(f.dayA1.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('RT8-GUARD-001: a non-canonical dayId is refused before any write — `createMany` never reaches the track upsert with an invalid day', async () => {
|
||||
// The Task 0 review carry (RT8): `RoadtripDayTracksRepository.upsertTrack`'s
|
||||
// `day_id` is that table's PRIMARY KEY column, and SQLite silently accepts
|
||||
// `INSERT … VALUES (NULL, …)` on an INTEGER PK by auto-assigning a rowid —
|
||||
// possibly colliding with a real, unrelated day. `requireDayId` (the
|
||||
// service's own gate, `toRowId`-based) refuses `'abc'`/`'3 '`/`'0x10'`
|
||||
// BEFORE the transaction opens, so `upsertTrack` can never be reached with
|
||||
// anything but a real, parsed integer.
|
||||
for (const badDayId of ['abc', '3 ', '0x10', '', '1.5']) {
|
||||
await expect(service.createMany(badDayId, { vias: [], track: { place_id: f.track.id, stray_km: null } }))
|
||||
.rejects.toMatchObject({ response: { error: 'Day not found' }, status: 404 });
|
||||
}
|
||||
expect(await service.tracksForTrip(f.tripA.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user