Merge remote-tracking branch 'origin/dev' into feat/storage-driver

# Conflicts:
#	server/src/db/migrations.ts
#	server/src/nest/journey/journey.controller.ts
#	server/tests/integration/leg-mode-incoming.test.ts
#	server/tests/unit/nest/journey.controller.test.ts
#	server/vitest.config.ts
This commit is contained in:
jubnl
2026-08-19 19:52:49 +02:00
154 changed files with 6148 additions and 823 deletions
+1
View File
@@ -45,6 +45,7 @@
"cors": "^2.8.5",
"cron": "^4.4.0",
"dotenv": "^16.4.1",
"exifr": "7.1.3",
"express": "^4.18.3",
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
+22 -3
View File
@@ -273,8 +273,24 @@ async function main() {
}
db.transaction(() => {
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token ---
for (const key of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token,
// plus the instance-wide provider keys (#1939) ---
//
// The last two mirror INSTANCE_API_KEY_NAMES in
// src/nest/settings/instance-api-keys.ts. Spelled out rather than imported,
// because that module pulls in DatabaseService and this script deliberately
// stays independent of src/ (see the crypto note above). Miss one and a
// rotation leaves it encrypted under the old key, which reads back as "no
// key" and silently drops the install to OpenStreetMap, so the copy is
// pinned by tests/unit/db/migrate-encryption-parity.test.ts.
for (const key of [
'oidc_client_secret',
'smtp_pass',
'admin_webhook_url',
'admin_ntfy_token',
'maps_api_key',
'unsplash_api_key',
]) {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
if (!row?.value) continue;
const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`);
@@ -284,7 +300,10 @@ async function main() {
}
// --- users: api key columns + synology credentials ---
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
// unsplash_api_key was missing here for as long as the column has existed. It
// matters now that the resolver reads it as the per-user fallback (#1939): left
// out of a rotation it stays encrypted under the old key and reads back as unset.
const apiKeyColumns = ['maps_api_key', 'unsplash_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
for (const user of users) {
+78 -2
View File
@@ -3909,13 +3909,89 @@ function runMigrations(db: Database.Database): void {
)
`);
},
// #1939 — the Google Places and Unsplash keys are instance configuration and
// now resolve out of app_settings (nest/settings/instance-api-keys.ts). This
// carries the value the old resolver would have handed out — the lowest-id
// admin who has one — into that row, so an install that upgrades keeps the
// key it was searching with instead of falling back to OpenStreetMap.
// Only where that key already was the whole install's, though. The resolver
// reads the instance row before the caller's own column, so promoting a
// personal key while a second row still holds one would move that member
// onto a stranger's key and a stranger's bill without anyone saying so. As
// soon as another row has a value for the same column nothing is written:
// everybody keeps resolving what they resolved before, and a member with no
// key of their own searches via OpenStreetMap until an admin saves the
// instance key once in the panel. Handing a key to the whole instance is the
// admin's call, not a migration's.
// The users columns are left alone: they are still the per-user fallback,
// and nothing here can lose a value. Stored blobs are copied verbatim; both
// sides use the same apiKeyCrypto format, legacy plaintext included.
// Appended LAST — the array is index-addressed against schema_version.
() => {
const upsert = db.prepare(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
);
for (const column of ['maps_api_key', 'unsplash_api_key'] as const) {
const existing = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(column) as
| { value: string | null }
| undefined;
if (existing?.value) continue;
const row = db
.prepare(
`SELECT id, ${column} AS value FROM users
WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != ''
ORDER BY id ASC LIMIT 1`
)
.get() as { id: number; value: string } | undefined;
if (!row?.value) continue;
const otherHolder = db
.prepare(
`SELECT 1 FROM users
WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1`
)
.get(row.id);
if (otherHolder) continue;
upsert.run(column, row.value);
}
},
// Capture metadata on the photo itself (#1614): when it was taken and where.
// Both are needed before photos can sit on the Journey map by their own
// coordinates, and before a gallery can be ordered by when a picture was
// taken rather than when it happened to be added. Nullable throughout —
// most providers answer with neither, and a photo without them is normal.
// Guarded so re-running the migration tail is a no-op.
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('trek_photos')").all() as Array<{ name: string }>;
const has = (name: string) => cols.some(c => c.name === name);
if (!has('taken_at')) db.exec('ALTER TABLE trek_photos ADD COLUMN taken_at TEXT');
if (!has('lat')) db.exec('ALTER TABLE trek_photos ADD COLUMN lat REAL');
if (!has('lng')) db.exec('ALTER TABLE trek_photos ADD COLUMN lng REAL');
// Answering "which photos of this journey have coordinates" without a scan.
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL');
},
// A journey shared while the trip is still running reads like a blog, and a
// blog puts the newest entry first (#1614). The owner decides per share link,
// because it is a property of how the link is meant to be read, not of the
// journey. Off by default: an already-published link must not reorder itself
// under its readers.
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'newest_first')) {
db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0');
}
},
// Storage slice 2 — collab note attachments historically stored 'files/<name>'
// in trip_files.filename while the file manager stored bare names in the same
// column; the storage layer addresses objects as category + bare name, so
// normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of
// 'files/'. The LIKE guard makes it a no-op on already-bare rows.
// Appended LAST: the array is index-addressed against schema_version, so a
// slot inserted above this line never runs on an existing database.
// Appended LAST (again): this slot moved below the three dev slots above in
// the dev merge — the array is index-addressed against schema_version, and
// the dev slots keep the indices they shipped with. A database that already
// ran this migration at its pre-merge index replays it harmlessly (the LIKE
// guard) but SKIPS the #1939 key promotion — acceptable only because this
// branch was never published; do not repeat this with a released slot.
() => {
db.exec("UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'");
},
+26
View File
@@ -27,6 +27,19 @@ function resetDemoUser(): void {
console.error('[Demo Reset] Failed to read admin data:', e instanceof Error ? e.message : e);
}
// The Places/Unsplash keys the searches actually use live in app_settings
// since #1939, so they have to survive the restore alongside the columns —
// otherwise the demo instance loses map search on every reset.
interface InstanceKeyRow { key: string; value: string | null }
let instanceKeys: InstanceKeyRow[] = [];
try {
instanceKeys = db.prepare(
"SELECT key, value FROM app_settings WHERE key IN ('maps_api_key', 'unsplash_api_key')"
).all() as InstanceKeyRow[];
} catch (e: unknown) {
console.error('[Demo Reset] Failed to read instance API keys:', e instanceof Error ? e.message : e);
}
// Flush WAL to main DB file
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
@@ -67,6 +80,19 @@ function resetDemoUser(): void {
}
}
if (instanceKeys.length) {
try {
const { db: freshDb } = require('../db/database');
const upsert = freshDb.prepare(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
);
for (const row of instanceKeys) upsert.run(row.key, row.value);
} catch (e: unknown) {
console.error('[Demo Reset] Failed to restore instance API keys:', e instanceof Error ? e.message : e);
}
}
console.log('[Demo Reset] Database restored from baseline');
}
@@ -115,6 +115,35 @@ export class AssignmentsMcp {
return ok({ assignment });
}
@Tool({
name: 'set_leg_transport_mode',
description: 'Set the travel mode of a route leg for a place assignment. Use direction "outgoing" (default) for the common case: the leg leaving this stop toward the next. Use direction "incoming" ONLY when this stop\'s arriving leg originates from something that is not itself a place assignment (e.g. a flight/train booking arrival, or a morning hotel departure) – setting "incoming" on an ordinary place-to-place leg is stored but has no effect on route rendering, because it targets a column that is only read for non-place origins. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:<pluginId>/<profileId>". Any other value is stored but drawn as a driving route. null clears it so the leg inherits the day default.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to inherit the day default'),
direction: z.enum(['outgoing', 'incoming']).default('outgoing').describe('Which leg to set: "outgoing" (leaving this stop, default) or "incoming" (arriving at it)'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'places', mode: 'write' },
})
async setLegTransportMode(
{ tripId, assignmentId, transport_mode, direction }: {
tripId: number; assignmentId: number; transport_mode?: string | null; direction?: 'outgoing' | 'incoming';
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const assignment = direction === 'incoming'
? this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null)
: this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null);
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
@Tool({
name: 'move_assignment',
description: 'Move a place assignment to a different day.',
+52 -9
View File
@@ -14,8 +14,19 @@ import {
} from './atlas-geo';
import { KNOWN_COUNTRIES } from './known-countries';
import { cityFromAddress } from './city-from-address';
import { transferEndpointIds } from './transfer-endpoints';
import type { FlightEndpointRow } from './transfer-endpoints';
import { haversineKm } from '../common/geo';
/**
* A reservation endpoint plus the two booking columns that decide whether it may
* take part in layover pairing at all (see transfer-endpoints.ts).
*/
type EndpointRow = FlightEndpointRow & {
reservation_type: string | null;
reservation_status: string | null;
};
export type CreateBucketData = {
name: string;
lat?: number | null;
@@ -314,19 +325,33 @@ export class AtlasService {
// via resolvePlaceCountries above and would otherwise be missed (#1366).
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
// connection/layover (e.g. a plane change) the traveler never really visited.
const endpoints = this.db
const endpointRows = this.db
.prepare(
`
SELECT DISTINCT r.trip_id, e.lat, e.lng
SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
r.type AS reservation_type, r.status AS reservation_status,
CASE e.role
WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
ELSE COALESCE(r.reservation_time, r.reservation_end_time)
END AS fallback_time
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')}) AND e.role IN ('from', 'to')
`,
)
.all(...tripIds) as { trip_id: number; lat: number; lng: number }[];
.all(...tripIds) as EndpointRow[];
// Selecting trip_id makes the DISTINCT per trip, so the same airport comes back once
// per booking. Collapse to one entry per coordinate before resolving countries —
// A layover the traveler never left is only marked 'stop' while both its legs sit in
// one booking. Split over two bookings it is a plain 'to' plus 'from' at the same
// airport, so it has to be recognised from the times instead (#1535). Only flights
// pair: a rental car picked up where a flight landed has its own from/to there and
// must keep contributing its country (#1366).
const transfers = transferEndpointIds(
endpointRows.filter((e) => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled'),
);
const endpoints = endpointRows.filter((e) => !transfers.has(e.id));
// Collapse to one entry per coordinate before resolving countries —
// getCountryFromCoords is a point-in-polygon scan and used to run once per point.
const endpointStatus = new Map<string, { lat: number; lng: number; status: VisitStatus }>();
for (const e of endpoints) {
@@ -962,9 +987,16 @@ export class AtlasService {
// planned place there) was never counted as visited (#1366). Resolve each endpoint
// coordinate to a country and fold it in too.
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
// connection/layover (e.g. a plane change) the traveler never really visited (#1486).
const endpoints = this.db.all<{ lat: number; lng: number }>(`
SELECT DISTINCT e.lat, e.lng
// connection/layover (e.g. a plane change) the traveler never really visited (#1486),
// and the same layover split over two bookings is recognised from its ground time
// instead, because there it is stored as a legitimate 'to'/'from' pair (#1535).
const endpointRows = this.db.all<EndpointRow>(`
SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
r.type AS reservation_type, r.status AS reservation_status,
CASE e.role
WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
ELSE COALESCE(r.reservation_time, r.reservation_end_time)
END AS fallback_time
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
JOIN trips t ON r.trip_id = t.id
@@ -973,7 +1005,18 @@ export class AtlasService {
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
AND COALESCE(t.start_date, t.end_date) <= date('now')
`, userId, userId);
for (const e of endpoints) {
const transfers = transferEndpointIds(
endpointRows.filter(e => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled')
);
// The DISTINCT no longer collapses per coordinate now that the endpoint id has to be
// in the projection, so collapse here instead: getCountryFromCoords is a
// point-in-polygon scan and must stay at one run per coordinate.
const endpointCoords = new Map<string, { lat: number; lng: number }>();
for (const e of endpointRows) {
if (transfers.has(e.id)) continue;
endpointCoords.set(`${e.lat},${e.lng}`, { lat: e.lat, lng: e.lng });
}
for (const e of endpointCoords.values()) {
const code = getCountryFromCoords(e.lat, e.lng);
if (code) countryCodes.add(code.toUpperCase());
}
+305
View File
@@ -0,0 +1,305 @@
/**
* Layover detection for the two country-deriving Atlas queries.
*
* A plane change is not a visit, so #1486 taught both queries to skip
* reservation_endpoints rows with role='stop'. That only reaches a layover
* stored INSIDE one booking. When the legs of one journey land in the database as
* two separate reservations (an AirTrail import whose flights never chained, or
* legs imported one by one before the join feature existed), the hub is the
* legitimate 'to' of the first booking and the legitimate 'from' of the second,
* and no role filter can tell that apart from a real destination (#1535).
*
* What does tell it apart is the time on the ground: leaving the same airport
* again within 24 h is a connection, not a stay. The window is deliberately the
* one both importers already apply when they merge the legs of one journey
* (orderConnectionChain in airtrail-import.service.ts, kitinerary-mapper.ts), so
* Atlas and the importers agree on what counts as one connection.
*
* Read-side only, hence no migration: both queries derive their countries live on
* every request, so a database that already holds the split bookings heals as
* soon as this ships.
*/
import { haversineKm } from '../common/geo';
/** Ground time up to which an onward departure is a connection rather than a stay. */
export const MAX_LAYOVER_MS = 24 * 3600 * 1000;
/**
* Without clocks only calendar dates are left. Leaving the day after arriving is
* still an overnight connection; a wider gap can't be told apart from a genuine
* stopover in the stored data, so it keeps counting as visited.
*/
export const MAX_CLOCKLESS_DAY_GAP = 1;
/**
* How far from where the arriving leg started the departing leg may land and
* still count as the flight home rather than the next hop. A return is regularly
* booked into the sibling airport of the same city (Charleroi instead of
* Zaventem, Gatwick instead of Heathrow), and that day was spent at the
* destination, not in transit.
*
* A radius is the right shape for that question: the country of a coordinate
* would be both too coarse (Belfast to Dublin to London would read as a return
* and start counting Ireland as visited) and too expensive, since
* getCountryFromCoords scans admin polygons and this runs inside the pairing
* loop. 100 km covers every sibling pair worth naming (CRL/BRU 45 km, LGW/LHR
* 40 km, EWR/JFK 34 km, HND/NRT 60 km) and stays below the distance between two
* cities that each own their traffic, so Amsterdam to Frankfurt to Brussels
* (170 km) keeps pairing as the connection it is.
*/
const RETURN_RADIUS_KM = 100;
/** One reservation_endpoints row, joined to the columns its booking contributes. */
export interface FlightEndpointRow {
id: number;
reservation_id: number;
trip_id: number;
role: string;
code: string | null;
lat: number;
lng: number;
local_date: string | null;
local_time: string | null;
/** The booking's own time column for this side of the leg, used for the date only. */
fallback_time: string | null;
}
/** Local calendar date plus, when the leg carried an instant, the local clock. */
interface Stamp {
date: string | null;
time: string | null;
}
/** Where one end of a booking actually is, for the airports the importer geocoded. */
interface Coords {
lat: number;
lng: number;
}
/** The match keys and positions of one booking's own two ends, shared by all of its endpoints. */
interface BookingKeys {
from: Set<string>;
to: Set<string>;
fromCoords: Coords[];
toCoords: Coords[];
}
interface Endpoint {
row: FlightEndpointRow;
booking: BookingKeys;
/** The buckets this endpoint belongs to, already scoped to its trip. */
keys: string[];
/** Midnight of the leg's local date. */
dayMs: number;
/** The leg's exact instant, null when it carried no clock. */
exactMs: number | null;
/** What a bucket of departures is ordered by: the instant if there is one, else the day. */
sortMs: number;
}
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
const DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
const CLOCK = /^\d{2}:\d{2}/;
const DAY_MS = 24 * 3600 * 1000;
/**
* How far past the arrival's midnight the sorted departures still have to be
* walked. Two days, because each half of a pair is anchored on its own date: a
* clockless arrival still pairs with a departure at 23:45 the next day, and an
* arrival at 23:50 still pairs with a date-only departure the day after. This
* bounds the scan only, isConnection stays the one place that decides.
*/
const SCAN_WINDOW_MS = 2 * DAY_MS;
/**
* Every key an endpoint can be matched on. Two rows for one airport need not
* agree on their shape: an imported leg carries the IATA/ICAO code, a booking
* typed in by hand may only have been geocoded. Indexing both and pairing on
* either match covers the mixed case, and the coordinate key is rounded because
* two geocodes of the same airport differ in the last decimals.
*/
function endpointKeys(e: FlightEndpointRow): string[] {
const keys: string[] = [];
const code = e.code?.trim();
if (code) keys.push(`c:${code.toUpperCase()}`);
if (Number.isFinite(e.lat) && Number.isFinite(e.lng)) keys.push(`p:${e.lat.toFixed(2)},${e.lng.toFixed(2)}`);
return keys;
}
/**
* An endpoint only has local_date/local_time when its leg carried a usable
* instant; localParts() returns nulls otherwise, which is exactly the date-only
* AirTrail flight that fails to chain in the first place. So fall back to the
* booking's own time column, but take its DATE half only: for an arrival that
* column can hold the departure airport's local clock (airtrail.mapper.ts), which
* says nothing about the time on the ground here.
*/
function stampOf(e: FlightEndpointRow): Stamp {
if (e.local_date && DATE_ONLY.test(e.local_date)) {
const time = e.local_time && CLOCK.test(e.local_time) ? e.local_time.slice(0, 5) : null;
return { date: e.local_date, time };
}
const fallback = e.fallback_time?.match(DATE_PREFIX);
return { date: fallback ? fallback[1] : null, time: null };
}
function utcMs(date: string, time: string | null): number {
return Date.parse(`${date}T${time ?? '00:00'}:00Z`);
}
/**
* The instants the pairing compares, read once per endpoint instead of once per
* pair. null means the leg can never be either half of a pair: without a date
* that parses, neither branch below has anything to measure.
*
* A clock that got past the shape check can still be nonsense, and the resulting
* NaN is kept rather than downgraded to "no clock": a pair where both sides claim
* an instant has always been judged on the instant alone.
*/
function instantsOf(stamp: Stamp): { dayMs: number; exactMs: number | null; sortMs: number } | null {
if (!stamp.date) return null;
const dayMs = utcMs(stamp.date, null);
if (!Number.isFinite(dayMs)) return null;
const exactMs = stamp.time === null ? null : utcMs(stamp.date, stamp.time);
return { dayMs, exactMs, sortMs: exactMs !== null && Number.isFinite(exactMs) ? exactMs : dayMs };
}
/**
* Is the departure the onward leg of the arrival? Both endpoints sit at the same
* airport and therefore in the same timezone, so the naive local stamps compare
* as real instants and their difference is the true time on the ground. That is
* the only reason this is sound, so never reuse it across two airports.
*/
function isConnection(arrival: Endpoint, departure: Endpoint): boolean {
if (arrival.exactMs !== null && departure.exactMs !== null) {
const gap = departure.exactMs - arrival.exactMs;
return Number.isFinite(gap) && gap >= 0 && gap <= MAX_LAYOVER_MS;
}
const days = (departure.dayMs - arrival.dayMs) / DAY_MS;
return days >= 0 && days <= MAX_CLOCKLESS_DAY_GAP;
}
function intersects(a: Set<string>, b: Set<string>): boolean {
for (const key of a) {
if (b.has(key)) return true;
}
return false;
}
/**
* Is the departing booking heading back to where the arriving one started? Then
* the traveler spent the time in between at the destination, and the same call is
* the one orderConnectionChain makes before it joins two legs.
*
* The airport keys answer that for a return into the exact same airport; the
* distance covers the sibling airport of the same city, which the keys can never
* match. Legs the importer had no coordinates for fall back to the key match.
*/
function returnsToOrigin(departure: Endpoint, arrival: Endpoint): boolean {
if (intersects(departure.booking.to, arrival.booking.from)) return true;
for (const landed of departure.booking.toCoords) {
for (const started of arrival.booking.fromCoords) {
if (haversineKm(landed.lat, landed.lng, started.lat, started.lng) <= RETURN_RADIUS_KM) return true;
}
}
return false;
}
/** First entry of a bucket sorted by sortMs that is not earlier than `from`. */
function firstFrom(bucket: Endpoint[], from: number): number {
let low = 0;
let high = bucket.length;
while (low < high) {
const mid = (low + high) >>> 1;
if (bucket[mid].sortMs < from) low = mid + 1;
else high = mid;
}
return low;
}
/**
* The endpoint ids that are only a connection airport, spread over two bookings.
*
* Callers pass flight endpoints of non-cancelled bookings only: a car rental
* picked up right where a flight landed has its own from/to at that airport and
* would otherwise pair with the arrival and swallow the destination country
* (#1366).
*/
export function transferEndpointIds(rows: FlightEndpointRow[]): Set<number> {
const transfers = new Set<number>();
if (rows.length < 2) return transfers;
const landings: Endpoint[] = [];
const departures = new Map<string, Endpoint[]>();
const bookings = new Map<number, BookingKeys>();
for (const row of rows) {
if (row.role !== 'from' && row.role !== 'to') continue;
const keys = endpointKeys(row);
if (keys.length === 0) continue;
let booking = bookings.get(row.reservation_id);
if (!booking) {
booking = { from: new Set<string>(), to: new Set<string>(), fromCoords: [], toCoords: [] };
bookings.set(row.reservation_id, booking);
}
const arriving = row.role === 'to';
const ownKeys = arriving ? booking.to : booking.from;
for (const key of keys) ownKeys.add(key);
if (Number.isFinite(row.lat) && Number.isFinite(row.lng)) {
(arriving ? booking.toCoords : booking.fromCoords).push({ lat: row.lat, lng: row.lng });
}
// A leg without a readable date pairs with nothing, but its keys and its
// position had to be recorded above: they describe the booking either way.
const instants = instantsOf(stampOf(row));
if (!instants) continue;
// Two trips that happen to touch the same airport are two journeys: an
// arrival home from one and a departure on the next is not a layover. The
// trip belongs in the bucket key rather than in a per-pair check, so the
// journeys of an imported flight log are never compared against each other.
const endpoint: Endpoint = { row, booking, keys: keys.map((key) => `${row.trip_id}:${key}`), ...instants };
if (arriving) {
landings.push(endpoint);
continue;
}
for (const key of endpoint.keys) {
const bucket = departures.get(key);
if (bucket) bucket.push(endpoint);
else departures.set(key, [endpoint]);
}
}
// In time order, so an arrival can stop looking once the ground time is out of
// reach. Ten years of flight log imported into one trip put hundreds of
// departures on a home airport, and comparing every arrival against all of them
// cost more than a second on both request paths that call this.
for (const bucket of departures.values()) bucket.sort((a, b) => a.sortMs - b.sortMs);
for (const arrival of landings) {
const until = arrival.dayMs + SCAN_WINDOW_MS;
// An endpoint carrying both a code and a coordinate sits in two buckets, so
// without this the same pair would be judged twice.
const seen = arrival.keys.length > 1 ? new Set<Endpoint>() : null;
for (const key of arrival.keys) {
const left = departures.get(key);
if (!left) continue;
for (let i = firstFrom(left, arrival.dayMs); i < left.length; i++) {
const departure = left[i];
if (departure.sortMs > until) break;
if (seen) {
if (seen.has(departure)) continue;
seen.add(departure);
}
if (departure.row.reservation_id === arrival.row.reservation_id) continue;
if (returnsToOrigin(departure, arrival)) continue;
if (!isConnection(arrival, departure)) continue;
transfers.add(arrival.row.id);
transfers.add(departure.row.id);
}
}
}
return transfers;
}
+7
View File
@@ -11,6 +11,7 @@ const ACTION_LABELS: Record<string, string> = {
'user.mfa_enable': 'enabled MFA',
'user.mfa_disable': 'disabled MFA',
'settings.app_update': 'updated settings',
'settings.api_keys_update': 'updated API keys',
'trip.create': 'created trip',
'trip.delete': 'deleted trip',
'admin.user_role_change': 'changed user role',
@@ -38,6 +39,12 @@ function buildInfoSummary(action: string, details?: Record<string, unknown>): st
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
return parts.length ? ` (${parts.join(', ')})` : '';
}
if (action === 'settings.api_keys_update') {
// The names, read straight out of details — the writer puts nothing else in
// there, and a key value must never reach a log line.
const changed = Array.isArray(details.changed) ? details.changed : [];
return changed.length ? ` (${changed.join(', ')})` : '';
}
if (action === 'immich.private_ip_configured') {
return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : '';
}
+34 -5
View File
@@ -117,22 +117,51 @@ export class AuthController {
return { success: true };
}
/**
* One row per save that actually changed a key, on all three routes that can
* write these columns (#1939 asked for it: the API keys were the only admin
* setting whose change left no trace, while `settings.app_update` next door
* has always been logged).
*
* Names only. `details` is rendered as raw JSON in the admin audit panel and
* mirrored into the debug log, so a value here would leak the key twice over.
* Nothing is written when nothing changed — the panel saves before every test
* click, and each click would otherwise cost a log line.
*/
private auditApiKeys(userId: number, changed: string[], req: Request): void {
if (!changed.length) return;
this.audit.writeAudit({
userId,
action: 'settings.api_keys_update',
resource: 'api_keys',
ip: getClientIp(req),
details: { changed },
});
}
@Put('me/maps-key')
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto) {
return this.profile.updateMapsKey(user.id, body.maps_api_key);
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) {
// changedKeys is for the audit line, not for the client: destructured off so
// the response body stays what it always was.
const { changedKeys = [], ...result } = this.profile.updateMapsKey(user.id, body.maps_api_key);
this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/api-keys')
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto) {
return this.profile.updateApiKeys(user.id, body);
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
const { changedKeys = [], ...result } = this.profile.updateApiKeys(user.id, body);
this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/settings')
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto) {
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
const result = this.profile.updateSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.auditApiKeys(user.id, result.changedKeys ?? [], req);
return { success: result.success, user: result.user };
}
+7 -7
View File
@@ -14,6 +14,7 @@ import { PermissionsService } from '../permissions/permissions.service';
import { validatePassword } from '../common/passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey } from '../settings/instance-api-keys';
import { EphemeralTokenService } from './ephemeral-token.service';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
// the whole tools fan-out (and via the domain bridges, the Nest services) into
@@ -236,13 +237,12 @@ export class AuthService {
// One directory deeper than the legacy src/services location — the extra
// '../' keeps resolving to the workspace package.json.
const version: string = readEnv().app.appVersion ?? require('../../../package.json').version;
// The operator's key counts too. getMapsKey prefers it over anything in the
// users table, so asking the table alone would report "no key" on an install
// where every lookup works, and the client would hide the Google features it
// is actually able to use.
const hasGoogleKey =
!!readEnv().maps.placesApiKey ||
!!this.db.get("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1");
// Asked through the same resolver the search itself uses, so the client can
// never show Google features that rest on a key this caller does not get —
// nor hide them from a member who does have one (#1939). Unauthenticated the
// question is only about the instance, which is the first two steps of the
// chain; id 0 matches no row.
const hasGoogleKey = !!resolveApiKey(this.db, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey).key;
const oidcDisplayName = readEnv().oidc.displayName ||
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null;
const oidcConfigured = !!(
+127 -23
View File
@@ -6,6 +6,14 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC
import { avatarUrl } from '../common/avatarUrl';
import { EMAIL_REGEX, mask_stored_api_key } from './auth.helpers';
import { splitManagedKeys, MANAGED_LOCKED_PROFILE_KEYS } from '../common/managed';
import {
INSTANCE_API_KEY_NAMES,
readInstanceApiKey,
resolveApiKey,
writeInstanceApiKey,
type InstanceApiKeyName,
} from '../settings/instance-api-keys';
import { SEARCH_TEXT_FIELD_MASK } from '../maps/maps.helpers';
import { User } from '../../types';
/**
@@ -43,33 +51,109 @@ export class UserProfileService {
return readEnv().managed.enabled;
}
/** The three key columns plus the role that decides where a save lands. */
private currentKeys(userId: number) {
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?',
userId
);
}
/**
* The value a save is measured against, in cleartext.
*
* For an admin and one of the two instance-wide names that is the instance
* value, because that is what the panel shows them and what the search uses;
* their own column only speaks when no instance value has been set yet.
*/
private storedKeyPlaintext(
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key',
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
isAdmin: boolean,
): string {
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
const instance = readInstanceApiKey(this.db, name as InstanceApiKeyName);
if (instance !== null) return instance;
}
return decrypt_api_key(current?.[name]) ?? '';
}
/**
* Which key names this body actually changes.
*
* Compared on cleartext, never on what is stored: the IV is random, so the
* same key encrypted twice differs every time and a ciphertext comparison
* would report a change on every save. The panel saves before each test click,
* so that difference is the one between an audit trail and a full log.
*
* `skipped` are the names a managed install refuses to write — auditing them
* would claim a change that never happened.
*/
private changedKeyNames(
body: Record<string, unknown>,
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
isAdmin: boolean,
skipped: string[] = [],
): string[] {
const norm = (v: unknown) => String(v ?? '').trim();
return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key'] as const).filter(
(name) => body[name] !== undefined && !skipped.includes(name) && norm(body[name]) !== norm(this.storedKeyPlaintext(name, current, isAdmin))
);
}
/**
* Mirror the two instance-wide names into app_settings when an admin saves.
*
* Only an admin: `PUT /me/api-keys` is not admin-gated (the class carries
* JwtAuthGuard alone), so letting any caller write here would hand every
* member the instance credential. A non-admin keeps writing their own column,
* which is still the last step of the resolver.
*/
private mirrorInstanceKeys(body: Record<string, unknown>, isAdmin: boolean): void {
if (!isAdmin) return;
for (const name of INSTANCE_API_KEY_NAMES) {
if (body[name] !== undefined) writeInstanceApiKey(this.db, name, body[name]);
}
}
updateMapsKey(userId: number, key: unknown) {
const maps_api_key = key as string | null | undefined;
if (this.managed) {
return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'] };
return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'], changedKeys: [] };
}
this.db.run(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
maybe_encrypt_api_key(maps_api_key), userId
);
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key) };
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = this.changedKeyNames({ maps_api_key }, current, isAdmin);
this.db.transaction(() => {
this.db.run(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
maybe_encrypt_api_key(maps_api_key), userId
);
this.mirrorInstanceKeys({ maps_api_key }, isAdmin);
});
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key), changedKeys };
}
updateApiKeys(userId: number, rawBody: unknown) {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string };
const { blocked } = splitManagedKeys(body, this.managed);
for (const key of blocked) delete body[key as keyof typeof body];
const current = this.db.get<Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?', userId);
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = this.changedKeyNames(body, current, isAdmin, blocked);
// `?? null` instead of the former non-null assertions: a user row deleted
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
this.db.run(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
userId
);
this.db.transaction(() => {
// `?? null` instead of the former non-null assertions: a user row deleted
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
this.db.run(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
userId
);
this.mirrorInstanceKeys(body, isAdmin);
});
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
@@ -81,13 +165,14 @@ export class UserProfileService {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
changedKeys,
};
}
updateSettings(
userId: number,
rawBody: unknown
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown> } {
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] } {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string };
const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body;
@@ -126,10 +211,19 @@ export class UserProfileService {
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
// Read before the write, so the comparison sees the old value; the role in
// the same row decides whether the two instance-wide names travel with it.
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = keyLocked ? [] : this.changedKeyNames(body, current, isAdmin, blocked);
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(userId);
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
this.db.transaction(() => {
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
if (!keyLocked) this.mirrorInstanceKeys(body, isAdmin);
});
}
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
@@ -142,6 +236,7 @@ export class UserProfileService {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
changedKeys,
};
}
@@ -168,11 +263,15 @@ export class UserProfileService {
};
}
// Maps and Unsplash are read where the search reads them: instance-wide
// first. Showing the admin their own column while every request used another
// value is the confusion #1939 reported. Their column still answers while no
// instance value exists — on that install it is what the resolver picks too.
return {
settings: {
maps_api_key: decrypt_api_key(user.maps_api_key),
maps_api_key: readInstanceApiKey(this.db, 'maps_api_key') ?? decrypt_api_key(user.maps_api_key),
openweather_api_key: decrypt_api_key(user.openweather_api_key),
unsplash_api_key: decrypt_api_key(user.unsplash_api_key),
unsplash_api_key: readInstanceApiKey(this.db, 'unsplash_api_key') ?? decrypt_api_key(user.unsplash_api_key),
},
};
}
@@ -227,7 +326,7 @@ export class UserProfileService {
// -------------------------------------------------------------------------
async validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'>>('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?', userId);
const user = this.db.get<Pick<User, 'role' | 'openweather_api_key'>>('SELECT role, openweather_api_key FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
const result: {
@@ -243,7 +342,10 @@ export class UserProfileService {
};
} = { maps: false, weather: false, maps_details: null };
const maps_api_key = decrypt_api_key(user.maps_api_key);
// The key a search would actually use, not the one in this admin's column:
// testing a value nothing resolves to is how "the panel says the key is
// fine" and "every search 403s" coexisted (#1939).
const { key: maps_api_key } = resolveApiKey(this.db, 'maps_api_key', userId, readEnv().maps.placesApiKey);
if (maps_api_key) {
try {
// Same Referer as maps.service googleFetch — without it, keys with an
@@ -257,7 +359,9 @@ export class UserProfileService {
...(referer ? { Referer: referer } : {}),
'Content-Type': 'application/json',
'X-Goog-Api-Key': maps_api_key,
'X-Goog-FieldMask': 'places.displayName',
// The mask the real search sends. A narrower probe passes on keys
// that are restricted to fewer Places SKUs than TREK asks for.
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify({ textQuery: 'test' }),
}
@@ -412,6 +412,14 @@ export class CalendarService {
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
// A segment booked under its own reference gets its own line (#1943): at
// the gate it is that code the airline asks for, not the booking's. No
// leg reference, no extra line, so an existing feed stays byte-identical.
for (const leg of meta.legs as { from?: string; to?: string; confirmation_number?: string }[]) {
if (!leg.confirmation_number) continue;
const segment = [leg.from, leg.to].filter(Boolean).join('-');
desc += `\nConfirmation${segment ? ` ${segment}` : ''}: ${leg.confirmation_number}`;
}
} else if (meta.departure_airport || meta.arrival_airport) {
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
+24
View File
@@ -104,6 +104,30 @@ export class DaysMcp {
return ok({ success: true });
}
@Tool({
name: 'set_day_default_transport_mode',
description: 'Set the whole-day default travel mode for a day. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:<pluginId>/<profileId>". Any other value is stored but drawn as a driving route. null clears the default. Per-segment leg modes still override this.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to clear the day default'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'trips', mode: 'write' },
})
async setDayDefaultTransportMode(
{ tripId, dayId, transport_mode }: { tripId: number; dayId: number; transport_mode?: string | null },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.days.getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const day = this.days.setDefaultTransportMode(dayId, transport_mode ?? null);
this.guards.safeBroadcast(tripId, 'day:updated', { day });
return ok({ day });
}
@ResourceTemplate({
name: 'trip-days',
uriTemplate: 'trek://trips/{tripId}/days',
@@ -14,7 +14,7 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository';
const JP_SELECT = `
gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
tp.media_type, tp.duration_ms
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
`;
const JP_JOIN = `journey_entry_photos jep
@@ -24,7 +24,7 @@ const JP_JOIN = `journey_entry_photos jep
const GALLERY_SELECT = `
gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
tp.media_type, tp.duration_ms
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
`;
const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
@@ -342,6 +342,10 @@ export class JourneyDomainService {
// (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped
// path enforces.
if (!this.db.canAccessTrip(tripId, userId)) return false;
// And a journey the caller can actually reach. Without this, any logged-in user
// could link a trip of theirs into a stranger's journey and seed entries and
// photos there — the MCP tool has always checked this, the REST route never did.
if (!this.canAccessJourney(journeyId, userId)) return false;
const now = this.ts();
try {
this.db.prepare('INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run(
@@ -355,8 +359,12 @@ export class JourneyDomainService {
// sync skeleton entries for all places in this trip
this.syncTripPlaces(journeyId, tripId, userId);
// import existing trip photos (Immich/Synology) with sharing settings
this.syncTripPhotos(journeyId, tripId);
// Trip photos are deliberately NOT pulled in any more (#1614). Photos live in
// journeys now: the trip-photo surface lost its UI in 3.1.0, nothing writes to
// it on an install newer than that, and copying rows between the two was what
// let a photo one member had chosen not to share reach a journey at all. The
// table and its (unreferenced) routes stay for one more release rather than
// being dropped in an append-only migration.
this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
return true;
}
@@ -441,29 +449,6 @@ export class JourneyDomainService {
}
}
// import trip_photos into journey gallery when a trip is linked
private syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = this.db
.prepare('SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?')
.all(tripId) as { photo_id: number; shared: number }[];
if (!tripPhotos.length) return;
const now = this.ts();
const maxOrderRow = this.db
.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?')
.get(journeyId) as { m: number | null };
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
for (const tp of tripPhotos) {
this.db.prepare(
`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`,
).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
}
}
// called when a trip place is created
onPlaceCreated(tripId: number, placeId: number) {
const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as {
@@ -7,6 +7,8 @@ interface JourneySharePermissions {
share_timeline?: boolean;
share_gallery?: boolean;
share_map?: boolean;
/** Read the journey newest-first, like a blog, rather than in trip order. */
newest_first?: boolean;
}
interface JourneyShareTokenInfo {
@@ -15,6 +17,7 @@ interface JourneyShareTokenInfo {
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
newest_first: boolean;
}
/**
@@ -45,18 +48,19 @@ export class JourneyShareService {
share_timeline = true,
share_gallery = true,
share_map = true,
newest_first = false,
} = permissions;
const existing = this.db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
if (existing) {
this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ?, newest_first = ? WHERE journey_id = ?')
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0, journeyId);
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map, newest_first) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0);
return { token, created: true };
}
@@ -69,6 +73,7 @@ export class JourneyShareService {
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
newest_first: !!row.newest_first,
};
}
@@ -130,7 +135,7 @@ export class JourneyShareService {
const photos = this.db.prepare(`
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
tkp.media_type, tkp.duration_ms
tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng
FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tkp ON tkp.id = gp.photo_id
@@ -146,7 +151,7 @@ export class JourneyShareService {
const gallery = this.db.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
tkp.media_type, tkp.duration_ms
tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
@@ -183,6 +188,7 @@ export class JourneyShareService {
const projected: Record<string, unknown> = { ...e };
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
if (!shareGallery) projected.photos = [];
else if (!shareMap) projected.photos = stripPhotoGps(e.photos);
return projected;
});
} else if (shareMap) {
@@ -208,13 +214,23 @@ export class JourneyShareService {
status: journey.status,
},
entries: publicEntries,
gallery: shareGallery ? gallery : [],
// A photo now carries the coordinates it was taken at, which is a location the
// owner never typed and may not expect to publish. It follows share_map, the
// same switch the entry coordinates follow — otherwise a gallery-only share
// would hand out places the map was deliberately turned off for.
gallery: shareGallery ? (shareMap ? gallery : stripPhotoGps(gallery)) : [],
stats,
permissions: {
share_timeline: shareTimeline,
share_gallery: shareGallery,
share_map: shareMap,
newest_first: !!row.newest_first,
},
};
}
}
/** Drop capture coordinates from a photo list, keeping everything else. */
function stripPhotoGps<T>(photos: T[] | undefined | null): T[] {
return (photos ?? []).map(p => ({ ...(p as Record<string, unknown>), lat: null, lng: null })) as T[];
}
@@ -25,6 +25,7 @@ import type { User } from '../../types';
import { StorageService } from '../storage/storage.service';
import { journeyThumbName } from '../memories/thumbnail.service';
import { JourneyService } from './journey.service';
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { ADDON_IDS } from '../../addons';
@@ -126,6 +127,7 @@ export class JourneyController {
constructor(
private readonly journey: JourneyService,
private readonly storage: StorageService,
private readonly captureBackfill: PhotoCaptureBackfillService,
) {}
/**
@@ -147,6 +149,16 @@ export class JourneyController {
}
}
// The add call carries only an asset id, so when and where the picture was taken
// are fetched from the provider afterwards rather than trusted from the client
// (#1614). Detached: a slow or unreachable provider must not hold up the add.
private backfillCapture(photos: unknown[], userId: number): void {
const ids = photos
.map(p => (p as { photo_id?: number } | null)?.photo_id)
.filter((id): id is number => typeof id === 'number');
this.captureBackfill.schedule(ids, userId);
}
// ── Static prefix routes (before /:id) ──────────────────────────────────
@Get()
list(@CurrentUser() user: User) {
@@ -226,6 +238,7 @@ export class JourneyController {
if (!results.length) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
this.backfillCapture(results, user.id);
return { photos: results };
}
@@ -239,6 +252,7 @@ export class JourneyController {
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt);
if (photo) added.push(photo);
});
this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
@@ -248,6 +262,7 @@ export class JourneyController {
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
this.backfillCapture([photo], user.id);
return photo;
}
@@ -332,6 +347,9 @@ export class JourneyController {
if (!photos.length) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
// An uploaded file carries its own EXIF; reading it is what puts the photo on
// the map later. Detached, like the provider branch.
this.backfillCapture(photos, user.id);
return { photos };
}
@@ -390,6 +408,7 @@ export class JourneyController {
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt);
if (photo) added.push(photo);
});
this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
@@ -399,6 +418,7 @@ export class JourneyController {
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
this.backfillCapture([photo], user.id);
return photo;
}
+29 -6
View File
@@ -22,6 +22,17 @@ export function buildUserAgent(instanceUrl: string | undefined): string {
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
export const UA = buildUserAgent(getAppUrl());
/**
* The fields places:searchText is asked for.
*
* Here rather than beside the call because the admin panel's key test has to
* send the same mask: a key restricted to a narrower set of Places SKUs answers
* a one-field probe with 200 and the real search with 403, which is the second
* way to reach the #1939 report ("test button green, searching fails").
*/
export const SEARCH_TEXT_FIELD_MASK =
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus';
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
@@ -118,17 +129,29 @@ export function namesOverlap(a: string, b: string): boolean {
}
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
// The same id inside the `data=` pathname blob, where a resolved share link keeps it:
// /maps/place/<name>/data=!4m6!3m5!1s0x47e66e1f06e2b70f:0x40b82c3688c9460!8m2!3d48.85!4d2.29
const GOOGLE_FTID_DATA_RE = /!1s(0x[0-9a-f]{1,20}:0x[0-9a-f]{1,20})/i;
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param.
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL.
// Two shapes carry it. The `?ftid=` query parameter is the old one. A share link that
// resolveGoogleMapsUrl has followed to its final hop carries it instead in the `data=`
// pathname blob, in the same `!1s…!3d…!4d…` run the coordinates come from (#1954) — so
// reading only the query parameter dropped the id for every pasted maps.app.goo.gl link.
// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN)
// with no ftid, so this returns null for most API responses — the precise query_place_id link is
// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link
// resolved by resolveGoogleMapsUrl or a Google MyMaps list import.
// with neither, so this still returns null for most API responses — the precise
// query_place_id link is used instead.
export function googleFtidFromMapsUrl(url?: string | null): string | null {
if (!url) return null;
try {
const ftid = new URL(url).searchParams.get('ftid')?.trim();
return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null;
const parsed = new URL(url);
const ftid = parsed.searchParams.get('ftid')?.trim();
if (ftid && GOOGLE_FTID_RE.test(ftid)) return ftid.toLowerCase();
// Only for a place URL. A /maps/dir/ route carries one !1s per waypoint and the
// first one is the origin, not the place the link is about.
if (!parsed.pathname.includes('/place/')) return null;
const fromPath = GOOGLE_FTID_DATA_RE.exec(parsed.pathname)?.[1];
return fromPath ? fromPath.toLowerCase() : null;
} catch {
return null;
}
+39 -24
View File
@@ -9,13 +9,14 @@ import type {
} from '@trek/shared';
import { readEnv, getAppUrl } from '../../app-config';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { DatabaseService } from '../database/database.service';
import { nominatimFetch, type GeoLane } from '../geo/nominatim.client';
import {
UA,
SEARCH_TEXT_FIELD_MASK,
toApiLang,
googleFtidFromMapsUrl,
buildOsmDetails,
@@ -59,6 +60,18 @@ function placesEndpoint(endpoint: string): string {
return base.replace(/\/+$/, '') + endpoint.slice(PLACES_UPSTREAM.length);
}
/**
* Says which of the three credentials Google rejected, never which value.
*
* The response body Google sends ("The caller does not have permission") is
* identical whichever key was used, so without this line a report of "works for
* the admin, fails for everyone else" cannot be told apart from a genuinely
* broken key.
*/
function logKeyFailure(label: string, status: number, userId: number, source: ApiKeySource | null): void {
console.error(`[Maps] ${label} failed with ${status} userId=${userId} keySource=${source}`);
}
function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise<Response> {
const endpoint = placesEndpoint(rawEndpoint);
googleApiCallCount++;
@@ -548,25 +561,25 @@ export class MapsService {
// ── API key retrieval ──────────────────────────────────────────────────────
getMapsKey(userId: number): string | null {
// When the operator supplies the credential, the database is not asked at
// all. Two reasons, and the second is the load-bearing one: a per-user key
// would route around whatever the operator's endpoint counts, and the key
// columns are readable by any admin of this instance, so a shared key stored
// there would be a key handed out. Unset, this branch never runs.
const operatorKey = readEnv().maps.placesApiKey;
if (operatorKey) return operatorKey;
/**
* The Places credential for this request, and where it came from.
*
* Operator env first: a per-user key would route around whatever the
* operator's endpoint counts, and unset, that branch never runs. Then the
* instance-wide value the admin panel writes, then the caller's own row.
*
* What is deliberately gone is the old third step, "any admin's key" (#1939):
* it read a stranger's credential, which server/CLAUDE.md forbids, and made
* the answer depend on who was asking — the saving admin got their own key,
* everybody else got the lowest-id admin's and a 403 from Google. The source
* is returned so a provider error can say which of the three was used.
*/
resolveMapsKey(userId: number): { key: string | null; source: ApiKeySource | null } {
return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
}
const user = this.database.get<{ maps_api_key: string | null }>(
'SELECT maps_api_key FROM users WHERE id = ?',
userId,
);
const user_key = decrypt_api_key(user?.maps_api_key);
if (user_key) return user_key;
const admin = this.database.get<{ maps_api_key: string }>(
"SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1",
);
return decrypt_api_key(admin?.maps_api_key) || null;
getMapsKey(userId: number): string | null {
return this.resolveMapsKey(userId).key;
}
// ── Nominatim search ───────────────────────────────────────────────────────
@@ -1417,7 +1430,7 @@ export class MapsService {
lang?: string,
locationBias?: { lat: number; lng: number; radius?: number },
): Promise<{ places: Record<string, unknown>[]; source: string }> {
const apiKey = this.getMapsKey(userId);
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
if (!apiKey) {
const places = await this.searchNominatim(query, lang);
@@ -1441,8 +1454,7 @@ export class MapsService {
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask':
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus',
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify(searchBody),
});
@@ -1450,6 +1462,7 @@ export class MapsService {
const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } };
if (!response.ok) {
logKeyFailure('searchText', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
@@ -1488,7 +1501,7 @@ export class MapsService {
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
sessionToken?: string,
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
const apiKey = this.getMapsKey(userId);
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
if (!apiKey) {
return this.autocompleteNominatim(input, lang);
@@ -1526,6 +1539,7 @@ export class MapsService {
};
if (!response.ok) {
logKeyFailure('autocomplete', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
err.status = response.status;
throw err;
@@ -1960,6 +1974,7 @@ export class MapsService {
lat: string,
lng: string,
lang?: string,
opts?: { lane?: GeoLane; timeoutMs?: number },
): Promise<{ name: string | null; address: string | null }> {
const params = new URLSearchParams({
lat,
@@ -1969,7 +1984,7 @@ export class MapsService {
zoom: '18',
'accept-language': toApiLang(lang),
});
const response = await nominatimFetch('reverse', params);
const response = await nominatimFetch('reverse', params, opts);
if (!response.ok) return { name: null, address: null };
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record<string, string> };
const addr = data.address || {};
@@ -522,6 +522,11 @@ export class ImmichService {
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
// The search path has always carried these; dropping them here meant a
// photo picked out of an album could never be placed on a map, and the
// distance sort silently had nothing to sort by (#1614).
lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null,
lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets };
+3 -1
View File
@@ -5,6 +5,7 @@ import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { UnifiedMemoriesService } from './unified-memories.service';
import { PhotoResolverService } from './photo-resolver.service';
import { PhotoCaptureBackfillService } from './photo-capture-backfill.service';
import { ThumbnailService } from './thumbnail.service';
import { TrekPhotoCacheService } from './trek-photo-cache.service';
import { TrekPhotoCacheJob } from './trek-photo-cache.job';
@@ -54,6 +55,7 @@ import { StorageModule } from '../storage/storage.module';
SynologyService,
UnifiedMemoriesService,
PhotoResolverService,
PhotoCaptureBackfillService,
ThumbnailService,
TrekPhotoCacheService,
TrekPhotoCacheJob,
@@ -67,6 +69,6 @@ import { StorageModule } from '../storage/storage.module';
inject: [ImmichPhotoProvider, SynologyPhotoProvider],
},
],
exports: [MemoriesAccessService, PhotoResolverService, ImmichService, SynologyService, PhotoProviderRegistry],
exports: [MemoriesAccessService, PhotoResolverService, PhotoCaptureBackfillService, ImmichService, SynologyService, PhotoProviderRegistry],
})
export class MemoriesModule {}
@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import exifr from 'exifr';
import { PhotoResolverService } from './photo-resolver.service';
import { StorageService } from '../storage/storage.service';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
/**
* Ask the provider when and where a photo was taken, and record it (#1614).
*
* The picker already sees these values, but the add call carries only the asset
* id — widening that contract would have meant trusting the client for something
* the provider can be asked for directly, and would have left the MCP path, the
* album sync and every already-imported photo without them.
*
* Runs detached and never throws: a provider that is slow, unreachable or simply
* does not know must not fail the add the user is waiting on. A photo without
* capture metadata is the normal case, not an error — it just will not appear on
* the map.
*/
@Injectable()
export class PhotoCaptureBackfillService {
constructor(
private readonly resolver: PhotoResolverService,
private readonly photos: TrekPhotosRepository,
private readonly storage: StorageService,
) {}
/** Fire-and-forget for a batch that was just added. */
schedule(trekPhotoIds: number[], userId: number): void {
if (!trekPhotoIds.length) return;
void this.run(trekPhotoIds, userId);
}
/** Awaitable form, so tests do not have to chase a floating promise. */
async run(trekPhotoIds: number[], userId: number): Promise<void> {
for (const id of trekPhotoIds) {
try {
const photo = this.photos.resolve(id);
// A row that already knows both has nothing to gain, and a provider call
// per photo is the expensive part of an album import.
if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) continue;
// A local file has no provider to ask — the answer is in its own EXIF, and
// getPhotoInfo would only hand back what the DB row already says.
if (photo.provider === 'local') {
const meta = await this.readLocalExif(photo.file_path);
if (meta) this.photos.recordCaptureMetadata(id, meta);
continue;
}
const info = await this.resolver.getPhotoInfo(id, userId);
if (!info.success) continue;
this.photos.recordCaptureMetadata(id, {
takenAt: info.data.takenAt ?? null,
lat: info.data.lat ?? null,
lng: info.data.lng ?? null,
});
} catch (err) {
console.error(`[Photos] capture backfill failed for ${id}:`, err instanceof Error ? err.message : err);
}
}
}
/**
* EXIF of an uploaded file.
*
* Note for anyone chasing "my phone photo has no location": the client converts
* HEIC before upload and that conversion drops GPS, so an iPhone photo arrives
* here already stripped. Nothing to read is the expected outcome far more often
* than not — hence no logging on the empty case.
*/
private async readLocalExif(
filePath?: string | null,
): Promise<{ takenAt: string | null; lat: number | null; lng: number | null } | null> {
if (!filePath) return null;
// photos.file_path is uploads-relative 'journey/<name>' by every writer;
// anything else reads as a miss (same rule as photo-resolver). Central key
// validation (storage-keys.ts) rejects a name that still carries a path,
// so a stored path can never climb out of the journey category.
if (!filePath.startsWith('journey/')) return null;
const name = filePath.slice('journey/'.length);
type Exif = { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number };
let parsed: Exif | null;
try {
parsed = await this.storage.withLocalFile('journey', name, async abs =>
(await exifr.parse(abs, {
pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'],
})) as Exif | null,
);
} catch {
// A vanished object, an invalid key, not an image, a truncated upload,
// a video — none of it is an error here.
return null;
}
if (!parsed) return null;
const taken = parsed.DateTimeOriginal ?? parsed.CreateDate ?? null;
const takenAt = taken instanceof Date && !Number.isNaN(taken.getTime()) ? taken.toISOString() : null;
const lat = typeof parsed.latitude === 'number' ? parsed.latitude : null;
const lng = typeof parsed.longitude === 'number' ? parsed.longitude : null;
return takenAt || lat != null ? { takenAt, lat, lng } : null;
}
}
+6 -2
View File
@@ -507,8 +507,10 @@ export class SynologyService {
while (true) {
const params: ApiCallParams = passphrase
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
// 'gps' costs nothing extra on the same call and is the only way a
// photo picked from an album can ever reach the map (#1614).
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail', 'gps'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail', 'gps'] };
const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
if (!result.success) return result as ServiceResult<AssetsList>;
const items = result.data.list || [];
@@ -520,6 +522,8 @@ export class SynologyService {
const assets = allItems.map(item => ({
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
lat: typeof item.additional?.gps?.latitude === 'number' ? item.additional.gps.latitude : null,
lng: typeof item.additional?.gps?.longitude === 'number' ? item.additional.gps.longitude : null,
})).filter(a => a.id);
return success({ assets, total: assets.length, hasMore: false });
@@ -89,6 +89,36 @@ export class TrekPhotosRepository {
);
}
/**
* Where and when the picture was taken (#1614).
*
* COALESCE throughout, for the same reason recordLocalThumbnail does it: the
* three sources disagree in how much they know. A provider search answers with
* coordinates, the same provider's album listing does not, and a local file
* only gives them up once its EXIF has been read. Whichever arrives first wins,
* and a later, emptier answer must not blank what is already there.
*
* A photo with neither is the normal case, not a failure.
*/
recordCaptureMetadata(
photoId: number,
meta: { takenAt?: string | null; lat?: number | null; lng?: number | null },
): void {
const { takenAt = null, lat = null, lng = null } = meta;
if (takenAt == null && lat == null && lng == null) return;
// Coordinates are stored as a pair or not at all — a lone latitude is not a
// place, and half a pair would put the photo on the null island.
const hasPair = Number.isFinite(lat) && Number.isFinite(lng);
this.db.run(
`UPDATE trek_photos
SET taken_at = COALESCE(taken_at, ?),
lat = COALESCE(lat, ?),
lng = COALESCE(lng, ?)
WHERE id = ?`,
takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId,
);
}
/**
* Drop the row once no trip and no journey references it. Local photos are
* kept: their bytes are ours, and the file would outlive the row.
+6
View File
@@ -254,6 +254,12 @@ export const SEARCH_BIAS_RADIUS_METERS = 2000;
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
export const ENRICH_CONCURRENCY = 3;
// The free address backfill is one Nominatim request per place on the throttled
// background lane (~1/s), so a normal list costs seconds. Past this it stops being
// a backfill and starts being bulk geocoding, which Nominatim's usage policy asks
// people not to do — so it stops rather than queueing for an hour.
export const ADDRESS_BACKFILL_MAX_PLACES = 250;
/**
* Pick the search result that is the same place as the import: it must be a
+62 -4
View File
@@ -30,6 +30,7 @@ import { StorageService } from '../storage/storage.service';
import {
COORD_DEDUP_TOLERANCE,
ENRICH_CONCURRENCY,
ADDRESS_BACKFILL_MAX_PLACES,
escapeLikePattern,
MAX_LIST_RESPONSE_BYTES,
googleMapsFeatureIdFromItem,
@@ -1007,8 +1008,8 @@ export class PlacesService {
}
});
if (opts?.enrich && opts.userId && created.length) {
void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
if (created.length) {
void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
}
return { places: created, listName, skipped };
@@ -1150,8 +1151,8 @@ export class PlacesService {
}
});
if (opts?.enrich && opts.userId && created.length) {
void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
if (created.length) {
void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
}
return { places: created, listName, skipped };
@@ -1250,6 +1251,63 @@ export class PlacesService {
}
}
/**
* Everything a just-imported list gets in the background, in one place so the
* Google and Naver call sites cannot drift apart.
*
* The Google pass stays exactly as opt-in and key-gated as before. The address
* backfill behind it needs neither — it is the same free Nominatim reverse
* lookup a single pasted link has always had, which is what made a list import
* come out thinner than the same place added one at a time (#1954).
*/
private async enrichImportedList(tripId: string, places: EnrichablePlace[], opts?: ListImportOptions): Promise<void> {
if (opts?.enrich && opts.userId) {
await this.enrichImportedPlaces(tripId, opts.userId, places, opts.lang);
}
await this.backfillMissingAddresses(tripId, places, opts?.lang);
}
/**
* Fill in the address of imported places that have none, from Nominatim.
*
* Runs on the background lane so it never queues in front of a user's own
* search, writes through COALESCE so it can only fill an empty column — the
* Google pass above may already have written a better one — and pushes each
* row over the websocket so the sidebar fills in without a reload. Never
* throws: one bad lookup must not take down a detached task.
*/
async backfillMissingAddresses(tripId: string, places: EnrichablePlace[], lang?: string): Promise<void> {
try {
const pending = places.filter(p => !p.address && p.lat != null && p.lng != null);
if (!pending.length) return;
if (pending.length > ADDRESS_BACKFILL_MAX_PLACES) {
console.warn(`[Places] address backfill skipped for trip ${tripId}: ${pending.length} places exceeds the ${ADDRESS_BACKFILL_MAX_PLACES} cap`);
return;
}
// Serial on purpose: the background lane throttles to roughly one request a
// second anyway, so concurrency would only build a queue.
for (const place of pending) {
try {
const { address } = await this.maps.reverseGeocode(String(place.lat), String(place.lng), lang, {
lane: 'background',
timeoutMs: 10000,
});
if (!address) continue;
this.dbs.run(
'UPDATE places SET address = COALESCE(address, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
address, place.id, tripId,
);
const updated = this.dbs.getPlaceWithTags(place.id);
if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined);
} catch (err) {
console.error(`[Places] address backfill failed for place ${place.id}:`, err instanceof Error ? err.message : err);
}
}
} catch (err) {
console.error('[Places] address backfill pass failed:', err instanceof Error ? err.message : err);
}
}
// -------------------------------------------------------------------------
// Search place image (Unsplash)
// -------------------------------------------------------------------------
@@ -90,6 +90,7 @@ function parseId(value: string | string[]): number | null {
const legsSchema = transportLegsInputSchema.optional().describe(
'Per-segment detail of a stopover flight or train: one entry per segment, in route order, exactly ONE FEWER than endpoints[]. '
+ 'Each leg carries its own departure and arrival day + local time (dep_day_id/dep_time, arr_day_id/arr_time) plus airline/flight_number (flights) or train_number/platform (trains). '
+ 'A leg may also carry its own confirmation_number when that segment was issued a separate booking reference; leave it out and the booking-level confirmation_number covers the segment. '
+ "Required for a booking with stopovers: a stop's endpoint stores only the onward departure, so without legs that one time is shown as both the arrival and the departure. "
+ 'Omit it for a direct booking. Blank endpoint times/dates, day_id/end_day_id and reservation_time are derived from the legs; a value that contradicts them is rejected.'
);
@@ -207,7 +208,7 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } {
from: leg.from ?? depEp.code ?? depEp.name,
to: leg.to ?? arrEp.code ?? arrEp.name,
};
for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat'] as const) {
for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat', 'confirmation_number'] as const) {
const value = leg[key];
if (value) entry[key] = value;
}
@@ -570,7 +571,7 @@ export class ReservationsMcp {
@Tool({
name: 'create_transport',
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -678,7 +679,7 @@ export class ReservationsMcp {
@Tool({
name: 'update_transport',
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. Use status "confirmed" to confirm.',
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. A per-segment booking reference lives on the leg (legs[].confirmation_number); the top-level one stays the booking reference. Use status "confirmed" to confirm.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
@@ -0,0 +1,89 @@
import { DatabaseService } from '../database/database.service';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
/**
* The third-party keys that belong to the instance rather than to a person.
*
* Google Places and Unsplash bill the install, not the account that pasted the
* key in, so the value is instance configuration — the same argument #1772 made
* for the LLM endpoint (see llm-parse/llm-config.ts: instance-wide, admin-set,
* wins; per-user as the fallback). Both keys used to be resolved out of the
* `users` columns alone, which meant "the key of the admin with the lowest id"
* for everyone else and produced #1939: the admin searched with the key they
* had just saved, every other member searched with a stranger's and got a 403
* from Google.
*
* Stored as an encrypted `app_settings` row under the name below, reusing
* apiKeyCrypto, so the format matches what the users columns already hold and
* a legacy plaintext value still reads back.
*/
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key';
/** Instance names whose per-user column is still honoured as a last resort. */
export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = ['maps_api_key', 'unsplash_api_key'];
/**
* Where a resolved key came from. Logged beside a provider error so "works for
* the admin, 403 for everyone else" is one line in the log rather than a
* guessing game; the key itself is never logged.
*/
export type ApiKeySource = 'operator-env' | 'instance' | 'user-row';
// Full statements rather than an interpolated column: the name doubles as the
// users column AND the app_settings key, and identifiers only ever come from a
// literal allow-list.
const USER_ROW_SQL: Record<InstanceApiKeyName, string> = {
maps_api_key: 'SELECT maps_api_key FROM users WHERE id = ?',
unsplash_api_key: 'SELECT unsplash_api_key FROM users WHERE id = ?',
};
/** The instance-wide value in cleartext, or null when unset/cleared. */
export function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): string | null {
const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', name);
if (!row?.value) return null;
return decrypt_api_key(row.value) || null;
}
/**
* Store the instance-wide value, encrypted. A blank value stores '' rather than
* deleting the row: an admin who clears the field means "this instance has no
* key", and a missing row would let the resolver fall through to whatever old
* value still sits in their own users column.
*/
export function writeInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName, value: unknown): void {
db.run(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
name, maybe_encrypt_api_key(value) ?? ''
);
}
/**
* Resolve a key for one request: operator env → instance-wide → the caller's
* own row.
*
* The caller's own row is last, not second, and no other user's row is ever
* read — falling back to a stranger's key is what server/CLAUDE.md forbids and
* what #1939 reported. It stays as a fallback because `PUT /me/api-keys` has
* always accepted a personal key and dropping it would break the installs where
* each member pays for their own.
*
* `userId` 0 means "nobody is asking" (the unauthenticated app-config read):
* there is no personal key to find, so the chain ends at the instance.
*/
export function resolveApiKey(
db: DatabaseService,
name: InstanceApiKeyName,
userId: number,
operatorKey: string | undefined,
): { key: string | null; source: ApiKeySource | null } {
if (operatorKey) return { key: operatorKey, source: 'operator-env' };
const instance = readInstanceApiKey(db, name);
if (instance) return { key: instance, source: 'instance' };
if (!userId) return { key: null, source: null };
const row = db.get<Record<string, string | null>>(USER_ROW_SQL[name], userId);
const own = decrypt_api_key(row?.[name]) || null;
return own ? { key: own, source: 'user-row' } : { key: null, source: null };
}
+8 -12
View File
@@ -2,7 +2,7 @@ import { Readable } from 'node:stream';
import { v4 as uuidv4 } from 'uuid';
import { Injectable } from '@nestjs/common';
import { safeFetch } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey } from '../settings/instance-api-keys';
import { DatabaseService } from '../database/database.service';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { StorageService } from '../storage/storage.service';
@@ -42,7 +42,7 @@ const COVER_EXT_BY_TYPE: Record<string, string> = {
* Unsplash search and cover download.
*
* Its own module rather than a method on trips or places: both call it, and the
* key resolution reads the env AND the users table, so it needs the injected
* key resolution reads the env AND the database, so it needs the injected
* connection either way.
*/
@Injectable()
@@ -56,18 +56,14 @@ export class UnsplashService {
/**
* Resolve an Unsplash access key for a request, in precedence order:
* the `UNSPLASH_ACCESS_KEY` env var (instance-wide override, matching the
* SMTP/OIDC env-over-DB convention) → the caller's own stored key → any
* admin's stored key. Mirrors `getMapsKey`. Returns null when none is set,
* in which case the search falls back to the unauthenticated endpoint.
* SMTP/OIDC env-over-DB convention) → the instance-wide value the admin panel
* saves → the caller's own stored key. Mirrors `resolveMapsKey`, including what
* it dropped: "any admin's stored key" read a stranger's credential and made
* the answer depend on who asked (#1939). Returns null when none is set, in
* which case the search falls back to the unauthenticated endpoint.
*/
getUnsplashKey(userId: number): string | null {
const env_key = this.env.env().integrations.unsplashAccessKey;
if (env_key) return env_key;
const user = this.db.get<{ unsplash_api_key: string | null }>('SELECT unsplash_api_key FROM users WHERE id = ?', userId);
const user_key = decrypt_api_key(user?.unsplash_api_key);
if (user_key) return user_key;
const admin = this.db.get<{ unsplash_api_key: string }>("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1");
return decrypt_api_key(admin?.unsplash_api_key) || null;
return resolveApiKey(this.db, 'unsplash_api_key', userId, this.env.env().integrations.unsplashAccessKey).key;
}
async searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
@@ -27,12 +27,14 @@ export class WeatherController {
@Query('lng') lng?: string,
@Query('date') date?: string,
@Query('lang') lang?: string,
/** HH:MM — lets a past date answer for that hour rather than the day (#1614). */
@Query('time') time?: string,
): Promise<WeatherResult> {
if (!lat || !lng) {
throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
}
try {
return await this.weather.get(lat, lng, date, lang ?? 'de');
return await this.weather.get(lat, lng, date, lang ?? 'de', time);
} catch (err: unknown) {
throw toHttp(err, 'Weather error:', 'Error fetching weather data');
}
+55 -10
View File
@@ -1,4 +1,24 @@
// Open-Meteo sits on the request path as a third party: without a deadline a hung
// connection holds the caller until the socket gives up on its own. Every other
// outbound client in the codebase carries one.
const WEATHER_TIMEOUT_MS = 8000;
/**
* Which slot of Open-Meteo's hourly series a HH:MM belongs to.
*
* The archive answers with one entry per hour of the requested day in the local
* timezone, so the hour is the index. Anything unparseable means "no time given",
* which falls back to the daily figures rather than guessing midnight.
*/
function hourIndexFromTime(time?: string): number | null {
if (!time) return null;
const m = /^(\d{1,2}):(\d{2})/.exec(time.trim());
if (!m) return null;
const hour = Number(m[1]);
return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null;
}
// ── Interfaces ──────────────────────────────────────────────────────────
export interface WeatherResult {
@@ -177,8 +197,9 @@ async function _getWeatherImpl(
lng: string,
date: string | undefined,
lang: string,
time?: string,
): Promise<WeatherResult> {
const ck = cacheKey(lat, lng, date);
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
if (date) {
const cached = getCached(ck);
@@ -191,7 +212,7 @@ async function _getWeatherImpl(
// Forecast range (-1 .. +16 days)
if (diffDays >= -1 && diffDays <= 16) {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -222,14 +243,36 @@ async function _getWeatherImpl(
// Past date: use archive API for the actual date
if (diffDays < -1) {
const dateStr = targetDate.toISOString().slice(0, 10);
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum&timezone=auto`;
const response = await fetch(url);
// With a time in hand, ask for the hourly series too: a journal entry made at
// 14:30 wants the weather of that hour, not the day's high and low averaged
// together, which is what a rainy morning and a bright afternoon collapse to.
const hourParam = hourIndexFromTime(time) != null
? '&hourly=temperature_2m,weathercode'
: '';
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum${hourParam}&timezone=auto`;
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Archive API error');
}
const hourIdx = hourIndexFromTime(time);
const hourly = (data as { hourly?: { temperature_2m?: (number | null)[]; weathercode?: (number | null)[] } }).hourly;
if (hourIdx != null && hourly?.temperature_2m?.[hourIdx] != null) {
const temp = hourly.temperature_2m[hourIdx]!;
const code = hourly.weathercode?.[hourIdx] ?? undefined;
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
const result: WeatherResult = {
temp: Math.round(temp),
main: WMO_MAP[code!] || estimateCondition(temp, 0),
description: descriptions[code!] || '',
type: 'forecast',
};
setCache(ck, result, TTL_CLIMATE_MS);
return result;
}
const daily = data.daily;
if (daily && daily.time && daily.time.length > 0 && daily.temperature_2m_max[0] != null) {
const code = daily.weathercode?.[0];
@@ -264,7 +307,7 @@ async function _getWeatherImpl(
const endStr = endDate.toISOString().slice(0, 10);
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -317,7 +360,7 @@ async function _getWeatherImpl(
if (cached) return cached;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,weathercode&timezone=auto`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -343,15 +386,17 @@ export async function getWeather(
lng: string,
date: string | undefined,
lang: string,
/** HH:MM of the moment being asked about. Only the archive path can use it. */
time?: string,
): Promise<WeatherResult> {
const ck = cacheKey(lat, lng, date);
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
const cached = getCached(ck);
if (cached) return cached;
const inFlightKey = `${ck}:${lang}`;
const existing = inFlight.get(inFlightKey);
if (existing) return existing;
const promise = _getWeatherImpl(lat, lng, date, lang);
const promise = _getWeatherImpl(lat, lng, date, lang, time);
inFlight.set(inFlightKey, promise);
try { return await promise; } finally { inFlight.delete(inFlightKey); }
}
@@ -388,7 +433,7 @@ async function _getDetailedWeatherImpl(
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
+ `&timezone=auto`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -451,7 +496,7 @@ async function _getDetailedWeatherImpl(
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max`
+ `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
+2 -2
View File
@@ -25,8 +25,8 @@ export class WeatherService implements OnModuleInit, OnModuleDestroy {
stopCacheCleanup();
}
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
get(lat: string, lng: string, date: string | undefined, lang: string, time?: string): Promise<WeatherResult> {
return getWeather(lat, lng, date, lang, time) as Promise<WeatherResult>;
}
getDetailed(lat: string, lng: string, date: string, lang: string): Promise<WeatherResult> {
+5
View File
@@ -398,6 +398,11 @@ export interface TrekPhoto {
media_type?: string | null;
/** Optional video duration in milliseconds. */
duration_ms?: number | null;
/** When the picture was taken, as the provider or its EXIF reported it (#1614). */
taken_at?: string | null;
/** Capture coordinates. Stored as a pair or not at all. */
lat?: number | null;
lng?: number | null;
created_at: string;
}
+33
View File
@@ -184,6 +184,39 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
expect(jp.status).toBe(200);
expect(jp.body.status).toBe('planned');
});
// #1535: the same journey booked as two flights, which is what an AirTrail import
// whose legs never chained leaves behind. The hub is a legitimate 'to'/'from' pair, so
// only the ground time between them says it was a plane change. Own user again.
it('200 stats leaves out a layover country whose two legs are separate bookings', async () => {
const flyerId = createUser(db as never, { username: 'atlas-flyer', email: 'atlas-flyer@test.example' }).user.id;
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
const trip = createTrip(db as never, flyerId, {
title: 'New York via Helsinki',
start_date: iso(-20),
end_date: iso(-10),
});
const day = iso(-15);
const insertFlight = db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, ?, 'flight')");
const insertEndpoint = db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
const inbound = insertFlight.run(trip.id, 'BRU-HEL').lastInsertRowid;
insertEndpoint.run(inbound, 'from', 0, 'Brussels', 50.9014, 4.4844, 'BRU', day, '07:00');
insertEndpoint.run(inbound, 'to', 1, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '09:30');
const onward = insertFlight.run(trip.id, 'HEL-JFK').lastInsertRowid;
insertEndpoint.run(onward, 'from', 0, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '11:00');
insertEndpoint.run(onward, 'to', 1, 'New York', 40.6413, -73.7781, 'JFK', day, '15:00');
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(flyerId));
expect(res.status).toBe(200);
const codes = (res.body.countries as { code: string }[]).map((c) => c.code);
expect(codes).toContain('BE');
expect(codes).toContain('US');
expect(codes).not.toContain('FI');
expect(res.body.stats.totalCountries).toBe(2);
});
// ── /locate (#1115) ────────────────────────────────────────────────────────
describe('GET locate', () => {
it('resolves a coordinate to the country and the region the map can highlight', async () => {
+75
View File
@@ -45,6 +45,7 @@ import { MailerService } from '../../src/nest/notifications/mailer/mailer.servic
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createUser } from '../helpers/factories';
import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
import { resetRateLimits } from '../helpers/test-db';
import { AuthModule } from '../../src/nest/auth/auth.module';
import { AuthService } from '../../src/nest/auth/auth.service';
@@ -251,6 +252,80 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
expect(res.body.error).toMatch(/password/i);
});
it('PUT /me/api-keys stores the key instance-wide and audits it once (#1939)', async () => {
const admin = createUser(db as never, { username: 'keys-admin', email: 'keys-admin@example.test', role: 'admin' });
const before = auditRows('settings.api_keys_update');
const res = await request(server)
.put('/api/auth/me/api-keys')
.set('Cookie', sessionCookie(admin.user.id))
.send({ maps_api_key: 'e2e-google-key' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Masked in the response and never accompanied by the audit bookkeeping.
expect(res.body.user.maps_api_key).not.toBe('e2e-google-key');
expect(res.body).not.toHaveProperty('changedKeys');
// What every member's search will now resolve to, encrypted at rest.
const stored = db.prepare("SELECT value FROM app_settings WHERE key = 'maps_api_key'").get() as { value: string };
expect(stored.value).toMatch(/^enc:v1:/);
// And the admin panel reads back what the search uses.
const settings = await request(server).get('/api/auth/me/settings').set('Cookie', sessionCookie(admin.user.id));
expect(settings.body.settings.maps_api_key).toBe('e2e-google-key');
expect(auditRows('settings.api_keys_update')).toBe(before + 1);
const row = db
.prepare("SELECT resource, details FROM audit_log WHERE action = 'settings.api_keys_update' ORDER BY id DESC LIMIT 1")
.get() as { resource: string; details: string };
expect(row.resource).toBe('api_keys');
expect(row.details).toContain('maps_api_key');
expect(row.details).not.toContain('e2e-google-key');
// Saving the same value again is not a change, so the log does not grow.
await request(server)
.put('/api/auth/me/api-keys')
.set('Cookie', sessionCookie(admin.user.id))
.send({ maps_api_key: 'e2e-google-key' });
expect(auditRows('settings.api_keys_update')).toBe(before + 1);
}, 10000);
it('GET /app-config answers has_maps_key from the instance row, never from an admin column (#1939)', async () => {
const member = createUser(db as never, { username: 'keys-member', email: 'keys-member@example.test' });
const admin = createUser(db as never, { username: 'keys-cfg-admin', email: 'keys-cfg-admin@example.test', role: 'admin' });
// Seeded here instead of riding on the save above, so running this case on
// its own asserts the same thing.
const setInstanceKey = (value: string | null) => {
if (value === null) {
db.prepare("DELETE FROM app_settings WHERE key = 'maps_api_key'").run();
return;
}
db.prepare(
`INSERT INTO app_settings (key, value) VALUES ('maps_api_key', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
).run(encrypt_api_key(value));
};
const hasMapsKey = async (userId: number) => {
const res = await request(server).get('/api/auth/app-config').set('Cookie', sessionCookie(userId));
expect(res.status).toBe(200);
return res.body.has_maps_key;
};
setInstanceKey('instance-configured-key');
db.prepare('UPDATE users SET maps_api_key = NULL WHERE id = ?').run(admin.user.id);
// Instance key, no column anywhere: the member searches with it, so the
// client is told the feature is there.
expect(await hasMapsKey(member.user.id)).toBe(true);
// The reported half of #1939: the key exists only in one admin's column.
// It is not the member's to spend, so they are told there is none instead
// of being offered a Google search that answers 403.
setInstanceKey(null);
db.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run(encrypt_api_key('admins-own-key'), admin.user.id);
expect(await hasMapsKey(member.user.id)).toBe(false);
// That same column still counts for the admin themselves.
expect(await hasMapsKey(admin.user.id)).toBe(true);
});
it('POST /logout clears the session cookie', async () => {
const res = await request(server).post('/api/auth/logout');
expect(res.status).toBe(200);
+5 -1
View File
@@ -99,7 +99,11 @@ export function createTrip(
const end = new Date(overrides.end_date);
const tripId = result.lastInsertRowid as number;
let dayNumber = 1;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
// Step in UTC. A date-only ISO string parses as UTC midnight, but setDate()
// advances local wall-clock time — 23h or 25h across a DST transition — so the
// instant drifts off midnight and toISOString() then reports the wrong day:
// one is dropped in autumn, one repeated in spring. CI runs UTC and never sees it.
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const dateStr = d.toISOString().slice(0, 10);
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, dayNumber++, dateStr);
}
@@ -43,10 +43,17 @@ describe('incoming_leg_transport_mode migration', () => {
//
// >>> Appending a migration? Re-point the undo below at whatever yours
// >>> does. That is the whole maintenance cost of this guard.
// Trailing migrations today (a dev/feat-storage-driver merge landed two
// in one release window, so the rewind spans both):
// version-2 — reservation_day_positions cross-trip cleanup: undone by
// Trailing migrations today (a dev/feat-storage-driver merge landed five
// in one release window, so the rewind spans all of them):
// version-5 — reservation_day_positions cross-trip cleanup: undone by
// re-inserting a mismatched reservation/day pair.
// version-4 — #1939 instance API key promotion: nothing to undo — the
// seeded users are not admins and hold no keys, so the
// replay is a no-op by its own guards.
// version-3 — trek_photos capture metadata (#1614): column-guarded, so
// the replay is a no-op; the from-scratch case above covers it.
// version-2 — journey_share_tokens.newest_first: undone by dropping the
// column and letting the replay put it back.
// version-1 — storage slice-2 trip_files 'files/' prefix strip: undone
// by re-inserting a prefixed row.
const upgraded = new Database(':memory:');
@@ -68,7 +75,15 @@ describe('incoming_leg_transport_mode migration', () => {
insertPosition.run(reservationOnA, dayOnA, 2); // a legitimate one it must leave alone
upgraded.prepare("INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, 'files/aaa.pdf', 'a.pdf')").run(tripA);
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 2);
const hasNewestFirst = () =>
(upgraded.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as { name: string }[])
.some(c => c.name === 'newest_first');
expect(hasNewestFirst()).toBe(true);
upgraded.exec('ALTER TABLE journey_share_tokens DROP COLUMN newest_first');
expect(hasNewestFirst()).toBe(false);
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 5);
runMigrations(upgraded);
@@ -76,6 +91,7 @@ describe('incoming_leg_transport_mode migration', () => {
expect(positions).toEqual([{ day_id: Number(dayOnA) }]);
const files = upgraded.prepare('SELECT filename FROM trip_files').all() as { filename: string }[];
expect(files.map(r => r.filename)).toEqual(['aaa.pdf']);
expect(hasNewestFirst()).toBe(true);
expect(upgraded.prepare('SELECT version FROM schema_version').get()).toEqual({ version });
upgraded.close();
});
@@ -56,7 +56,7 @@ const { testDb, dbMock, immichState } = vi.hoisted(() => {
* the legacy one. Both must be filtered out of the picker (#1474).
*/
const DEFAULT_ALBUM_ASSETS = [
{ id: 'asset-sync-1', type: 'IMAGE', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France' } },
{ id: 'asset-sync-1', type: 'IMAGE', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France', latitude: 48.8584, longitude: 2.2945 } },
{ id: 'asset-sync-2', type: 'VIDEO', fileCreatedAt: '2024-06-02T10:00:00.000Z', exifInfo: { city: 'Lyon', country: 'France' } },
{ id: 'asset-hidden', type: 'VIDEO', fileCreatedAt: '2024-06-03T10:00:00.000Z', visibility: 'hidden' },
{ id: 'asset-legacy-hidden', type: 'VIDEO', fileCreatedAt: '2024-06-04T10:00:00.000Z', isVisible: false },
@@ -590,6 +590,23 @@ describe('Immich album photos', () => {
expect(res.body.assets[1].mediaType).toBe('video');
});
// #1614 — the album mapping used to drop lat/lng even though the search path
// kept them, so a photo picked from an album could never reach a map.
it('IMMICH-064b — album photos carry their capture coordinates, and tolerate their absence', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
expect(res.body.assets[0]).toMatchObject({ lat: 48.8584, lng: 2.2945 });
// The second fixture asset has no coordinates; it must come back null, not undefined
// or a half pair.
expect(res.body.assets[1].lat).toBeNull();
expect(res.body.assets[1].lng).toBeNull();
});
it('IMMICH-065 — album photos are fetched via search/metadata with albumIds and withExif', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
+29
View File
@@ -136,6 +136,35 @@ describe('API key encryption', () => {
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
});
it('SEC-008 — saving keys audits the names and never the values (#1939)', async () => {
const { user } = createUser(testDb);
const save = () =>
request(app)
.put('/api/auth/me/api-keys')
.set('Cookie', authCookie(user.id))
.send({ openweather_api_key: 'test-api-key-12345' });
const first = await save();
expect(first.status).toBe(200);
// changedKeys is internal: the client body is what it always was.
expect(first.body).not.toHaveProperty('changedKeys');
const rows = () =>
testDb
.prepare("SELECT details FROM audit_log WHERE action = 'settings.api_keys_update'")
.all() as { details: string | null }[];
expect(rows()).toHaveLength(1);
expect(rows()[0].details).toContain('openweather_api_key');
expect(rows()[0].details).not.toContain('test-api-key-12345');
// The same value again writes no second row. This is the real test of the
// cleartext comparison: encryption uses a random IV, so the stored blob
// differs on every save even when the key does not.
await save();
expect(rows()).toHaveLength(1);
});
it('SEC-008 — GET /api/auth/me does not return plaintext API key', async () => {
const { user } = createUser(testDb);
await request(app)
@@ -0,0 +1,141 @@
/**
* Unit test for the instance API-key backfill migration (#1939).
*
* The Places/Unsplash credential moved from "whichever admin row the resolver
* happened to find" to an instance-wide app_settings row. An install that
* upgrades must keep searching with the key it already had, so the migration
* copies the value the old resolver would have handed out — the lowest-id admin
* who has one — and leaves the users columns alone. It only does so where that
* value was the whole install's key: as soon as a second row holds one, the
* promotion is skipped, because the instance row outranks everybody's own
* column and nobody may be moved onto a stranger's key by an upgrade.
*
* Seeded before runMigrations rather than by rewinding schema_version
* afterwards: createTables already brings users.role and both key columns, so
* the backfill fires on its normal pass and finds the rows. That keeps this
* file independent of where the entry sits in the append-only array, so
* appending the next migration does not drag it along. The one guard that does
* have to track the tail is tests/integration/leg-mode-incoming.test.ts, which
* says so in place.
*/
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
function freshDb() {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
createTables(db);
return db;
}
function seedUser(
db: Database.Database,
id: number,
role: 'admin' | 'user',
keys: { maps?: string; unsplash?: string } = {},
) {
db.prepare(
`INSERT INTO users (id, username, email, password_hash, role, maps_api_key, unsplash_api_key)
VALUES (?, ?, ?, 'x', ?, ?, ?)`
).run(id, `u${id}`, `u${id}@test.local`, role, keys.maps ?? null, keys.unsplash ?? null);
}
const setting = (db: Database.Database, key: string) =>
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
describe('instance API-key backfill migration', () => {
it('KEYFILL-001: copies the one key the install was searching with and keeps the column', () => {
const db = freshDb();
seedUser(db, 1, 'admin', { maps: 'the-admin-google', unsplash: 'the-admin-unsplash' });
seedUser(db, 2, 'user');
runMigrations(db);
expect(setting(db, 'maps_api_key')).toBe('the-admin-google');
expect(setting(db, 'unsplash_api_key')).toBe('the-admin-unsplash');
// Nothing is taken away: the columns are still the per-user fallback.
const row = db.prepare('SELECT maps_api_key FROM users WHERE id = 1').get() as { maps_api_key: string };
expect(row.maps_api_key).toBe('the-admin-google');
db.close();
});
it('KEYFILL-002: skips an admin whose column is empty and takes the next one', () => {
const db = freshDb();
seedUser(db, 1, 'admin', { maps: '' });
seedUser(db, 2, 'admin', { maps: 'the-only-real-key' });
runMigrations(db);
expect(setting(db, 'maps_api_key')).toBe('the-only-real-key');
db.close();
});
it('KEYFILL-003: never overwrites a value the admin has already saved instance-wide', () => {
const db = freshDb();
seedUser(db, 1, 'admin', { maps: 'old-column-key' });
db.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'already-instance-wide')").run();
runMigrations(db);
expect(setting(db, 'maps_api_key')).toBe('already-instance-wide');
db.close();
});
it('KEYFILL-004: ignores a non-admin key and writes no row at all', () => {
const db = freshDb();
seedUser(db, 1, 'user', { maps: 'members-own-key' });
runMigrations(db);
// A member's key stays theirs — the resolver still finds it for them, and
// promoting it would hand their billing to the whole instance.
expect(setting(db, 'maps_api_key')).toBeUndefined();
db.close();
});
it('KEYFILL-005: writes nothing on an install that never had a key', () => {
const db = freshDb();
seedUser(db, 1, 'admin');
runMigrations(db);
expect(setting(db, 'maps_api_key')).toBeUndefined();
expect(setting(db, 'unsplash_api_key')).toBeUndefined();
db.close();
});
it('KEYFILL-006: leaves a column alone once a member holds a key of their own', () => {
const db = freshDb();
seedUser(db, 1, 'admin', { maps: 'admins-own-google', unsplash: 'admins-own-unsplash' });
seedUser(db, 7, 'user', { maps: 'members-own-google' });
runMigrations(db);
// Member 7 pays for their own Google key. Promoting the admin's would put
// every one of their searches on his key and his bill, because the instance
// row is resolved before their own column.
expect(setting(db, 'maps_api_key')).toBeUndefined();
// Decided per column: nobody else has an Unsplash key, so that one really
// was the whole install's and stays it.
expect(setting(db, 'unsplash_api_key')).toBe('admins-own-unsplash');
const row = db.prepare('SELECT maps_api_key FROM users WHERE id = 7').get() as { maps_api_key: string };
expect(row.maps_api_key).toBe('members-own-google');
db.close();
});
it('KEYFILL-007: two admins with their own keys both keep them', () => {
const db = freshDb();
seedUser(db, 1, 'admin', { maps: 'first-admin-google' });
seedUser(db, 2, 'admin', { maps: 'second-admin-google' });
runMigrations(db);
expect(setting(db, 'maps_api_key')).toBeUndefined();
const rows = db.prepare('SELECT maps_api_key FROM users ORDER BY id').all() as { maps_api_key: string }[];
expect(rows.map((r) => r.maps_api_key)).toEqual(['first-admin-google', 'second-admin-google']);
db.close();
});
});
@@ -0,0 +1,49 @@
/**
* Parity guard for scripts/migrate-encryption.ts.
*
* The rotation script re-encrypts at-rest secrets from one ENCRYPTION_KEY to the
* next, and it walks a hardcoded list of app_settings keys. It cannot import the
* canonical list from src/, because it deliberately runs without config.ts and
* without the Nest container, so the list is a copy.
*
* A missing name does not fail loudly: the value stays encrypted under the old
* key, decrypt_api_key returns null on the next read, and the instance quietly
* behaves as if no key were configured. This pins the copy instead.
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { INSTANCE_API_KEY_NAMES } from '../../../src/nest/settings/instance-api-keys';
const script = fs.readFileSync(
path.join(__dirname, '..', '..', '..', 'scripts', 'migrate-encryption.ts'),
'utf8',
);
// The app_settings loop only, so a name that happens to appear elsewhere in the
// script (a users column of the same name, a comment) cannot satisfy the test.
const appSettingsLoop = (() => {
const start = script.indexOf("'oidc_client_secret'");
expect(start).toBeGreaterThan(-1);
const end = script.indexOf('--- users:', start);
expect(end).toBeGreaterThan(start);
return script.slice(start, end);
})();
describe('migrate-encryption.ts app_settings parity', () => {
it('ROTPAR-001: rotates every instance-wide API key', () => {
for (const name of INSTANCE_API_KEY_NAMES) {
expect(appSettingsLoop).toContain(`'${name}'`);
}
});
it('ROTPAR-002: still rotates the app_settings secrets that predate them', () => {
for (const name of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
expect(appSettingsLoop).toContain(`'${name}'`);
}
});
it('ROTPAR-003: the canonical list is not empty, so ROTPAR-001 cannot pass vacuously', () => {
expect(INSTANCE_API_KEY_NAMES.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,299 @@
/**
* Unit tests for MCP leg-mode tools: set_leg_transport_mode,
* set_day_default_transport_mode.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, 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: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// set_leg_transport_mode
// ---------------------------------------------------------------------------
describe('Tool: set_leg_transport_mode', () => {
it('sets the outgoing leg mode (default direction)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
// Seed the incoming column so the assertion below proves it survives; the factory
// leaves it NULL, so without this it would pass whether or not the tool touched it.
testDb.prepare('UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?').run('walking', assignment.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_leg_transport_mode',
arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: 'cycling' },
});
const data = parseToolResult(result) as any;
expect(data.assignment.leg_transport_mode).toBe('cycling');
expect(data.assignment.incoming_leg_transport_mode).toBe('walking'); // outgoing must not touch incoming (panel item 4)
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object));
});
});
it('sets the incoming leg mode without clobbering the outgoing column (column independence)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
// Seed the outgoing column so we prove incoming does NOT touch it (panel item 4).
testDb.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run('driving', assignment.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_leg_transport_mode',
arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: 'walking', direction: 'incoming' },
});
const data = parseToolResult(result) as any;
expect(data.assignment.incoming_leg_transport_mode).toBe('walking');
expect(data.assignment.leg_transport_mode).toBe('driving'); // outgoing untouched
});
});
it('rejects an assignment belonging to another trip (cross-trip join)', async () => {
// Proves the `AND d.trip_id = ?` join in getAssignmentForTrip; a 99999 id cannot.
const { user } = createUser(testDb);
const trip1 = createTrip(testDb, user.id);
const trip2 = createTrip(testDb, user.id);
const day2 = createDay(testDb, trip2.id);
const place2 = createPlace(testDb, trip2.id);
const assignment2 = createDayAssignment(testDb, day2.id, place2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_leg_transport_mode',
arguments: { tripId: trip1.id, assignmentId: assignment2.id, transport_mode: 'walking' },
});
expect(result.isError).toBe(true);
});
});
it('clears the leg mode with null (inherit day default)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run('cycling', assignment.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_leg_transport_mode',
arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: null },
});
const data = parseToolResult(result) as any;
expect(data.assignment.leg_transport_mode).toBeNull();
});
});
it('returns error when assignment not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_leg_transport_mode',
arguments: { tripId: trip.id, assignmentId: 99999, transport_mode: 'walking' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_leg_transport_mode',
arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: 'walking' },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_leg_transport_mode',
arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: 'walking' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// set_day_default_transport_mode
// ---------------------------------------------------------------------------
describe('Tool: set_day_default_transport_mode', () => {
it('sets the day default transport mode', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_day_default_transport_mode',
arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'driving' },
});
const data = parseToolResult(result) as any;
expect(data.day.default_transport_mode).toBe('driving');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:updated', expect.any(Object));
});
});
it('clears the day default with null', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
testDb.prepare('UPDATE days SET default_transport_mode = ? WHERE id = ?').run('driving', day.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_day_default_transport_mode',
arguments: { tripId: trip.id, dayId: day.id, transport_mode: null },
});
const data = parseToolResult(result) as any;
expect(data.day.default_transport_mode).toBeNull();
});
});
it('returns the day with its populated assignments array', async () => {
// setDefaultTransportMode returns { ...day, assignments }; exercise the non-empty case (panel item 4).
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_day_default_transport_mode',
arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'driving' },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.day.assignments)).toBe(true);
expect(data.day.assignments).toHaveLength(1);
expect(data.day.assignments[0].id).toBe(assignment.id);
});
});
it('rejects a day belonging to another trip (cross-trip join)', async () => {
const { user } = createUser(testDb);
const trip1 = createTrip(testDb, user.id);
const trip2 = createTrip(testDb, user.id);
const day2 = createDay(testDb, trip2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_day_default_transport_mode',
arguments: { tripId: trip1.id, dayId: day2.id, transport_mode: 'walking' },
});
expect(result.isError).toBe(true);
});
});
it('returns error when day not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_day_default_transport_mode',
arguments: { tripId: trip.id, dayId: 99999, transport_mode: 'walking' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_day_default_transport_mode',
arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'walking' },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_day_default_transport_mode',
arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'walking' },
});
expect(result.isError).toBe(true);
});
});
});
@@ -506,6 +506,33 @@ describe('Tool: create_transport (multi-leg)', () => {
});
});
it('stores a per-segment booking reference and leaves the booking-level one alone (#1943)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const data = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
legs: [
{ from: 'AMS', to: 'CDG', confirmation_number: 'ABC123' },
{ from: 'CDG', to: 'FCO' },
],
},
})) as any;
const meta = storedMetadata(data.reservation.id);
expect(meta.legs[0].confirmation_number).toBe('ABC123');
// A leg without one gets no key at all, rather than an undefined that
// serialises away and back as null.
expect(Object.keys(meta.legs[1])).not.toContain('confirmation_number');
// The reference of a single segment must never become the booking's: that
// is a column of its own, fed only by the tool's own parameter.
expect(meta.confirmation_number).toBeUndefined();
expect(data.reservation.confirmation_number).toBeNull();
});
});
it('never overwrites a flat key the caller set itself', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -806,6 +833,29 @@ describe('Tool: update_transport (multi-leg)', () => {
});
});
it('keeps a per-segment booking reference through a legs-only update (#1943)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = await seedStopover(h, trip.id);
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00', confirmation_number: 'ABC123' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00', confirmation_number: 'XYZ789' },
],
},
});
const meta = storedMetadata((parseToolResult(result) as any).reservation.id);
// The copy-through list in applyLegs is what carries the field: a field
// missing from it is accepted by the schema and then silently dropped.
expect(meta.legs.map((l: any) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
expect(meta.confirmation_number).toBeUndefined();
});
});
it('uses the supplied metadata as the base when both are sent', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -0,0 +1,337 @@
/**
* The layover pairing behind #1535. Pure, so it gets its own file and no DB: the
* whole question is which two reservation_endpoints rows describe one plane change
* spread over two bookings, and every case below is a shape the AirTrail import
* really produces (timed legs, date-only legs, a leg whose booking clock belongs
* to the other airport).
*/
import { describe, it, expect } from 'vitest';
import {
MAX_CLOCKLESS_DAY_GAP,
MAX_LAYOVER_MS,
transferEndpointIds,
type FlightEndpointRow,
} from '../../../src/nest/atlas/transfer-endpoints';
interface Airport {
code: string | null;
lat: number;
lng: number;
}
const BRU: Airport = { code: 'BRU', lat: 50.9014, lng: 4.4844 };
const HEL: Airport = { code: 'HEL', lat: 60.3172, lng: 24.9633 };
const JFK: Airport = { code: 'JFK', lat: 40.6413, lng: -73.7781 };
/** Brussels' other airport, 45 km from BRU: the one a cheap return lands at. */
const CRL: Airport = { code: 'CRL', lat: 50.4592, lng: 4.4538 };
/** Amsterdam, 170 km from Brussels, which is two cities and not one. */
const AMS: Airport = { code: 'AMS', lat: 52.3105, lng: 4.7683 };
const FRA: Airport = { code: 'FRA', lat: 50.0379, lng: 8.5622 };
/** Same airport as HEL, but geocoded instead of imported, so no IATA code. */
const HEL_NO_CODE: Airport = { code: null, lat: 60.3174, lng: 24.9629 };
interface Clock {
date?: string | null;
time?: string | null;
/** The booking's own reservation_time/_end_time, which is all a date-only leg has. */
fallback?: string | null;
}
let nextId = 0;
function endpoint(
reservationId: number,
role: 'from' | 'to' | 'stop',
at: Airport,
clock: Clock = {},
tripId = 1,
): FlightEndpointRow {
return {
id: ++nextId,
reservation_id: reservationId,
trip_id: tripId,
role,
code: at.code,
lat: at.lat,
lng: at.lng,
local_date: clock.date ?? null,
local_time: clock.time ?? null,
fallback_time: clock.fallback ?? null,
};
}
/**
* BRU → HEL as one booking and HEL → JFK as the next, which is what the importer
* writes when the two legs never chained. `arrival`/`departure` are the clocks at
* the hub, the only ones the pairing looks at.
*/
function splitChain(arrival: Clock, departure: Clock, hub: Airport = HEL, hubAgain: Airport = hub) {
const rows = [
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'to', hub, arrival),
endpoint(2, 'from', hubAgain, departure),
endpoint(2, 'to', JFK, { date: departure.date ?? '2026-08-01', time: '15:00' }),
];
return { rows, hubIds: [rows[1].id, rows[2].id].sort((a, b) => a - b) };
}
const ids = (found: Set<number>) => [...found].sort((a, b) => a - b);
describe('transferEndpointIds', () => {
it('keeps the 24 h window the importers use to merge the legs of one journey', () => {
expect(MAX_LAYOVER_MS).toBe(24 * 3600 * 1000);
expect(MAX_CLOCKLESS_DAY_GAP).toBe(1);
});
it('pairs an arrival and an onward departure at the same airport code', () => {
const { rows, hubIds } = splitChain({ date: '2026-08-01', time: '09:30' }, { date: '2026-08-01', time: '11:00' });
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
});
it('pairs on the coordinate when neither row carries a code', () => {
const { rows, hubIds } = splitChain(
{ date: '2026-08-01', time: '09:30' },
{ date: '2026-08-01', time: '11:00' },
HEL_NO_CODE,
);
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
});
it('pairs a coded row with an uncoded one at the same coordinate', () => {
const { rows, hubIds } = splitChain(
{ date: '2026-08-01', time: '09:30' },
{ date: '2026-08-01', time: '11:00' },
HEL,
HEL_NO_CODE,
);
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
});
it('matches codes case-insensitively', () => {
const { rows, hubIds } = splitChain(
{ date: '2026-08-01', time: '09:30' },
{ date: '2026-08-01', time: '11:00' },
{ code: 'hel', lat: 1, lng: 1 },
{ code: 'HEL', lat: 2, lng: 2 },
);
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
});
it('pairs a turnaround with no ground time at all and one of exactly 24 h', () => {
for (const departure of [
{ date: '2026-08-01', time: '09:30' },
{ date: '2026-08-02', time: '09:30' },
]) {
const { rows, hubIds } = splitChain({ date: '2026-08-01', time: '09:30' }, departure);
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
}
});
it('leaves a stay of 24 h and a minute, or a departure before the arrival, counted', () => {
for (const departure of [
{ date: '2026-08-02', time: '09:31' },
{ date: '2026-08-01', time: '09:29' },
]) {
const { rows } = splitChain({ date: '2026-08-01', time: '09:30' }, departure);
expect(transferEndpointIds(rows).size).toBe(0);
}
});
it('falls back to the calendar date when a leg has no clock, same day or the next', () => {
for (const date of ['2026-08-01', '2026-08-02']) {
const { rows, hubIds } = splitChain({ date: '2026-08-01' }, { date });
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
}
});
it('treats an unreadable clock as no clock and pairs on the date', () => {
const { rows, hubIds } = splitChain({ date: '2026-08-01', time: 'midday' }, { date: '2026-08-01', time: '11:00' });
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
});
it('keeps a hub the traveler left two days later', () => {
const { rows } = splitChain({ date: '2026-08-01' }, { date: '2026-08-03' });
expect(transferEndpointIds(rows).size).toBe(0);
});
it('keeps a hub whose onward departure has no date at all', () => {
const { rows } = splitChain({ date: '2026-08-01', time: '09:30' }, {});
expect(transferEndpointIds(rows).size).toBe(0);
});
it('reads the date out of the booking column but never its clock', () => {
// A date-only AirTrail flight leaves the arrival endpoint without local parts, and
// reservation_time holds the DEPARTURE airport's local clock, 22:00 in Brussels for
// a flight that landed in Helsinki that morning. Using it as an arrival time would
// make the onward 11:00 departure look like it left before the plane landed.
const { rows, hubIds } = splitChain(
{ fallback: '2026-08-01T22:00' },
{ date: '2026-08-01', time: '11:00' },
);
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
});
it('never pairs the two ends of one booking', () => {
const rows = [
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
];
expect(transferEndpointIds(rows).size).toBe(0);
});
it('never pairs across two trips', () => {
const rows = [
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }, 2),
endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }, 2),
];
expect(transferEndpointIds(rows).size).toBe(0);
});
it('never treats a same-day out-and-back as a connection', () => {
// Flown out in the morning, back in the evening: the day was spent in Finland.
const rows = [
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '18:00' }),
endpoint(2, 'to', BRU, { date: '2026-08-01', time: '20:30' }),
];
expect(transferEndpointIds(rows).size).toBe(0);
});
it("never treats an out-and-back into the city's other airport as a connection", () => {
// Flown to Helsinki in the morning, back to Brussels in the evening, but the return
// lands at Charleroi instead of Zaventem. The day was still spent in Finland, and
// the airport keys alone can never see that the two ends are one city.
const rows = [
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '18:00' }),
endpoint(2, 'to', CRL, { date: '2026-08-01', time: '20:30' }),
];
expect(transferEndpointIds(rows).size).toBe(0);
});
it('still pairs a hub between two airports that are their own cities', () => {
// Amsterdam to Frankfurt to Brussels is a real connection, and AMS and BRU are
// 170 km apart, so the radius that catches a sibling airport must not reach here.
const rows = [
endpoint(1, 'from', AMS, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'to', FRA, { date: '2026-08-01', time: '08:15' }),
endpoint(2, 'from', FRA, { date: '2026-08-01', time: '10:00' }),
endpoint(2, 'to', BRU, { date: '2026-08-01', time: '11:05' }),
];
expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
});
it('keeps an overnight return through the sibling airport out of the pairing', () => {
// Same shape a night away on business has: 22.5 h on the ground, home into Gatwick
// instead of Heathrow. Still inside the 24 h window, so only the direction says no.
const LHR: Airport = { code: 'LHR', lat: 51.47, lng: -0.4543 };
const LGW: Airport = { code: 'LGW', lat: 51.1537, lng: -0.1821 };
const rows = [
endpoint(1, 'from', LHR, { date: '2026-08-01', time: '06:30' }),
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
endpoint(2, 'from', HEL, { date: '2026-08-02', time: '08:00' }),
endpoint(2, 'to', LGW, { date: '2026-08-02', time: '09:45' }),
];
expect(transferEndpointIds(rows).size).toBe(0);
});
it('pairs only inside one trip, even where the hub and the clocks would fit', () => {
// Two journeys through Helsinki on one day. The second trip's arrival has no onward
// leg of its own, and the first trip's departure two hours later must not become one.
const rows = [
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }),
endpoint(3, 'from', BRU, { date: '2026-08-01', time: '06:00' }, 2),
endpoint(3, 'to', HEL, { date: '2026-08-01', time: '09:00' }, 2),
];
expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
});
it('pairs across midnight when only one of the two legs carries a clock', () => {
// The scan looks ahead from the arrival's date, not from its instant, because a
// date-only leg is anchored on midnight: landing at 23:50 and leaving on a flight
// the next day is one connection, and so is a dateless landing left at 23:45.
const lateArrival = splitChain({ date: '2026-08-01', time: '23:50' }, { date: '2026-08-02' });
expect(ids(transferEndpointIds(lateArrival.rows))).toEqual(lateArrival.hubIds);
const lateDeparture = splitChain({ date: '2026-08-01' }, { date: '2026-08-02', time: '23:45' });
expect(ids(transferEndpointIds(lateDeparture.rows))).toEqual(lateDeparture.hubIds);
});
it('pairs nothing when neither side has a readable date', () => {
for (const clock of [{}, { fallback: 'sometime in August' }, { date: '01/08/2026' }]) {
const { rows } = splitChain(clock, clock);
expect(transferEndpointIds(rows).size).toBe(0);
}
});
it('pairs nothing when a date passes the shape check but is not a real date', () => {
for (const time of [null, '09:30']) {
const { rows } = splitChain({ date: '2026-13-45', time }, { date: '2026-13-45', time });
expect(transferEndpointIds(rows).size).toBe(0);
}
});
it('pairs an onward leg whose booking never got a second endpoint', () => {
// A leg AirTrail has no coordinates for loses that endpoint, so the onward booking
// has a 'from' and nothing else, which must not read as flying back to the origin.
const rows = [
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
];
expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
});
it('ignores a stop row rather than reading it as an arrival', () => {
// The callers filter roles in SQL, but a 'stop' is already handled by #1486 and must
// never become one half of a pair here.
const rows = [
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'stop', HEL, { date: '2026-08-01', time: '09:30' }),
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }),
];
expect(transferEndpointIds(rows).size).toBe(0);
});
it('ignores a row that can be keyed neither by code nor by coordinate', () => {
const { rows } = splitChain(
{ date: '2026-08-01', time: '09:30' },
{ date: '2026-08-01', time: '11:00' },
{ code: null, lat: NaN, lng: NaN },
);
expect(transferEndpointIds(rows).size).toBe(0);
});
it('has nothing to pair with a single endpoint', () => {
expect(transferEndpointIds([endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' })]).size).toBe(0);
});
it('marks only the pair that matches when the same hub is used again later', () => {
// Out via Helsinki on the 1st, home via Helsinki on the 8th, but that time with
// three days in the city, so only the first pass is a plane change.
const rows = [
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }),
endpoint(3, 'from', JFK, { date: '2026-08-08', time: '01:00' }),
endpoint(3, 'to', HEL, { date: '2026-08-08', time: '10:00' }),
endpoint(4, 'from', HEL, { date: '2026-08-11', time: '12:00' }),
endpoint(4, 'to', BRU, { date: '2026-08-11', time: '14:30' }),
];
expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
});
});
+158 -3
View File
@@ -54,11 +54,14 @@ function insertReservationEndpoint(
role: 'from' | 'to' | 'stop',
sequence: number,
lat: number,
lng: number
lng: number,
code: string | null = null,
localDate: string | null = null,
localTime: string | null = null
) {
db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)'
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng);
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng, code, localDate, localTime);
}
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
@@ -202,6 +205,158 @@ describe('getStats', () => {
});
});
// ── #1535: the layover the role filter can't see ─────────────────────────────
/**
* When the legs of one journey arrive as two separate bookings (an AirTrail import
* whose flights never chained), the connection airport is the legitimate 'to' of the
* first and the legitimate 'from' of the second, so the role filter from #1486 lets
* it through. Brussels to Helsinki to New York throughout: BE and US were reached,
* Finland was a plane change.
*/
describe('getStats layover pairs across two bookings', () => {
const BRU = { lat: 50.9014, lng: 4.4844, code: 'BRU' };
const HEL = { lat: 60.3172, lng: 24.9633, code: 'HEL' };
const JFK = { lat: 40.6413, lng: -73.7781, code: 'JFK' };
type Clock = { date: string | null; time: string | null };
function inboundToHelsinki(tripId: number, arrival: Clock) {
const res = createReservation(testDb, tripId, { type: 'flight', title: 'BRU-HEL' });
insertReservationEndpoint(testDb, res.id, 'from', 0, BRU.lat, BRU.lng, BRU.code, arrival.date, '07:00');
insertReservationEndpoint(testDb, res.id, 'to', 1, HEL.lat, HEL.lng, HEL.code, arrival.date, arrival.time);
return res;
}
function onwardFromHelsinki(tripId: number, departure: Clock, type = 'flight') {
const res = createReservation(testDb, tripId, { type, title: 'HEL-JFK' });
insertReservationEndpoint(testDb, res.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, departure.date, departure.time);
insertReservationEndpoint(testDb, res.id, 'to', 1, JFK.lat, JFK.lng, JFK.code, departure.date, '15:00');
return res;
}
const codesFor = async (userId: number) =>
(await atlas.stats(userId)).countries.map((c: { code: string }) => c.code);
it('ATLAS-UNIT-044 (#1535): a hub arrived at and left again the same day is not visited', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' });
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' });
const codes = await codesFor(user.id);
expect(codes).toContain('BE');
expect(codes).toContain('US');
expect(codes).not.toContain('FI');
});
it('ATLAS-UNIT-045 (#1535): a hub the traveler left two days later stays visited', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover' });
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
onwardFromHelsinki(trip.id, { date: '2026-08-03', time: '11:00' });
expect(await codesFor(user.id)).toContain('FI');
});
it('ATLAS-UNIT-046 (#1535): a pair with no clocks pairs on the booking dates', async () => {
// The case the reporter actually has: a date-only AirTrail flight leaves the
// endpoints without local parts, so only reservation_time carries the day.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' });
const inbound = inboundToHelsinki(trip.id, { date: null, time: null });
const onward = onwardFromHelsinki(trip.id, { date: null, time: null });
const setTime = testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
setTime.run('2026-08-01', inbound.id);
setTime.run('2026-08-01', onward.id);
const codes = await codesFor(user.id);
expect(codes).toContain('BE');
expect(codes).toContain('US');
expect(codes).not.toContain('FI');
});
it('ATLAS-UNIT-047 (#1535): clockless bookings two days apart keep the hub visited', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover' });
const inbound = inboundToHelsinki(trip.id, { date: null, time: null });
const onward = onwardFromHelsinki(trip.id, { date: null, time: null });
const setTime = testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
setTime.run('2026-08-01', inbound.id);
setTime.run('2026-08-03', onward.id);
expect(await codesFor(user.id)).toContain('FI');
});
it('ATLAS-UNIT-048 (#1535): a place in the layover country keeps it visited', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' });
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' });
insertPlace(testDb, trip.id, 'Kamppi Chapel', 'Simonkatu 7, Helsinki, Finland');
expect(await codesFor(user.id)).toContain('FI');
});
it('ATLAS-UNIT-049 (#1366 guard): a car rental picked up where the flight landed keeps the country', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Driving Finland' });
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' }, 'car_rental');
expect(await codesFor(user.id)).toContain('FI');
});
it('ATLAS-UNIT-050 (#1535): a cancelled onward flight is no connection', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Helsinki, stuck' });
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
const onward = onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' });
testDb.prepare('UPDATE reservations SET status = ? WHERE id = ?').run('cancelled', onward.id);
expect(await codesFor(user.id)).toContain('FI');
});
it('ATLAS-UNIT-051 (#1535): an onward flight on another trip is another journey', async () => {
// Landing home from one trip and leaving on the next within a day is not a plane
// change, and the pairing spans every trip the user can see.
const { user } = createUser(testDb);
const arriving = createTrip(testDb, user.id, { title: 'Back from Brussels' });
const leaving = createTrip(testDb, user.id, { title: 'Off to New York' });
inboundToHelsinki(arriving.id, { date: '2026-08-01', time: '09:30' });
onwardFromHelsinki(leaving.id, { date: '2026-08-01', time: '11:00' });
expect(await codesFor(user.id)).toContain('FI');
});
it('ATLAS-UNIT-052 (#1535): a same-day out-and-back is a day in the country, not a transfer', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'A day in Helsinki' });
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
const back = createReservation(testDb, trip.id, { type: 'flight', title: 'HEL-BRU' });
insertReservationEndpoint(testDb, back.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, '2026-08-01', '18:00');
insertReservationEndpoint(testDb, back.id, 'to', 1, BRU.lat, BRU.lng, BRU.code, '2026-08-01', '20:30');
expect(await codesFor(user.id)).toContain('FI');
});
it('ATLAS-UNIT-053 (#1535): a return into the other airport of the same city is still a day away', async () => {
// Same day trip as ATLAS-UNIT-052, but home into Charleroi instead of Zaventem, the
// way a cheap return is booked. Comparing the airports alone reads that as flying
// onwards and drops Finland, though the traveler spent the day in Helsinki.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'A day in Helsinki' });
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
const back = createReservation(testDb, trip.id, { type: 'flight', title: 'HEL-CRL' });
insertReservationEndpoint(testDb, back.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, '2026-08-01', '18:00');
insertReservationEndpoint(testDb, back.id, 'to', 1, 50.4592, 4.4538, 'CRL', '2026-08-01', '20:30');
const codes = await codesFor(user.id);
expect(codes).toContain('FI');
expect(codes).toContain('BE');
});
});
// ── the shared geocode cache ──────────────────────────────────────
/**
@@ -170,6 +170,24 @@ describe('writeAudit', () => {
testDb.prepare('ALTER TABLE audit_log_gone RENAME TO audit_log').run();
});
it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', () => {
seedUser(1, 'admin@b.c');
svc.writeAudit({
userId: 1,
action: 'settings.api_keys_update',
resource: 'api_keys',
details: { changed: ['maps_api_key', 'unsplash_api_key'] },
ip: '1.2.3.4',
});
expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys (maps_api_key, unsplash_api_key) ip=1.2.3.4');
// A details blob without the array (or with an empty one) leaves the brief off
// rather than stringifying whatever else is in there.
svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { changed: [] }, ip: '1.2.3.4' });
expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys ip=1.2.3.4');
svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { other: 'x' }, ip: '1.2.3.4' });
expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys ip=1.2.3.4');
});
it('AUDIT-SVC-014: buildInfoSummary variants (settings parts, login empty brief)', () => {
seedUser(1, 'a@b.c');
svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { notification_channel: 'smtp', require_mfa: false }, ip: '1.1.1.1' });
+41 -4
View File
@@ -290,15 +290,52 @@ describe('AuthController (authenticated)', () => {
it('maps-key + api-keys pass straight through to the service', () => {
const updateMapsKey = vi.fn().mockReturnValue({ success: true });
expect(ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true });
expect(ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' }, req)).toEqual({ success: true });
expect(updateMapsKey).toHaveBeenCalledWith(1, 'k');
const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 });
expect(ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never))).toEqual({ ok: 1 });
expect(ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never), req)).toEqual({ ok: 1 });
// No changedKeys from the service (and none in the body): nothing to audit.
expect(writeAudit).not.toHaveBeenCalled();
});
it('api-keys audits one settings.api_keys_update with the changed names only (#1939)', () => {
const updateApiKeys = vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['maps_api_key'] });
const c = ac(asvc({}), rl(), {}, { updateApiKeys });
// changedKeys is stripped off the response — the client body is unchanged.
expect(c.apiKeys(user, anyBody({ maps_api_key: 'AIza-super-secret' } as never), req)).toEqual({ success: true, user: { id: 1 } });
expect(writeAudit).toHaveBeenCalledTimes(1);
expect(writeAudit).toHaveBeenCalledWith({
userId: 1,
action: 'settings.api_keys_update',
resource: 'api_keys',
ip: '1.2.3.4',
details: { changed: ['maps_api_key'] },
});
// Never the value, not even truncated: details is rendered raw in the panel.
expect(JSON.stringify(writeAudit.mock.calls)).not.toContain('AIza-super-secret');
});
it('maps-key and settings audit the same action; an unchanged save writes nothing', () => {
const mapsC = ac(asvc({}), rl(), {}, { updateMapsKey: vi.fn().mockReturnValue({ success: true, changedKeys: ['maps_api_key'] }) });
mapsC.mapsKey(user, { maps_api_key: 'k' }, req);
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['maps_api_key'] } }));
writeAudit.mockClear();
const setC = ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['unsplash_api_key'] }) });
expect(setC.updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['unsplash_api_key'] } }));
// The test button in the panel saves before every click — an identical value
// must not cost a log line.
writeAudit.mockClear();
ac(asvc({}), rl(), {}, { updateApiKeys: vi.fn().mockReturnValue({ success: true, changedKeys: [] }) })
.apiKeys(user, anyBody({ maps_api_key: 'same' } as never), req);
expect(writeAudit).not.toHaveBeenCalled();
});
it('update-settings + get-settings map errors, else return their payloads', () => {
expect(thrown(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } });
expect(ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } });
expect(thrown(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'Bad' } });
expect(ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } });
expect(thrown(() => ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) }).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
expect(ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) }).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
});
@@ -595,6 +595,44 @@ describe('exportICS', () => {
expect(ics).not.toContain('Route:');
});
it('CAL-043: a segment with its own booking reference gets its own Confirmation line (#1943)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Layover' });
const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=?, confirmation_number=?, metadata=? WHERE id=?').run(
'2025-06-02T09:00', 'BOOK1',
JSON.stringify({ legs: [
{ from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
{ from: 'BER', to: 'HND', confirmation_number: 'XYZ789' },
] }),
flight.id,
);
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
expect(ics).toContain(
'DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND'
+ '\\nConfirmation FRA-BER: ABC123\\nConfirmation BER-HND: XYZ789\r\n'
);
});
it('CAL-044: a multi-leg flight without per-segment references reads exactly as before', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Layover' });
const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=?, confirmation_number=?, metadata=? WHERE id=?').run(
'2025-06-02T09:00', 'BOOK1',
JSON.stringify({ legs: [{ from: 'FRA', to: 'BER' }, { from: 'BER', to: 'HND' }] }),
flight.id,
);
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
// Byte parity for every feed that exists today: the description ends after
// the route line, so no subscriber sees a changed event.
expect(ics).toContain('DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND\r\n');
});
it('CAL-020: an empty trip title falls back for SUMMARY, X-WR-CALNAME and the filename', () => {
const { user } = createUser(testDb);
// The title is only NOT NULL, not non-empty; an empty one used to produce
@@ -1411,7 +1411,11 @@ describe('Edge cases', () => {
expect(JSON.parse(raw.pros_cons)).toEqual({ pros: ['Fun'], cons: [] });
});
it('JOURNEY-SVC-086: addTripToJourney syncs trip photos when present', () => {
// #1614 — photos live in journeys now. Linking a trip used to copy its
// trip_photos into the gallery; that surface lost its UI in 3.1.0, nothing
// writes to it on a newer install, and the copy was how a photo one member had
// chosen not to share could reach a journey at all.
it('JOURNEY-SVC-086: addTripToJourney no longer pulls trip photos into the gallery', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, {
@@ -1421,16 +1425,10 @@ describe('Edge cases', () => {
});
addTripPhoto(testDb, trip.id, user.id, 'immich-photo-1', 'immich', { shared: true });
svc.addTripToJourney(journey.id, trip.id, user.id);
expect(svc.addTripToJourney(journey.id, trip.id, user.id)).toBe(true);
// Trip photos now go straight into the journey gallery (no wrapper entry).
const photos = testDb.prepare(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.journey_id = ?
`).all(journey.id);
expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
const photos = testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id);
expect(photos).toHaveLength(0);
});
it('JOURNEY-SVC-087: removeTripFromJourney detaches filled entries, deletes skeletons', () => {
@@ -2079,3 +2077,46 @@ describe('journeyTracks', () => {
expect(svc.journeyTracks(journey.id, stranger.id)).toBeNull();
});
});
// ── Trip linking: whose journey, and whose photos (#1614 review) ─────────────
describe('addTripToJourney guards', () => {
it('JOURNEY-SVC-100: refuses to link into a journey the caller cannot reach', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const journey = createJourney(testDb, owner.id, { title: "Owner's journey" });
const trip = createTrip(testDb, stranger.id, { title: 'Stranger trip' });
// The stranger owns the trip, so the trip gate passes — only the journey gate stops this.
expect(svc.addTripToJourney(journey.id, trip.id, stranger.id)).toBe(false);
const links = testDb.prepare('SELECT * FROM journey_trips WHERE journey_id = ?').all(journey.id);
expect(links).toHaveLength(0);
});
it('JOURNEY-SVC-101: a contributor may still link, the owner obviously too', () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id, { title: 'Shared journey' });
testDb.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)')
.run(journey.id, helper.id, 'editor', new Date().toISOString());
const trip = createTrip(testDb, helper.id, { title: 'Helper trip' });
expect(svc.addTripToJourney(journey.id, trip.id, helper.id)).toBe(true);
});
it('JOURNEY-SVC-102: not even a shared trip photo is copied any more', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Photo journey' });
const trip = createTrip(testDb, user.id, { title: 'Photo trip' });
const r = testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id, media_type) VALUES ('immich', 'shared-asset', ?, 'image')",
).run(user.id);
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)')
.run(trip.id, user.id, Number(r.lastInsertRowid));
svc.addTripToJourney(journey.id, trip.id, user.id);
expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id)).toHaveLength(0);
});
});
@@ -495,6 +495,55 @@ describe('getPublicJourney', () => {
expect(e.story).toBeUndefined(); // narrative withheld
});
// #1614 — a photo now carries the coordinates it was taken at. That is a place
// the owner never typed, so it has to follow the same switch the entry
// coordinates follow rather than riding in on the gallery flag.
it('JOURNEY-SHARE-025: withholds photo capture coordinates when the map flag is off', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01',
});
const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id });
testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ?, taken_at = ? WHERE id = ?')
.run(48.8584, 2.2945, '2026-05-01T10:00:00Z', trekId);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: false,
});
const result = svc.getPublicJourney(token)!;
const gallery = result.gallery as Record<string, unknown>[];
expect(gallery).toHaveLength(1);
expect(gallery[0].lat).toBeNull();
expect(gallery[0].lng).toBeNull();
// The capture time is not a location and stays — it is what a blog view sorts by.
expect(gallery[0].taken_at).toBe('2026-05-01T10:00:00Z');
const inline = (result.entries[0] as Record<string, unknown>).photos as Record<string, unknown>[];
expect(inline[0].lat).toBeNull();
expect(inline[0].lng).toBeNull();
});
it('JOURNEY-SHARE-026: hands out photo coordinates once the map is shared', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', entry_date: '2026-05-01',
});
const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id });
testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ? WHERE id = ?')
.run(48.8584, 2.2945, trekId);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: true,
});
const gallery = svc.getPublicJourney(token)!.gallery as Record<string, unknown>[];
expect(gallery[0].lat).toBe(48.8584);
expect(gallery[0].lng).toBe(2.2945);
});
it('JOURNEY-SHARE-024: strips inline entry photos (and their asset metadata) when the gallery is off', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
@@ -4,6 +4,7 @@ import type { Response } from 'express';
import { JourneyController } from '../../../src/nest/journey/journey.controller';
import { journeyThumbName } from '../../../src/nest/memories/thumbnail.service';
import type { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller';
import type { JourneyService } from '../../../src/nest/journey/journey.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
@@ -23,6 +24,9 @@ const storageStub = {
exists: storageExists,
sendToResponse: storageSendToResponse,
} as unknown as StorageService;
// The capture backfill is detached and irrelevant to every case here — it only has
// to exist so the constructor can call it.
const captureBackfillStub = { schedule: vi.fn(), run: vi.fn() } as unknown as PhotoCaptureBackfillService;
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
@@ -45,130 +49,130 @@ beforeEach(() => vi.clearAllMocks());
describe('JourneyController', () => {
it('GET / lists; POST / 400 without title, else creates', () => {
expect(new JourneyController(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub).list(user)).toEqual({ journeys: [{ id: 1 }] });
expect(thrown(() => new JourneyController(svc(), storageStub).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
expect(new JourneyController(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).list(user)).toEqual({ journeys: [{ id: 1 }] });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
const createJourney = vi.fn().mockReturnValue({ id: 9 });
expect(new JourneyController(svc({ createJourney } as Partial<JourneyService>), storageStub).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
expect(new JourneyController(svc({ createJourney } as Partial<JourneyService>), storageStub, captureBackfillStub).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] });
});
it('GET /suggestions + /available-trips', () => {
expect(new JourneyController(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
expect(new JourneyController(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>), storageStub).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
expect(new JourneyController(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
expect(new JourneyController(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
});
it('PATCH/DELETE entries map 404', () => {
expect(thrown(() => new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>), storageStub).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
expect(thrown(() => new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).deleteEntry(user, '3')).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
expect(thrown(() => new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteEntry(user, '3')).toEqual({ success: true });
});
it('provider-photos: batch, single 400/403, success', () => {
const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>);
expect(new JourneyController(batch, storageStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(thrown(() => new JourneyController(svc(), storageStub).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(new JourneyController(batch, storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
});
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
expect(thrown(() => new JourneyController(svc(), storageStub).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 });
const c = new JourneyController(svc({ linkPhotoToEntry } as Partial<JourneyService>), storageStub);
const c = new JourneyController(svc({ linkPhotoToEntry } as Partial<JourneyService>), storageStub, captureBackfillStub);
expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 });
expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1);
// accepts the canonical journey_photo_id, 403 when the service refuses
expect(thrown(() => new JourneyController(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => new JourneyController(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('unlink photo (204) maps 404; delete photo 404 then removes the object', async () => {
expect(thrown(() => new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
expect(new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).unlinkPhoto(user, '3', '7')).toBeUndefined();
expect(await thrownAsync(() => new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>), storageStub).deletePhoto(user, '7')).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
expect(new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).unlinkPhoto(user, '3', '7')).toBeUndefined();
expect(await thrownAsync(() => new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '7')).toEqual({ success: true });
});
it('gallery upload 400 no files / 403 not allowed, else commits + returns photos', async () => {
expect(await thrownAsync(() => new JourneyController(svc(), storageStub).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>), storageStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
expect(storageStub.put).toHaveBeenCalledWith('journey', 'a.jpg', { tmpPath: undefined });
});
it('gallery video: 400 no video, 403 not allowed, else stores the clip + poster (#823)', async () => {
const files = { video: [{ filename: 'v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg' } as Express.Multer.File] };
expect(await thrownAsync(() => new JourneyController(svc(), storageStub).uploadGalleryVideo(user, '3', {}, {}))).toEqual({ status: 400, body: { error: 'No video uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', {}, {}))).toEqual({ status: 400, body: { error: 'No video uploaded' } });
// Not-allowed after commit → the final objects are deleted through storage
// (the pre-commit unlink would miss them: file.path is already consumed).
const withPaths = { video: [{ filename: 'v.mp4', path: '/nonexistent/v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg', path: '/nonexistent/p.jpg' } as Express.Multer.File] };
expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>), storageStub).uploadGalleryVideo(user, '3', withPaths, { duration_ms: 'abc' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', withPaths, { duration_ms: 'abc' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'v.mp4');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'p.jpg');
const up = vi.fn().mockReturnValue([{ id: 7 }]);
expect(await new JourneyController(svc({ uploadGalleryPhotos: up } as Partial<JourneyService>), storageStub).uploadGalleryVideo(user, '3', files, { duration_ms: '4200' })).toEqual({ photos: [{ id: 7 }] });
expect(await new JourneyController(svc({ uploadGalleryPhotos: up } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', files, { duration_ms: '4200' })).toEqual({ photos: [{ id: 7 }] });
expect(up).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v.mp4', thumbnail: 'journey/p.jpg', mediaType: 'video', durationMs: 4200 }]);
// No poster + no duration → thumbnail undefined, durationMs null.
const up2 = vi.fn().mockReturnValue([{ id: 8 }]);
await new JourneyController(svc({ uploadGalleryPhotos: up2 } as Partial<JourneyService>), storageStub).uploadGalleryVideo(user, '3', { video: [{ filename: 'v2.mp4' } as Express.Multer.File] }, {});
await new JourneyController(svc({ uploadGalleryPhotos: up2 } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', { video: [{ filename: 'v2.mp4' } as Express.Multer.File] }, {});
expect(up2).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v2.mp4', thumbnail: undefined, mediaType: 'video', durationMs: null }]);
});
it('provider-photos forwards per-asset media_types for gallery and entries (#823)', () => {
const add = vi.fn().mockReturnValue({ id: 1 });
new JourneyController(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
new JourneyController(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
expect(add).toHaveBeenNthCalledWith(1, 9, 1, 'immich', 'a', undefined, undefined, 'video');
expect(add).toHaveBeenNthCalledWith(2, 9, 1, 'immich', 'b', undefined, undefined, 'image');
const addOne = vi.fn().mockReturnValue({ id: 2 });
new JourneyController(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
new JourneyController(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
expect(addOne).toHaveBeenCalledWith(9, 1, 'immich', 'c', undefined, undefined, 'video');
// Entry path mirrors the gallery path.
const eAdd = vi.fn().mockReturnValue({ id: 3 });
new JourneyController(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>), storageStub).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
new JourneyController(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
expect(eAdd).toHaveBeenNthCalledWith(1, 4, 1, 'immich', 'x', 'c', undefined, 'video');
const eOne = vi.fn().mockReturnValue({ id: 4 });
new JourneyController(svc({ addProviderPhoto: eOne } as Partial<JourneyService>), storageStub).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
new JourneyController(svc({ addProviderPhoto: eOne } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video');
});
it('GET/PATCH/DELETE /:id map 404', () => {
expect(thrown(() => new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>), storageStub).get(user, '9')).toEqual({ id: 9 });
expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).get(user, '9')).toEqual({ id: 9 });
expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
});
it('trips: POST 400 without trip_id / 403, DELETE 403', () => {
expect(thrown(() => new JourneyController(svc(), storageStub).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
expect(thrown(() => new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
expect(thrown(() => new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('entries under journey: list 404, create 400/404, reorder 400/403', () => {
expect(thrown(() => new JourneyController(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(new JourneyController(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
expect(thrown(() => new JourneyController(svc(), storageStub).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
expect(thrown(() => new JourneyController(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => new JourneyController(svc(), storageStub).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
expect(thrown(() => new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => new JourneyController(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(new JourneyController(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
expect(thrown(() => new JourneyController(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
expect(thrown(() => new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('contributors: add 400/403, update 403, remove 403', () => {
expect(thrown(() => new JourneyController(svc(), storageStub).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
expect(thrown(() => new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
expect(thrown(() => new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('preferences 403, share-link get/set/delete', () => {
expect(thrown(() => new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>), storageStub).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
expect(thrown(() => new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>), storageStub).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
expect(thrown(() => new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
expect(thrown(() => new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
expect(thrown(() => new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('entry photo upload mirrors to Immich only when opted in', async () => {
@@ -176,76 +180,76 @@ describe('JourneyController', () => {
const uploadToImmich = vi.fn().mockResolvedValue('immich-1');
const setPhotoProvider = vi.fn();
const s = svc({ addPhoto, immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich, setPhotoProvider } as Partial<JourneyService>);
const res = await new JourneyController(s, storageStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
const res = await new JourneyController(s, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
expect(setPhotoProvider).toHaveBeenCalledWith(5, 'immich', 'immich-1', 1);
expect(res).toEqual({ photos: [{ id: 5, provider: 'immich', asset_id: 'immich-1', owner_id: 1 }] });
const noOptIn = svc({ addPhoto: vi.fn().mockReturnValue({ id: 6 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(false), uploadToImmich } as Partial<JourneyService>);
await new JourneyController(noOptIn, storageStub).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
await new JourneyController(noOptIn, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
expect(uploadToImmich).toHaveBeenCalledTimes(1); // only the opted-in upload above
});
it('entry photo upload: 400 no files, 403 when nothing added, swallows immich errors and empty ids', async () => {
expect(await thrownAsync(() => new JourneyController(svc(), storageStub).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
// opted in but the immich upload throws → best-effort, the local photo still wins
const setPhotoProvider = vi.fn();
const blowsUp = svc({ addPhoto: vi.fn().mockReturnValue({ id: 8 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockRejectedValue(new Error('immich down')), setPhotoProvider } as Partial<JourneyService>);
expect(await new JourneyController(blowsUp, storageStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
expect(await new JourneyController(blowsUp, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
expect(setPhotoProvider).not.toHaveBeenCalled();
// opted in but immich returns a falsy id → no provider stamping
const noId = svc({ addPhoto: vi.fn().mockReturnValue({ id: 9 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockResolvedValue(''), setPhotoProvider } as Partial<JourneyService>);
expect(await new JourneyController(noId, storageStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
expect(await new JourneyController(noId, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
});
it('provider-photos batch passes the passphrase through when present', () => {
const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 });
new JourneyController(svc({ addProviderPhoto } as Partial<JourneyService>), storageStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
new JourneyController(svc({ addProviderPhoto } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image');
// single-photo success path
expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>), storageStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
});
it('PATCH photos: 404 then returns the updated photo', () => {
expect(thrown(() => new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>), storageStub).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
expect(thrown(() => new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
});
it('DELETE photo removes the storage object when a path exists', async () => {
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial<JourneyService>), storageStub).deletePhoto(user, '7')).toEqual({ success: true });
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '7')).toEqual({ success: true });
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'a.jpg');
// ...and the derived thumb goes with it (spec fix #2).
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/a.jpg'));
// a vanished object is swallowed
(storageStub.delete as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ENOENT'));
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial<JourneyService>), storageStub).deletePhoto(user, '8')).toEqual({ success: true });
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '8')).toEqual({ success: true });
// a file_path outside the journey category never reaches storage
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' }) } as Partial<JourneyService>), storageStub).deletePhoto(user, '9')).toEqual({ success: true });
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '9')).toEqual({ success: true });
expect(storageStub.delete).not.toHaveBeenCalled();
});
it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => {
const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 });
const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial<JourneyService>), storageStub);
const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial<JourneyService>), storageStub, captureBackfillStub);
expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image');
expect(thrown(() => new JourneyController(svc(), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
});
it('DELETE gallery photo: 404, then removes the object when present', async () => {
expect(await thrownAsync(() => new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
expect(await thrownAsync(() => new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
// no file_path → nothing to delete, returns void
expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>), storageStub).deleteGalleryPhoto(user, '7')).toBeUndefined();
await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial<JourneyService>), storageStub).deleteGalleryPhoto(user, '8');
expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '7')).toBeUndefined();
await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '8');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'g.jpg');
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/g.jpg'));
(storageStub.delete as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ENOENT'));
expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial<JourneyService>), storageStub).deleteGalleryPhoto(user, '9')).toBeUndefined();
expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '9')).toBeUndefined();
});
it('DELETE gallery video also reclaims its uploaded poster (spec fix #2 extension)', async () => {
@@ -253,6 +257,7 @@ describe('JourneyController', () => {
await new JourneyController(
svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 10, file_path: 'journey/clip.mp4', thumbnail_path: 'journey/poster.jpg' }) } as Partial<JourneyService>),
storageStub,
captureBackfillStub,
).deleteGalleryPhoto(user, '10');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'clip.mp4');
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/clip.mp4'));
@@ -260,45 +265,45 @@ describe('JourneyController', () => {
});
it('PATCH /:id returns the updated journey on success', () => {
expect(new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>), storageStub).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
expect(new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
});
it('cover upload: 400 without file, 404 when the journey is gone, else commits + returns the journey', async () => {
expect(await thrownAsync(() => new JourneyController(svc(), storageStub).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Journey not found' } });
const updateJourney = vi.fn().mockReturnValue({ id: 9, cover_image: 'journey/c.jpg' });
expect(await new JourneyController(svc({ updateJourney } as Partial<JourneyService>), storageStub).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)).toEqual({ id: 9, cover_image: 'journey/c.jpg' });
expect(await new JourneyController(svc({ updateJourney } as Partial<JourneyService>), storageStub, captureBackfillStub).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)).toEqual({ id: 9, cover_image: 'journey/c.jpg' });
expect(storageStub.put).toHaveBeenCalledWith('journey', 'c.jpg', { tmpPath: undefined });
expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' });
});
it('DELETE /:id and trips/contributors success paths', () => {
expect(new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).remove(user, '9')).toEqual({ success: true });
expect(new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).removeTrip(user, '9', '2')).toEqual({ success: true });
expect(new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
expect(new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).removeContributor(user, '9', '2')).toEqual({ success: true });
expect(new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).remove(user, '9')).toEqual({ success: true });
expect(new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeTrip(user, '9', '2')).toEqual({ success: true });
expect(new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
expect(new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeContributor(user, '9', '2')).toEqual({ success: true });
});
it('addContributor defaults the role to viewer when omitted', () => {
const addContributor = vi.fn().mockReturnValue(true);
new JourneyController(svc({ addContributor } as Partial<JourneyService>), storageStub).addContributor(user, '9', { user_id: 2 });
new JourneyController(svc({ addContributor } as Partial<JourneyService>), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 });
expect(addContributor).toHaveBeenCalledWith(9, 1, 2, 'viewer');
});
it('createEntry returns the entry when the journey exists', () => {
expect(new JourneyController(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>), storageStub).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
expect(new JourneyController(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
});
it('reorderEntries succeeds for a numeric array', () => {
expect(new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
expect(new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
});
it('preferences returns the result on success', () => {
expect(new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>), storageStub).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
expect(new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>), storageStub, captureBackfillStub).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
});
it('deleteShareLink returns success when removed', () => {
expect(new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub).deleteShareLink(user, '9')).toEqual({ success: true });
expect(new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteShareLink(user, '9')).toEqual({ success: true });
});
});
+131 -14
View File
@@ -38,6 +38,8 @@ type SsrfCheckStub = Pick<SsrfResult, 'allowed' | 'error'>;
const {
mockDbGet,
mockDbRun,
mockInstanceGet,
preparedSql,
mockCheckSsrf,
mockCacheGet,
mockCacheGetErrored,
@@ -47,8 +49,14 @@ const {
mockCacheSetInFlight,
mockServeFilePath,
} = vi.hoisted(() => ({
mockDbGet: vi.fn(() => undefined as any),
mockDbGet: vi.fn((..._args: unknown[]) => undefined as any),
mockDbRun: vi.fn(),
// The instance-wide key row (#1939) is read before the caller's own row, so it
// gets its own seam: every case below that stubs mockDbGet means "this user's
// row holds a key", and would otherwise have its stub eaten by the
// app_settings lookup.
mockInstanceGet: vi.fn((..._args: unknown[]) => undefined as any),
preparedSql: [] as string[],
mockCheckSsrf: vi.fn(async (_url: string, _bypassInternalIpAllowed?: boolean): Promise<SsrfCheckStub> => ({
allowed: true,
})),
@@ -69,7 +77,14 @@ const {
vi.mock('../../../src/db/database', () => ({
db: {
prepare: () => ({ get: mockDbGet, all: vi.fn(() => []), run: mockDbRun }),
prepare: (sql: string) => {
preparedSql.push(sql);
return {
get: (...args: unknown[]) => (sql.includes('app_settings') ? mockInstanceGet(...args) : mockDbGet(...args)),
all: vi.fn(() => []),
run: mockDbRun,
};
},
},
}));
@@ -96,6 +111,8 @@ vi.mock('../../../src/utils/ssrfGuard', () => {
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: (v: string | null) => v,
// Unused by the read paths here, but instance-api-keys imports it.
maybe_encrypt_api_key: (v: string | null) => v,
}));
vi.mock('../../../src/config', () => ({
@@ -131,6 +148,9 @@ afterEach(() => {
vi.unstubAllGlobals();
mockDbGet.mockReset();
mockDbGet.mockReturnValue(undefined);
mockInstanceGet.mockReset();
mockInstanceGet.mockReturnValue(undefined);
preparedSql.length = 0;
mockDbRun.mockReset();
mockCheckSsrf.mockReset();
mockCheckSsrf.mockResolvedValue({ allowed: true });
@@ -392,24 +412,48 @@ describe('buildOsmDetails', () => {
});
});
// ── getMapsKey ────────────────────────────────────────────────────────────────
// ── resolveMapsKey / getMapsKey ───────────────────────────────────────────────
describe('getMapsKey', () => {
it('MAPS-015: returns user key when user has one', () => {
describe('resolveMapsKey', () => {
const ORIGINAL_PLACES_KEY = process.env.PLACES_API_KEY;
afterEach(() => {
if (ORIGINAL_PLACES_KEY === undefined) delete process.env.PLACES_API_KEY;
else process.env.PLACES_API_KEY = ORIGINAL_PLACES_KEY;
});
it('MAPS-015: returns the caller own row key when nothing above it is set', () => {
mockDbGet.mockReturnValue({ maps_api_key: 'user-api-key' });
expect(svc.resolveMapsKey(1)).toEqual({ key: 'user-api-key', source: 'user-row' });
expect(svc.getMapsKey(1)).toBe('user-api-key'); // the wrapper reads the same chain
});
it('MAPS-016: the instance-wide key wins over the caller own row (#1939)', () => {
mockInstanceGet.mockReturnValueOnce({ value: 'instance-api-key' });
mockDbGet.mockReturnValueOnce({ maps_api_key: 'user-api-key' });
expect(svc.getMapsKey(1)).toBe('user-api-key');
expect(svc.resolveMapsKey(1)).toEqual({ key: 'instance-api-key', source: 'instance' });
});
it('MAPS-016: falls back to admin key when user has none', () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: null });
mockDbGet.mockReturnValueOnce({ maps_api_key: 'admin-api-key' });
expect(svc.getMapsKey(1)).toBe('admin-api-key');
});
it('MAPS-017: returns null when neither user nor admin has a key', () => {
mockDbGet.mockReturnValue(undefined);
it('MAPS-017: returns null with no source when nothing is set anywhere', () => {
expect(svc.resolveMapsKey(1)).toEqual({ key: null, source: null });
expect(svc.getMapsKey(1)).toBeNull();
});
it('MAPS-017b: the operator env key wins and the database is never asked', () => {
process.env.PLACES_API_KEY = 'operator-key';
expect(svc.resolveMapsKey(1)).toEqual({ key: 'operator-key', source: 'operator-env' });
expect(mockInstanceGet).not.toHaveBeenCalled();
expect(mockDbGet).not.toHaveBeenCalled();
});
it("MAPS-017c: never reads another user's row — the admin fallback is gone (#1939)", () => {
svc.resolveMapsKey(1);
// Two statements, both scoped: the instance row and this caller's own row.
// The old chain ended in "WHERE role = 'admin' ... LIMIT 1", which handed a
// stranger's credential to every non-admin.
expect(preparedSql).toHaveLength(2);
expect(preparedSql.join(' ')).not.toContain("role = 'admin'");
expect(preparedSql.some((sql) => sql.includes('WHERE id = ?'))).toBe(true);
});
});
// ── reverseGeocode ────────────────────────────────────────────────────────────
@@ -1153,6 +1197,25 @@ describe('searchPlaces (fetch stubbed)', () => {
});
});
it('MAPS-039h: a Google rejection logs which credential was used, never the credential (#1939)', async () => {
mockInstanceGet.mockReturnValueOnce({ value: 'instance-secret-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
json: async () => ({ error: { message: 'The caller does not have permission' } }),
}),
);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(svc.searchPlaces(7, 'anything')).rejects.toMatchObject({ status: 403 });
const logged = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(logged).toContain('keySource=instance');
expect(logged).toContain('userId=7');
expect(logged).not.toContain('instance-secret-key');
errorSpy.mockRestore();
});
it('MAPS-039c: throws with generic message when Google error has no message', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
@@ -1319,6 +1382,20 @@ describe('autocompletePlaces (fetch stubbed)', () => {
});
});
it('MAPS-083b: the autocomplete rejection logs the key source too (#1939)', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'own-row-secret' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({ error: { message: 'nope' } }) }),
);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(svc.autocompletePlaces(4, 'anything')).rejects.toMatchObject({ status: 403 });
const logged = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(logged).toContain('keySource=user-row');
expect(logged).not.toContain('own-row-secret');
errorSpy.mockRestore();
});
it('MAPS-084: throws generic message when Google error has no message', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
@@ -2188,6 +2265,46 @@ describe('googleFtidFromMapsUrl', () => {
expect(googleFtidFromMapsUrl('not a url')).toBeNull();
expect(googleFtidFromMapsUrl(null)).toBeNull();
});
// #1954 — a followed maps.app.goo.gl link keeps the id in the `data=` blob, not
// in a query parameter, which is why a pasted single link used to lose it.
it('MAPS-FTID-004: extracts the ftid from the data= path blob of a resolved share link', () => {
expect(
googleFtidFromMapsUrl(
'https://www.google.com/maps/place/Notre-Dame/data=!4m6!3m5!1s0x47e671e23a09b3b1:0x40b82c3688b2f60!8m2!3d48.8529!4d2.3499',
),
).toBe('0x47e671e23a09b3b1:0x40b82c3688b2f60');
});
it('MAPS-FTID-005: the query parameter still wins over the path blob', () => {
expect(
googleFtidFromMapsUrl(
'https://www.google.com/maps/place/X/data=!3m5!1s0x1:0x2!8m2?ftid=0x882bf179e806d471:0x8591dde29c821a93',
),
).toBe('0x882bf179e806d471:0x8591dde29c821a93');
});
it('MAPS-FTID-006: ignores the path blob outside a /place/ URL', () => {
// A directions link carries one !1s per waypoint and the first is the origin,
// so reading it would attach the wrong place.
expect(
googleFtidFromMapsUrl('https://www.google.com/maps/dir/A/B/data=!4m2!1s0x47e671e23a09b3b1:0x40b82c3688b2f60'),
).toBeNull();
});
it('MAPS-FTID-007: a place URL without either shape is still null, and case is normalised', () => {
expect(googleFtidFromMapsUrl('https://www.google.com/maps/place/Notre-Dame/@48.85,2.34,17z')).toBeNull();
expect(
googleFtidFromMapsUrl('https://www.google.com/maps/place/X/data=!1s0x47E671E23A09B3B1:0x40B82C3688B2F60'),
).toBe('0x47e671e23a09b3b1:0x40b82c3688b2f60');
});
it('MAPS-FTID-008: a long hostile path blob resolves in linear time (ReDoS budget)', () => {
const hostile = `https://www.google.com/maps/place/X/data=${'!1s0x'.repeat(20000)}`;
const started = Date.now();
expect(googleFtidFromMapsUrl(hostile)).toBeNull();
expect(Date.now() - started).toBeLessThan(500);
});
});
// ── buildUserAgent (instance-specific UA, #1309) ──────────────────────────────
@@ -0,0 +1,224 @@
/**
* PhotoCaptureBackfillService (#1614) — asking the provider when and where a
* photo was taken, after the add the user was waiting on has already answered.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// exifr reads real files; the local branch is about which tags are picked and what
// is done with them, not about decoding a JPEG.
vi.mock('exifr', () => ({ default: { parse: vi.fn() } }));
import exifr from 'exifr';
import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
import type { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service';
import type { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import type { StorageService } from '../../../src/nest/storage/storage.service';
// The storage layer's job here is only to hand the EXIF reader a real path;
// materialization (local fast-path vs remote temp download) has its own tests.
const storageStub = {
withLocalFile: vi.fn(async (_category: string, name: string, fn: (absPath: string) => Promise<unknown>) => fn(`/uploads/journey/${name}`)),
} as unknown as StorageService;
type Row = { id: number; provider?: string; file_path?: string | null; taken_at?: string | null; lat?: number | null; lng?: number | null };
function build(rows: Row[], info: Record<number, unknown>) {
const recordCaptureMetadata = vi.fn();
const getPhotoInfo = vi.fn(async (id: number) =>
info[id] ? { success: true, data: info[id] } : { success: false, error: 'nope', status: 404 },
);
const photos = {
resolve: (id: number) => rows.find(r => r.id === id) ?? null,
recordCaptureMetadata,
} as unknown as TrekPhotosRepository;
const resolver = { getPhotoInfo } as unknown as PhotoResolverService;
return { svc: new PhotoCaptureBackfillService(resolver, photos, storageStub), recordCaptureMetadata, getPhotoInfo };
}
describe('PhotoCaptureBackfillService', () => {
it('CAPTURE-001: records what the provider knows', async () => {
const { svc, recordCaptureMetadata } = build(
[{ id: 7 }],
{ 7: { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 } },
);
await svc.run([7], 1);
expect(recordCaptureMetadata).toHaveBeenCalledWith(7, {
takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945,
});
});
it('CAPTURE-002: skips a row that already knows both, so an album import is not a provider call per photo', async () => {
const { svc, getPhotoInfo } = build(
[{ id: 7, taken_at: '2026-03-15T10:20:00Z', lat: 1, lng: 2 }],
{ 7: { takenAt: 'x' } },
);
await svc.run([7], 1);
expect(getPhotoInfo).not.toHaveBeenCalled();
});
it('CAPTURE-003: still asks when only half is known', async () => {
const { svc, getPhotoInfo } = build(
[{ id: 7, taken_at: '2026-03-15T10:20:00Z' }],
{ 7: { takenAt: '2026-03-15T10:20:00Z', lat: 48.8, lng: 2.2 } },
);
await svc.run([7], 1);
expect(getPhotoInfo).toHaveBeenCalledTimes(1);
});
it('CAPTURE-004: a provider that refuses leaves the row alone and does not throw', async () => {
const { svc, recordCaptureMetadata } = build([{ id: 7 }], {});
await expect(svc.run([7], 1)).resolves.toBeUndefined();
expect(recordCaptureMetadata).not.toHaveBeenCalled();
});
it('CAPTURE-005: one failing photo does not take down the rest of the batch', async () => {
const { svc, recordCaptureMetadata } = build(
[{ id: 7 }, { id: 8 }],
{ 8: { takenAt: '2026-03-16T08:00:00Z', lat: null, lng: null } },
);
// 7 has no info entry, so getPhotoInfo answers unsuccessfully for it.
await svc.run([7, 8], 1);
expect(recordCaptureMetadata).toHaveBeenCalledTimes(1);
expect(recordCaptureMetadata).toHaveBeenCalledWith(8, {
takenAt: '2026-03-16T08:00:00Z', lat: null, lng: null,
});
});
it('CAPTURE-006: an empty batch touches nothing', () => {
const { svc, getPhotoInfo } = build([], {});
svc.schedule([], 1);
expect(getPhotoInfo).not.toHaveBeenCalled();
});
});
describe('PhotoCaptureBackfillService — local files', () => {
// Reset *and* give it a benign default. A bare mockReset leaves the mock with no
// implementation at all, and a later mockImplementation that throws then trips
// vitest's unhandled-error reporting instead of reaching the code's own catch.
beforeEach(() => {
vi.mocked(exifr.parse).mockReset();
vi.mocked(exifr.parse).mockResolvedValue({});
});
function localBuild(rows: Row[]) {
const recordCaptureMetadata = vi.fn();
const getPhotoInfo = vi.fn();
const photos = {
resolve: (id: number) => rows.find(r => r.id === id) ?? null,
recordCaptureMetadata,
} as unknown as TrekPhotosRepository;
const resolver = { getPhotoInfo } as unknown as PhotoResolverService;
return { svc: new PhotoCaptureBackfillService(resolver, photos, storageStub), recordCaptureMetadata, getPhotoInfo };
}
it('CAPTURE-007: reads a local file rather than asking a provider', async () => {
vi.mocked(exifr.parse).mockResolvedValue({
DateTimeOriginal: new Date('2026-03-15T10:20:00Z'),
latitude: 48.8584,
longitude: 2.2945,
});
const { svc, recordCaptureMetadata, getPhotoInfo } = localBuild([
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
]);
await svc.run([7], 1);
expect(getPhotoInfo).not.toHaveBeenCalled();
expect(recordCaptureMetadata).toHaveBeenCalledWith(7, {
takenAt: '2026-03-15T10:20:00.000Z', lat: 48.8584, lng: 2.2945,
});
});
it('CAPTURE-008: falls back to CreateDate when the original timestamp is missing', async () => {
vi.mocked(exifr.parse).mockResolvedValue({ CreateDate: new Date('2026-03-16T08:00:00Z') });
const { svc, recordCaptureMetadata } = localBuild([
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
]);
await svc.run([7], 1);
expect(recordCaptureMetadata).toHaveBeenCalledWith(7, {
takenAt: '2026-03-16T08:00:00.000Z', lat: null, lng: null,
});
});
it('CAPTURE-009: a file with nothing readable is left alone', async () => {
vi.mocked(exifr.parse).mockResolvedValue({});
const { svc, recordCaptureMetadata } = localBuild([
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
]);
await svc.run([7], 1);
expect(recordCaptureMetadata).not.toHaveBeenCalled();
});
it('CAPTURE-010: an unreadable file is not an error', async () => {
// Throws synchronously. A mock that *rejects* leaves vitest recording the
// settlement of a promise nothing else owns, and the run fails on that even
// though the code under test caught it. The catch is the same either way.
vi.mocked(exifr.parse).mockImplementation((() => { throw new Error('not an image'); }) as never);
const { svc, recordCaptureMetadata } = localBuild([
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
]);
await expect(svc.run([7], 1)).resolves.toBeUndefined();
expect(recordCaptureMetadata).not.toHaveBeenCalled();
});
it('CAPTURE-011: a stored path that climbs out of the uploads tree is refused', async () => {
const { svc, recordCaptureMetadata } = localBuild([
{ id: 7, provider: 'local', file_path: '../../../etc/passwd' },
]);
await svc.run([7], 1);
expect(exifr.parse).not.toHaveBeenCalled();
expect(recordCaptureMetadata).not.toHaveBeenCalled();
});
it('CAPTURE-012: a local row without a path is skipped', async () => {
const { svc, recordCaptureMetadata } = localBuild([{ id: 7, provider: 'local' }]);
await svc.run([7], 1);
expect(exifr.parse).not.toHaveBeenCalled();
expect(recordCaptureMetadata).not.toHaveBeenCalled();
});
it('CAPTURE-013: a row that no longer exists is skipped', async () => {
const { svc, recordCaptureMetadata } = localBuild([]);
await svc.run([99], 1);
expect(recordCaptureMetadata).not.toHaveBeenCalled();
});
it('CAPTURE-014: a throwing lookup is swallowed so the detached task survives', async () => {
const recordCaptureMetadata = vi.fn();
const photos = {
resolve: () => { throw new Error('db gone'); },
recordCaptureMetadata,
} as unknown as TrekPhotosRepository;
const svc = new PhotoCaptureBackfillService({} as PhotoResolverService, photos, storageStub);
await expect(svc.run([7], 1)).resolves.toBeUndefined();
expect(recordCaptureMetadata).not.toHaveBeenCalled();
});
it('CAPTURE-015: schedule kicks the run off for a non-empty batch', async () => {
vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: new Date('2026-03-15T10:20:00Z') });
const { svc, recordCaptureMetadata } = localBuild([
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
]);
svc.schedule([7], 1);
await vi.waitFor(() => expect(recordCaptureMetadata).toHaveBeenCalled());
});
});
@@ -14,6 +14,7 @@ import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/plac
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { TRACK_COLORS } from '@trek/shared';
import { ADDRESS_BACKFILL_MAX_PLACES } from '../../../src/nest/places/places.helpers';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -1470,3 +1471,100 @@ describe('importNaverList provider payload', () => {
expect(result.places[0].name).toBe('Gyeongbokgung');
});
});
// ── Free address backfill for list imports (#1954) ───────────────────────────
//
// A place pasted as a single Google Maps link has always been reverse geocoded;
// the same place arriving through a list import was not, so it stayed without an
// address forever. These cover the backfill that closes that gap.
describe('backfillMissingAddresses', () => {
function backfillSvc(reverseGeocode: MapsService['reverseGeocode']) {
return makePlacesService({ reverseGeocode } as unknown as MapsService);
}
it('PLACE-SVC-078 — fills the address of a place that has none', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
const reverseGeocode = vi.fn(async () => ({ name: null, address: '1 Rue de Rivoli, Paris' }));
await backfillSvc(reverseGeocode).backfillMissingAddresses(String(trip.id), [
{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 },
]);
expect(reverseGeocode).toHaveBeenCalledWith('48.85', '2.35', undefined, { lane: 'background', timeoutMs: 10000 });
const row = testDb.prepare('SELECT address FROM places WHERE id = ?').get(place.id) as { address: string };
expect(row.address).toBe('1 Rue de Rivoli, Paris');
});
it('PLACE-SVC-079 — never overwrites an address the import or the Google pass already wrote', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('Imported address', place.id);
const reverseGeocode = vi.fn(async () => ({ name: null, address: 'Nominatim address' }));
await backfillSvc(reverseGeocode).backfillMissingAddresses(String(trip.id), [
{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35, address: 'Imported address' },
]);
expect(reverseGeocode).not.toHaveBeenCalled();
const row = testDb.prepare('SELECT address FROM places WHERE id = ?').get(place.id) as { address: string };
expect(row.address).toBe('Imported address');
});
it('PLACE-SVC-080 — a lookup that answers with nothing leaves the row untouched', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
await backfillSvc(vi.fn(async () => ({ name: null, address: null }))).backfillMissingAddresses(String(trip.id), [
{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 },
]);
const row = testDb.prepare('SELECT address FROM places WHERE id = ?').get(place.id) as { address: string | null };
expect(row.address).toBeNull();
});
it('PLACE-SVC-081 — one failing lookup does not take down the rest of the batch', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const first = createPlace(testDb, trip.id, { name: 'A', lat: 1, lng: 2 }) as any;
const second = createPlace(testDb, trip.id, { name: 'B', lat: 3, lng: 4 }) as any;
const reverseGeocode = vi.fn()
.mockRejectedValueOnce(new Error('nominatim down'))
.mockResolvedValueOnce({ name: null, address: 'Second address' });
await expect(
backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode']).backfillMissingAddresses(String(trip.id), [
{ id: first.id, name: 'A', lat: 1, lng: 2 },
{ id: second.id, name: 'B', lat: 3, lng: 4 },
]),
).resolves.toBeUndefined();
const rows = testDb.prepare('SELECT id, address FROM places WHERE trip_id = ? ORDER BY id').all(trip.id) as {
id: number; address: string | null;
}[];
expect(rows[0].address).toBeNull();
expect(rows[1].address).toBe('Second address');
});
it('PLACE-SVC-082 — an oversized batch is refused rather than queued for an hour', async () => {
const reverseGeocode = vi.fn();
const batch = Array.from({ length: ADDRESS_BACKFILL_MAX_PLACES + 1 }, (_, i) => ({
id: i + 1, name: `P${i}`, lat: 1, lng: 2,
}));
await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode']).backfillMissingAddresses('1', batch);
expect(reverseGeocode).not.toHaveBeenCalled();
});
it('PLACE-SVC-083 — a place without coordinates is skipped', async () => {
const reverseGeocode = vi.fn();
await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode']).backfillMissingAddresses('1', [
{ id: 1, name: 'A', lat: null as unknown as number, lng: null as unknown as number },
]);
expect(reverseGeocode).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,139 @@
/**
* instance-api-keys.test.ts — INSTKEY-001 through INSTKEY-008.
*
* The shared resolver behind #1939: the Places/Unsplash credential is instance
* configuration, so it comes from the env or an encrypted app_settings row, and
* only then from the caller's own users row. Run against a real in-memory DB
* with real apiKeyCrypto (a fixed ENCRYPTION_KEY), because the round trip
* through the random IV is half of what these functions promise.
*/
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {} } };
});
vi.mock('../../../../src/db/database', () => dbMock);
vi.mock('../../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
}));
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { createUser, createAdmin } from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { DatabaseService } from '../../../../src/nest/database/database.service';
import {
readInstanceApiKey,
writeInstanceApiKey,
resolveApiKey,
} from '../../../../src/nest/settings/instance-api-keys';
const db = new DatabaseService(testDb);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
delete process.env.PLACES_API_KEY;
});
afterAll(() => {
testDb.close();
});
const storedValue = (key: string) =>
(testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
describe('instance API keys', () => {
it('INSTKEY-001: round-trips through encryption at rest', () => {
writeInstanceApiKey(db, 'maps_api_key', 'AIza-instance-key');
expect(storedValue('maps_api_key')).toMatch(/^enc:v1:/);
expect(storedValue('maps_api_key')).not.toContain('AIza-instance-key');
expect(readInstanceApiKey(db, 'maps_api_key')).toBe('AIza-instance-key');
});
it('INSTKEY-002: a second write of the same value replaces the row (no second one)', () => {
writeInstanceApiKey(db, 'maps_api_key', 'same-key');
const first = storedValue('maps_api_key');
writeInstanceApiKey(db, 'maps_api_key', 'same-key');
// Same plaintext, different blob — the IV is random. That is exactly why
// "did this change?" is never asked of the stored value.
expect(storedValue('maps_api_key')).not.toBe(first);
expect(readInstanceApiKey(db, 'maps_api_key')).toBe('same-key');
const rows = testDb.prepare("SELECT COUNT(*) AS n FROM app_settings WHERE key = 'maps_api_key'").get() as { n: number };
expect(rows.n).toBe(1);
});
it('INSTKEY-003: a blank value reads back as unset but keeps the row', () => {
writeInstanceApiKey(db, 'unsplash_api_key', ' ');
expect(storedValue('unsplash_api_key')).toBe('');
expect(readInstanceApiKey(db, 'unsplash_api_key')).toBeNull();
});
it('INSTKEY-004: a legacy plaintext row still reads', () => {
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'plain-old-key')").run();
expect(readInstanceApiKey(db, 'maps_api_key')).toBe('plain-old-key');
});
it('INSTKEY-005: the operator env key wins and the database is never read', () => {
process.env.PLACES_API_KEY = 'operator-key';
writeInstanceApiKey(db, 'maps_api_key', 'instance-key');
expect(resolveApiKey(db, 'maps_api_key', 1, process.env.PLACES_API_KEY)).toEqual({
key: 'operator-key',
source: 'operator-env',
});
});
it('INSTKEY-006: the instance value wins over the caller own row', () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', user.id);
writeInstanceApiKey(db, 'maps_api_key', 'instance-key');
expect(resolveApiKey(db, 'maps_api_key', user.id, undefined)).toEqual({ key: 'instance-key', source: 'instance' });
});
it("INSTKEY-007: without an instance value the caller's own row answers — and nobody else's (#1939)", () => {
const { user: admin } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('admins-own-key', admin.id);
const { user: member } = createUser(testDb);
// The admin gets theirs...
expect(resolveApiKey(db, 'maps_api_key', admin.id, undefined)).toEqual({
key: 'admins-own-key',
source: 'user-row',
});
// ...and the member gets nothing rather than the admin's, which is the whole
// point: they used to get it, and Google answered them with a 403.
expect(resolveApiKey(db, 'maps_api_key', member.id, undefined)).toEqual({ key: null, source: null });
});
it('INSTKEY-009: userId 0 asks about the instance only', () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', user.id);
// app-config is optional-auth: with nobody asking there is no own row, and
// the answer must not be some other row that happens to be first.
expect(resolveApiKey(db, 'maps_api_key', 0, undefined)).toEqual({ key: null, source: null });
writeInstanceApiKey(db, 'maps_api_key', 'instance-key');
expect(resolveApiKey(db, 'maps_api_key', 0, undefined)).toEqual({ key: 'instance-key', source: 'instance' });
});
it('INSTKEY-008: an empty instance value does not fall through to the own row', () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET unsplash_api_key = ? WHERE id = ?').run('stale-personal', user.id);
writeInstanceApiKey(db, 'unsplash_api_key', 'to-be-cleared');
writeInstanceApiKey(db, 'unsplash_api_key', '');
// The admin who cleared the field cleared their column in the same save, so
// the fallback finding the old value would only happen on a row nobody
// touched — here it must not resurrect a cleared instance key for them.
testDb.prepare('UPDATE users SET unsplash_api_key = NULL WHERE id = ?').run(user.id);
expect(resolveApiKey(db, 'unsplash_api_key', user.id, undefined)).toEqual({ key: null, source: null });
});
});
@@ -44,10 +44,19 @@ afterAll(() => { testDb.close(); });
// ── getTravelStats — dashboard passport card ────────────────────────────────
describe('getTravelStats', () => {
function endpoint(reservationId: number, role: 'from' | 'to' | 'stop', sequence: number, lat: number, lng: number) {
function endpoint(
reservationId: number,
role: 'from' | 'to' | 'stop',
sequence: number,
lat: number,
lng: number,
code: string | null = null,
localDate: string | null = null,
localTime: string | null = null
) {
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)'
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng);
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng, code, localDate, localTime);
}
// Every trip below is dated in the past: since #1048 the passport card only counts
@@ -149,6 +158,51 @@ describe('getTravelStats', () => {
expect(stats.countries).not.toContain('JP');
});
// ── #1535: the layover split over two bookings ─────────────────────────────
// The passport card derives its countries from the same endpoints, so it needs the
// same pairing: a hub stored as the 'to' of one booking and the 'from' of the next
// slips past the #1486 role filter. Brussels → Helsinki → New York.
function splitChainThroughHelsinki(tripId: number, onwardDate: string, onwardTime: string) {
const inbound = createReservation(testDb, tripId, { type: 'flight', title: 'BRU-HEL' });
endpoint(inbound.id, 'from', 0, 50.9014, 4.4844, 'BRU', '2023-05-01', '07:00');
endpoint(inbound.id, 'to', 1, 60.3172, 24.9633, 'HEL', '2023-05-01', '09:30');
const onward = createReservation(testDb, tripId, { type: 'flight', title: 'HEL-JFK' });
endpoint(onward.id, 'from', 0, 60.3172, 24.9633, 'HEL', onwardDate, onwardTime);
endpoint(onward.id, 'to', 1, 40.6413, -73.7781, 'JFK', onwardDate, '15:00');
}
it('AUTH-DB-099: #1535 a plane change booked as two flights does not stamp the hub country', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END });
splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00');
const stats = atlas.getTravelStats(user.id);
expect(stats.countries).toContain('BE');
expect(stats.countries).toContain('US');
expect(stats.countries).not.toContain('FI');
});
it('AUTH-DB-100: #1535 a stopover of two days still stamps the hub country', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover', start_date: PAST_START, end_date: PAST_END });
splitChainThroughHelsinki(trip.id, '2023-05-03', '11:00');
expect(atlas.getTravelStats(user.id).countries).toContain('FI');
});
it('AUTH-DB-101: #1490 removing a country still subtracts it around the layover pairing', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END });
splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00');
atlas.unmarkCountry(user.id, 'BE');
const stats = atlas.getTravelStats(user.id);
expect(stats.countries).not.toContain('BE');
expect(stats.countries).toContain('US');
});
it('AUTH-DB-097: #1048 a manually marked country stays stamped regardless of trip dates', () => {
// The manual list is the user's own word, not derived from a trip — the date
// filter must not reach it.
@@ -0,0 +1,95 @@
/**
* TrekPhotosRepository — capture metadata (#1614).
*
* The three sources of a photo's "when and where" disagree in how much they know:
* a provider search answers with coordinates, the same provider's album listing
* does not, and a local file gives them up only once its EXIF has been read. These
* pin the merge rule that keeps a later, emptier answer from erasing an earlier one.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA foreign_keys = ON');
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => undefined, isOwner: () => false } };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
let repo: TrekPhotosRepository;
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
repo = new TrekPhotosRepository(new DatabaseService(testDb));
});
beforeEach(() => resetTestDb(testDb));
afterAll(() => testDb.close());
// A fresh path per call: getOrCreateLocal is keyed on file_path and resetTestDb
// leaves trek_photos alone, so a shared path would hand every test the same row.
let seq = 0;
function makePhoto(): number {
return repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null);
}
function read(id: number) {
return testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE id = ?').get(id) as {
taken_at: string | null; lat: number | null; lng: number | null;
};
}
describe('TrekPhotosRepository.recordCaptureMetadata', () => {
it('TREKPHOTO-001: stores the capture time and coordinates', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-002: a later, emptier answer does not erase what is known', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
// The album listing knows the date but not the place.
repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-003: fills only the half that was still missing', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' });
repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null });
});
it('TREKPHOTO-005: an answer with nothing in it touches no row', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
repo.recordCaptureMetadata(id, {});
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
});
@@ -5,12 +5,28 @@ import path from 'path';
// safeFetch is mocked so saveUnsplashCover never hits the network.
// db is mocked so getUnsplashKey resolves from a controllable stub, and
// decrypt_api_key is a passthrough so stored values compare as plaintext.
const { safeFetch, mockDbGet } = vi.hoisted(() => ({ safeFetch: vi.fn(), mockDbGet: vi.fn(() => undefined as unknown) }));
// The instance-wide row (#1939) is read before the user's own, so it gets its
// own seam rather than eating the mockDbGet stub of every case below.
const { safeFetch, mockDbGet, mockInstanceGet } = vi.hoisted(() => ({
safeFetch: vi.fn(),
mockDbGet: vi.fn((..._args: unknown[]) => undefined as unknown),
mockInstanceGet: vi.fn((..._args: unknown[]) => undefined as unknown),
}));
vi.mock('../../../../src/utils/ssrfGuard', () => ({ safeFetch }));
vi.mock('../../../../src/db/database', () => ({
db: { prepare: () => ({ get: mockDbGet, all: vi.fn(() => []), run: vi.fn() }) },
db: {
prepare: (sql: string) => ({
get: (...args: unknown[]) => (sql.includes('app_settings') ? mockInstanceGet(...args) : mockDbGet(...args)),
all: vi.fn(() => []),
run: vi.fn(),
}),
},
}));
vi.mock('../../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: (v: string | null) => v,
// Unused by the read path here, but instance-api-keys imports it.
maybe_encrypt_api_key: (v: string | null) => v,
}));
vi.mock('../../../../src/nest/common/crypto/apiKeyCrypto', () => ({ decrypt_api_key: (v: string | null) => v }));
import { UnsplashService } from '../../../../src/nest/unsplash/unsplash.service';
import { DatabaseService } from '../../../../src/nest/database/database.service';
@@ -32,6 +48,7 @@ afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
mockDbGet.mockReturnValue(undefined);
mockInstanceGet.mockReturnValue(undefined);
if (ORIGINAL_UNSPLASH_ENV === undefined) delete process.env.UNSPLASH_ACCESS_KEY;
else process.env.UNSPLASH_ACCESS_KEY = ORIGINAL_UNSPLASH_ENV;
});
@@ -66,6 +83,37 @@ describe('unsplashService.searchUnsplashPhotos', () => {
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Rate limited', status: 429 });
});
it('UNSPLASH-016: a 200 carrying unparseable JSON becomes a 502, not a crash', async () => {
// The web endpoint answers 200 with an HTML challenge page when it decides the
// caller is a datacenter (#1449), so a parse failure on an ok response is an
// upstream problem, not the caller's.
const bad = fakeRes({ ok: true, type: 'text/html' });
(bad as { json: () => Promise<unknown> }).json = async () => {
throw new SyntaxError('Unexpected token < in JSON');
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bad));
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Unsplash search unavailable', status: 502 });
});
it('UNSPLASH-017: a failing response with unparseable JSON keeps its own status', async () => {
const bad = fakeRes({ ok: false, status: 503, type: 'text/html' });
(bad as { json: () => Promise<unknown> }).json = async () => {
throw new SyntaxError('Unexpected token < in JSON');
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bad));
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Unsplash search unavailable', status: 503 });
});
it('UNSPLASH-018: falls back to the generic message when a non-ok body names no error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes({ ok: false, status: 500, type: 'application/json', json: {} })));
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Unsplash search unavailable', status: 500 });
});
it('UNSPLASH-019: uses the single error field when the errors array is absent', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes({ ok: false, status: 401, type: 'application/json', json: { error: 'Bad credentials' } })));
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Bad credentials', status: 401 });
});
it('UNSPLASH-004: returns normalised photos on success and drops entries missing a url/thumb', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes({
ok: true,
@@ -116,18 +164,28 @@ describe('unsplashService.getUnsplashKey', () => {
expect(getUnsplashKey(1)).toBe('user-key');
});
it('UNSPLASH-014: falls back to the admin key when the user has none', () => {
it('UNSPLASH-014: the instance-wide key wins over the user own key (#1939)', () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockDbGet.mockReturnValueOnce({ unsplash_api_key: null });
mockDbGet.mockReturnValueOnce({ unsplash_api_key: 'admin-key' });
expect(getUnsplashKey(1)).toBe('admin-key');
mockInstanceGet.mockReturnValue({ value: 'instance-key' });
mockDbGet.mockReturnValue({ unsplash_api_key: 'user-key' });
expect(getUnsplashKey(1)).toBe('instance-key');
expect(mockDbGet).not.toHaveBeenCalled(); // the own row is not even read
});
it('UNSPLASH-015: returns null when neither env, user, nor admin has a key', () => {
it('UNSPLASH-015: returns null when neither env, instance, nor the user has a key', () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockDbGet.mockReturnValue(undefined);
expect(getUnsplashKey(1)).toBeNull();
});
it("UNSPLASH-015b: never reads another user's key — the admin fallback is gone (#1939)", () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockDbGet.mockReturnValue(undefined);
expect(getUnsplashKey(1)).toBeNull();
// Both reads are scoped: the instance row and this caller's own row.
expect(mockDbGet).toHaveBeenCalledTimes(1);
expect(mockDbGet).toHaveBeenCalledWith(1);
});
});
describe('unsplashService.saveUnsplashCover', () => {
@@ -43,6 +43,7 @@ import path from 'node:path';
import { UserProfileService } from '../../../src/nest/auth/user-profile.service';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { SEARCH_TEXT_FIELD_MASK } from '../../../src/nest/maps/maps.helpers';
const avatarsFx = makeStorageFixture('avatars/');
const profile = new UserProfileService(new DatabaseService(testDb), avatarsFx.storage);
@@ -257,6 +258,55 @@ describe('validateKeys', () => {
if (prevAppUrl === undefined) delete process.env.APP_URL;
else process.env.APP_URL = prevAppUrl;
});
it('AUTH-DB-111: probes the operator key on an install where the admin has no column of their own', async () => {
const prevPlacesKey = process.env.PLACES_API_KEY;
process.env.PLACES_API_KEY = 'operator-key';
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
status: 200,
statusText: 'OK',
text: async () => '',
} as Response);
try {
// The admin column stays empty on purpose: on an operator-run install the
// key belongs to whoever runs the box, and the panel used to answer
// "no maps key" while every search on that same install worked.
const { user } = createAdmin(testDb);
const result = await profile.validateKeys(user.id);
const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
expect(headers['X-Goog-Api-Key']).toBe('operator-key');
expect(result.maps).toBe(true);
} finally {
fetchSpy.mockRestore();
if (prevPlacesKey === undefined) delete process.env.PLACES_API_KEY;
else process.env.PLACES_API_KEY = prevPlacesKey;
}
});
it('AUTH-DB-112: probes the instance key, not a stale value in the admin own column', async () => {
const { user } = createAdmin(testDb);
// What a second admin sees after the instance key was saved by the first
// one: their column still holds whatever they pasted long ago, while every
// search on the install runs on the app_settings row.
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('stale-personal-key', user.id);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'live-instance-key')").run();
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
status: 200,
statusText: 'OK',
text: async () => '',
} as Response);
await profile.validateKeys(user.id);
const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
expect(headers['X-Goog-Api-Key']).toBe('live-instance-key');
// And it asks for the fields the real search asks for, so a key restricted
// to fewer Places SKUs fails the test button instead of passing it.
expect(headers['X-Goog-FieldMask']).toBe(SEARCH_TEXT_FIELD_MASK);
fetchSpy.mockRestore();
});
});
describe('updateMapsKey / avatar', () => {
@@ -323,3 +373,110 @@ describe('profile quirk fixes', () => {
expect(result.success).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Instance-wide keys + changedKeys (#1939)
//
// apiKeyCrypto is mocked to identity in this file, so nothing here asserts
// anything about encryption — the random-IV half of changedKeys is pinned in
// tests/integration/security.test.ts, which runs with a real ENCRYPTION_KEY.
// ---------------------------------------------------------------------------
const instanceRow = (key: string) =>
(testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
describe('instance-wide API keys', () => {
it('AUTH-DB-102: an admin save lands in app_settings AND in their own column', () => {
const { user } = createAdmin(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'instance-google-key', unsplash_api_key: 'instance-unsplash-key' });
expect(instanceRow('maps_api_key')).toBe('instance-google-key');
expect(instanceRow('unsplash_api_key')).toBe('instance-unsplash-key');
// The column stays in step so clearing the field clears both.
const row = testDb.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(user.id) as { maps_api_key: string };
expect(row.maps_api_key).toBe('instance-google-key');
});
it('AUTH-DB-103: a non-admin save never touches the instance value', () => {
const { user: admin } = createAdmin(testDb);
profile.updateApiKeys(admin.id, { maps_api_key: 'admin-set' });
const { user } = createUser(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'members-own' });
expect(instanceRow('maps_api_key')).toBe('admin-set');
const row = testDb.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(user.id) as { maps_api_key: string };
expect(row.maps_api_key).toBe('members-own');
});
it('AUTH-DB-104: clearing the field stores an empty instance value rather than dropping the row', () => {
const { user } = createAdmin(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'to-be-removed' });
profile.updateApiKeys(user.id, { maps_api_key: '' });
// '' and not a missing row: a missing row would fall through to whatever
// still sat in the admin's own column.
expect(instanceRow('maps_api_key')).toBe('');
});
it('AUTH-DB-105: getSettings reads the instance value, not the admin own column', () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('stale-personal-key', user.id);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'live-instance-key')").run();
expect(profile.getSettings(user.id).settings?.maps_api_key).toBe('live-instance-key');
// openweather is per-user and unaffected.
expect(profile.getSettings(user.id).settings?.openweather_api_key).toBeNull();
});
it('AUTH-DB-106: updateMapsKey mirrors for an admin and stays personal for a member', () => {
const { user: admin } = createAdmin(testDb);
expect(profile.updateMapsKey(admin.id, 'via-maps-key-route').changedKeys).toEqual(['maps_api_key']);
expect(instanceRow('maps_api_key')).toBe('via-maps-key-route');
const { user } = createUser(testDb);
profile.updateMapsKey(user.id, 'members-own');
expect(instanceRow('maps_api_key')).toBe('via-maps-key-route');
});
it('AUTH-DB-107: updateSettings mirrors the key half without touching name/email handling', () => {
const { user } = createAdmin(testDb);
const result = profile.updateSettings(user.id, { maps_api_key: 'from-settings-route', username: 'renamed' });
expect(result.success).toBe(true);
expect(result.user?.username).toBe('renamed');
expect(instanceRow('maps_api_key')).toBe('from-settings-route');
expect(result.changedKeys).toEqual(['maps_api_key']);
});
});
describe('changedKeys', () => {
it('AUTH-DB-108: only the names in the body count, and clearing counts as a change', () => {
const { user } = createAdmin(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'k1', openweather_api_key: 'w1' });
// unsplash was never sent, so it can never be reported.
expect(profile.updateApiKeys(user.id, { openweather_api_key: 'w2' }).changedKeys).toEqual(['openweather_api_key']);
expect(profile.updateApiKeys(user.id, { maps_api_key: '' }).changedKeys).toEqual(['maps_api_key']);
});
it('AUTH-DB-109: an unchanged value reports nothing, whitespace included', () => {
const { user } = createAdmin(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'unchanged-key' });
expect(profile.updateApiKeys(user.id, { maps_api_key: 'unchanged-key' }).changedKeys).toEqual([]);
// maybe_encrypt_api_key trims on the way in, so the comparison has to too.
expect(profile.updateApiKeys(user.id, { maps_api_key: ' unchanged-key ' }).changedKeys).toEqual([]);
// A member is measured against their own column, not the instance value.
const { user: member } = createUser(testDb);
expect(profile.updateApiKeys(member.id, { maps_api_key: 'unchanged-key' }).changedKeys).toEqual(['maps_api_key']);
});
it('AUTH-DB-110: a managed install reports no change for the names it refuses to write', () => {
const prev = process.env.TREK_MANAGED;
process.env.TREK_MANAGED = 'true';
try {
const { user } = createAdmin(testDb);
const result = profile.updateApiKeys(user.id, { maps_api_key: 'operator-owns-this' });
expect(result.managed_keys).toEqual(['maps_api_key']);
expect(result.changedKeys).toEqual([]);
expect(instanceRow('maps_api_key')).toBeUndefined();
expect(profile.updateMapsKey(user.id, 'operator-owns-this').changedKeys).toEqual([]);
expect(profile.updateSettings(user.id, { maps_api_key: 'operator-owns-this' }).changedKeys).toEqual([]);
} finally {
if (prev === undefined) delete process.env.TREK_MANAGED;
else process.env.TREK_MANAGED = prev;
}
});
});
@@ -37,14 +37,14 @@ describe('WeatherController (parity with the legacy /api/weather route)', () =>
const c = makeController({ get });
const res = await c.getWeather('52.5', '13.4', undefined, undefined);
expect(res).toEqual(sample);
expect(get).toHaveBeenCalledWith('52.5', '13.4', undefined, 'de');
expect(get).toHaveBeenCalledWith('52.5', '13.4', undefined, 'de', undefined);
});
it('passes an explicit lang and date through unchanged', async () => {
const get = vi.fn().mockResolvedValue(sample);
const c = makeController({ get });
await c.getWeather('1', '2', '2026-07-01', 'en');
expect(get).toHaveBeenCalledWith('1', '2', '2026-07-01', 'en');
expect(get).toHaveBeenCalledWith('1', '2', '2026-07-01', 'en', undefined);
});
it('maps an ApiError to its status + { error: message }', async () => {
@@ -306,6 +306,51 @@ describe('getWeather', () => {
expect(vi.mocked(fetch).mock.calls[0][0]).toContain('archive-api.open-meteo.com');
});
// #1614 — an entry made at 14:30 wants the weather of that hour. Averaging a
// rainy morning with a bright afternoon is what the daily figures do.
it('answers for the given hour when a time comes with the date', async () => {
const date = dateOffset(-5);
const hourly = { temperature_2m: Array(24).fill(null), weathercode: Array(24).fill(null) };
hourly.temperature_2m[14] = 21;
hourly.weathercode[14] = 0;
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({
daily: { time: [date], temperature_2m_max: [18], temperature_2m_min: [10], weathercode: [2], precipitation_sum: [0] },
hourly,
}));
const result = await getWeather('14.02', '24.02', date, 'en', '14:30');
expect(vi.mocked(fetch).mock.calls[0][0]).toContain('hourly=temperature_2m,weathercode');
expect(result.temp).toBe(21);
// The daily average would have been 14, so this is demonstrably the hour.
expect(result.temp_max).toBeUndefined();
});
it('falls back to the daily figures when the hour has no reading', async () => {
const date = dateOffset(-5);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({
daily: { time: [date], temperature_2m_max: [18], temperature_2m_min: [10], weathercode: [2], precipitation_sum: [0] },
hourly: { temperature_2m: Array(24).fill(null), weathercode: Array(24).fill(null) },
}));
const result = await getWeather('14.03', '24.03', date, 'en', '14:30');
expect(result.temp).toBe(14);
expect(result.temp_max).toBe(18);
});
it('ignores an unparseable time rather than guessing midnight', async () => {
const date = dateOffset(-5);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({
daily: { time: [date], temperature_2m_max: [18], temperature_2m_min: [10], weathercode: [2], precipitation_sum: [0] },
}));
const result = await getWeather('14.04', '24.04', date, 'en', 'not-a-time');
expect(vi.mocked(fetch).mock.calls[0][0]).not.toContain('hourly=');
expect(result.temp).toBe(14);
});
it('returns no_forecast error when archive has no data for the date', async () => {
const date = dateOffset(-5);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } }));
+4 -4
View File
@@ -67,8 +67,8 @@ export default defineConfig({
'src/nest/airports/**/*.ts': { statements: 68, branches: 60, functions: 93, lines: 73 },
'src/nest/app-config/**/*.ts': { statements: 79, branches: 100, functions: 65, lines: 71 },
'src/nest/assignments/**/*.ts': { statements: 90, branches: 80, functions: 97, lines: 96 },
'src/nest/atlas/**/*.ts': { statements: 91, branches: 78, functions: 96, lines: 93 },
'src/nest/audit/**/*.ts': { statements: 92, branches: 79, functions: 99, lines: 96 },
'src/nest/atlas/**/*.ts': { statements: 92, branches: 82, functions: 96, lines: 94 },
'src/nest/audit/**/*.ts': { statements: 93, branches: 80, functions: 99, lines: 96 },
'src/nest/auth/**/*.ts': { statements: 93, branches: 85, functions: 95, lines: 96 },
'src/nest/backup/**/*.ts': { statements: 97, branches: 93, functions: 99, lines: 98 },
'src/nest/booking-import/**/*.ts': { statements: 50, branches: 28, functions: 64, lines: 53 },
@@ -110,7 +110,7 @@ export default defineConfig({
'src/nest/realtime/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
'src/nest/reservation-import/**/*.ts': { statements: 61, branches: 55, functions: 41, lines: 61 },
'src/nest/reservations/**/*.ts': { statements: 92, branches: 83, functions: 96, lines: 96 },
'src/nest/settings/**/*.ts': { statements: 85, branches: 68, functions: 99, lines: 87 },
'src/nest/settings/**/*.ts': { statements: 87, branches: 71, functions: 99, lines: 88 },
'src/nest/share/**/*.ts': { statements: 97, branches: 87, functions: 99, lines: 99 },
'src/nest/storage/**/*.ts': { statements: 93, branches: 84, functions: 96, lines: 94 },
'src/nest/system-notices/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 },
@@ -122,7 +122,7 @@ export default defineConfig({
'src/nest/trip-members/**/*.ts': { statements: 95, branches: 91, functions: 99, lines: 95 },
'src/nest/trip-read-model/**/*.ts': { statements: 97, branches: 96, functions: 99, lines: 97 },
'src/nest/trips/**/*.ts': { statements: 95, branches: 86, functions: 95, lines: 96 },
'src/nest/unsplash/**/*.ts': { statements: 97, branches: 84, functions: 99, lines: 96 },
'src/nest/unsplash/**/*.ts': { statements: 99, branches: 88, functions: 99, lines: 99 },
'src/nest/vacay/**/*.ts': { statements: 82, branches: 66, functions: 90, lines: 86 },
'src/nest/weather/**/*.ts': { statements: 93, branches: 78, functions: 91, lines: 97 },
'src/nest/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },