mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/%'");
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` : '';
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = !!(
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}¤t=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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [] } }));
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user