refactor(atlas, school-holidays, settings): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-21 21:23:10 +02:00
parent f6c17938c3
commit 953e2526bb
55 changed files with 812 additions and 768 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ export async function buildApp(): Promise<INestApplication> {
// engines go through the same door — the second one answers the avoidance questions
// the first cannot, and is blocked just as silently when the policy leaves it out.
const settings = app.get(SettingsService, { strict: false });
const defaults = settings?.getAdminUserDefaults();
const defaults = await settings?.getAdminUserDefaults();
const asUrl = (value: unknown) => (typeof value === 'string' ? value : null);
applyGlobalMiddleware(instance, {
http,
+4 -4
View File
@@ -239,15 +239,15 @@ export class AddonsService {
* personal key gets Google while every other member silently gets Transitous
* — the #1939 shape, one layer up.
*/
private googleKeySource(userId: number): ApiKeySource | null {
return resolveApiKey(this.dbs, 'maps_api_key', userId, readEnv().maps.placesApiKey).source;
private async googleKeySource(userId: number): Promise<ApiKeySource | null> {
return (await resolveApiKey(this.dbs, 'maps_api_key', userId, readEnv().maps.placesApiKey)).source;
}
async getTransitProvider(userId = 0) {
return { provider: await readTransitProvider(this.dbs), googleKeySource: this.googleKeySource(userId) };
return { provider: await readTransitProvider(this.dbs), googleKeySource: await this.googleKeySource(userId) };
}
async updateTransitProvider(provider: TransitProvider, userId = 0) {
return { provider: await writeTransitProvider(this.dbs, provider), googleKeySource: this.googleKeySource(userId) };
return { provider: await writeTransitProvider(this.dbs, provider), googleKeySource: await this.googleKeySource(userId) };
}
}
+21 -21
View File
@@ -43,13 +43,13 @@ export class AtlasController {
constructor(private readonly atlas: AtlasService) {}
@Get('stats')
stats(@CurrentUser() user: User) {
async stats(@CurrentUser() user: User) {
return this.atlas.stats(user.id);
}
@Get('regions')
@Header('Cache-Control', 'no-cache, no-store')
regions(@CurrentUser() user: User) {
async regions(@CurrentUser() user: User) {
return this.atlas.visitedRegions(user.id);
}
@@ -106,47 +106,47 @@ export class AtlasController {
}
@Get('country/:code')
countryPlaces(@CurrentUser() user: User, @Param('code') code: string) {
async countryPlaces(@CurrentUser() user: User, @Param('code') code: string) {
return this.atlas.countryPlaces(user.id, code.toUpperCase());
}
@Post('country/:code/mark')
@HttpCode(200)
markCountry(@CurrentUser() user: User, @Param('code') code: string): { success: boolean } {
this.atlas.markCountry(user.id, code.toUpperCase());
async markCountry(@CurrentUser() user: User, @Param('code') code: string): Promise<{ success: boolean }> {
await this.atlas.markCountry(user.id, code.toUpperCase());
return { success: true };
}
@Delete('country/:code/mark')
unmarkCountry(@CurrentUser() user: User, @Param('code') code: string): { success: boolean } {
this.atlas.unmarkCountry(user.id, code.toUpperCase());
async unmarkCountry(@CurrentUser() user: User, @Param('code') code: string): Promise<{ success: boolean }> {
await this.atlas.unmarkCountry(user.id, code.toUpperCase());
return { success: true };
}
@Post('region/:code/mark')
@HttpCode(200)
markRegion(
async markRegion(
@CurrentUser() user: User,
@Param('code') code: string,
@Body() body: AtlasMarkRegionDto,
): { success: boolean } {
this.atlas.markRegion(user.id, code.toUpperCase(), body.name, body.country_code.toUpperCase());
): Promise<{ success: boolean }> {
await this.atlas.markRegion(user.id, code.toUpperCase(), body.name, body.country_code.toUpperCase());
return { success: true };
}
@Delete('region/:code/mark')
unmarkRegion(@CurrentUser() user: User, @Param('code') code: string): { success: boolean } {
this.atlas.unmarkRegion(user.id, code.toUpperCase());
async unmarkRegion(@CurrentUser() user: User, @Param('code') code: string): Promise<{ success: boolean }> {
await this.atlas.unmarkRegion(user.id, code.toUpperCase());
return { success: true };
}
@Get('bucket-list')
bucketList(@CurrentUser() user: User) {
return { items: this.atlas.bucketList(user.id) };
async bucketList(@CurrentUser() user: User) {
return { items: await this.atlas.bucketList(user.id) };
}
@Post('bucket-list')
createBucketItem(@CurrentUser() user: User, @Body() body: AtlasCreateBucketItemDto): { item: unknown } {
async createBucketItem(@CurrentUser() user: User, @Body() body: AtlasCreateBucketItemDto): Promise<{ item: unknown }> {
// The schema's min(1) admits whitespace-only names — this trim guard keeps
// the legacy 400 for those (missing/empty names 400 in the pipe envelope).
if (!body.name?.trim()) {
@@ -154,7 +154,7 @@ export class AtlasController {
}
const { name, lat, lng, country_code, notes, target_date } = body;
try {
return { item: this.atlas.createBucketItem(user.id, { name, lat, lng, country_code, notes, target_date }) };
return { item: await this.atlas.createBucketItem(user.id, { name, lat, lng, country_code, notes, target_date }) };
} catch (err) {
// #1898: the same wish twice is a conflict, not a server error. Bespoke
// { error } body like the neighbouring 400/404s.
@@ -166,15 +166,15 @@ export class AtlasController {
}
@Put('bucket-list/:id')
updateBucketItem(
async updateBucketItem(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: AtlasUpdateBucketItemDto,
): { item: unknown } {
): Promise<{ item: unknown }> {
const { name, notes, lat, lng, country_code, target_date } = body;
let item: unknown;
try {
item = this.atlas.updateBucketItem(user.id, id, { name, notes, lat, lng, country_code, target_date });
item = await this.atlas.updateBucketItem(user.id, id, { name, notes, lat, lng, country_code, target_date });
} catch (err) {
// #1898: editing a wish onto an existing one conflicts the same way a
// duplicate create does.
@@ -190,8 +190,8 @@ export class AtlasController {
}
@Delete('bucket-list/:id')
deleteBucketItem(@CurrentUser() user: User, @Param('id') id: string): { success: boolean } {
if (!this.atlas.deleteBucketItem(user.id, id)) {
async deleteBucketItem(@CurrentUser() user: User, @Param('id') id: string): Promise<{ success: boolean }> {
if (!(await this.atlas.deleteBucketItem(user.id, id))) {
throw new HttpException({ error: 'Item not found' }, 404);
}
return { success: true };
+12 -12
View File
@@ -88,7 +88,7 @@ export class AtlasMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
try {
const item = this.atlas.createBucketItem(ctx.userId, { name, lat, lng, country_code, notes, target_date });
const item = await this.atlas.createBucketItem(ctx.userId, { name, lat, lng, country_code, notes, target_date });
return ok({ item });
} catch (err) {
if (err instanceof BucketItemExistsError) return bucketDuplicateResult();
@@ -108,7 +108,7 @@ export class AtlasMcp {
})
async deleteBucketListItem({ itemId }: { itemId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const deleted = this.atlas.deleteBucketItem(ctx.userId, itemId);
const deleted = await this.atlas.deleteBucketItem(ctx.userId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
return ok({ success: true });
}
@@ -127,7 +127,7 @@ export class AtlasMcp {
})
async markCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
this.atlas.markCountry(ctx.userId, country_code.toUpperCase());
await this.atlas.markCountry(ctx.userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
@@ -143,7 +143,7 @@ export class AtlasMcp {
})
async unmarkCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
this.atlas.unmarkCountry(ctx.userId, country_code.toUpperCase());
await this.atlas.unmarkCountry(ctx.userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
@@ -169,7 +169,7 @@ export class AtlasMcp {
// figures the passport card is actually asked for (GET /api/auth/travel-stats)
// were unreachable here. Its coords array is one entry per place, which is
// rendering data rather than an answer, hence the opt-in.
const { coords, ...travel } = this.atlas.getTravelStats(ctx.userId);
const { coords, ...travel } = await this.atlas.getTravelStats(ctx.userId);
return ok({ stats, travel: include_coords ? { ...travel, coords } : travel });
}
@@ -231,8 +231,8 @@ export class AtlasMcp {
// (the legacy registrar passed them through verbatim, so a lowercase mark
// created a row REST's uppercased unmark could never hit).
const upperRegion = regionCode.toUpperCase();
this.atlas.markRegion(ctx.userId, upperRegion, regionName, countryCode.toUpperCase());
const row = this.atlas.listManuallyVisitedRegions(ctx.userId).find((r) => r.region_code === upperRegion);
await this.atlas.markRegion(ctx.userId, upperRegion, regionName, countryCode.toUpperCase());
const row = (await this.atlas.listManuallyVisitedRegions(ctx.userId)).find((r) => r.region_code === upperRegion);
// Echo in the client-facing shape ({ code, name, ... }) rather than raw DB columns.
const region = row
? { code: row.region_code, name: row.region_name, country_code: row.country_code, manuallyMarked: true }
@@ -253,7 +253,7 @@ export class AtlasMcp {
async unmarkRegionVisited({ regionCode }: { regionCode: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Post-fold quirk fix: uppercase, matching the REST controller.
this.atlas.unmarkRegion(ctx.userId, regionCode.toUpperCase());
await this.atlas.unmarkRegion(ctx.userId, regionCode.toUpperCase());
return ok({ success: true });
}
@@ -270,7 +270,7 @@ export class AtlasMcp {
async getCountryAtlasPlaces({ countryCode }: { countryCode: string }, ctx: McpContext) {
// Post-fold quirk fix: uppercase, matching the REST controller (the legacy
// registrar passed 'fr' through and matched nothing).
const result = this.atlas.countryPlaces(ctx.userId, countryCode.toUpperCase());
const result = await this.atlas.countryPlaces(ctx.userId, countryCode.toUpperCase());
return ok(result);
}
@@ -305,7 +305,7 @@ export class AtlasMcp {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
let item: unknown;
try {
item = this.atlas.updateBucketItem(ctx.userId, itemId, { name, notes, lat, lng, country_code, target_date });
item = await this.atlas.updateBucketItem(ctx.userId, itemId, { name, notes, lat, lng, country_code, target_date });
} catch (err) {
if (err instanceof BucketItemExistsError) return bucketDuplicateResult();
throw err;
@@ -327,7 +327,7 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'read' },
})
async bucketListResource(uri: URL, ctx: McpContext) {
const items = this.atlas.bucketList(ctx.userId);
const items = await this.atlas.bucketList(ctx.userId);
return jsonContent(uri.href, items);
}
@@ -340,7 +340,7 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'read' },
})
async visitedCountriesResource(uri: URL, ctx: McpContext) {
const countries = this.atlas.listVisitedCountries(ctx.userId);
const countries = await this.atlas.listVisitedCountries(ctx.userId);
return jsonContent(uri.href, countries);
}
+9 -9
View File
@@ -27,8 +27,8 @@ export class AtlasRpc {
const userId = this.requireAtlasUser(ctx, 'reads');
await this.requireAtlasAddon();
return {
countries: this.atlas.listVisitedCountries(userId),
regions: this.atlas.listManuallyVisitedRegions(userId),
countries: await this.atlas.listVisitedCountries(userId),
regions: await this.atlas.listManuallyVisitedRegions(userId),
};
}
@@ -36,7 +36,7 @@ export class AtlasRpc {
async bucketList(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireAtlasUser(ctx, 'reads');
await this.requireAtlasAddon();
return this.atlas.bucketList(userId) as unknown[];
return (await this.atlas.bucketList(userId)) as unknown[];
}
@PluginMethod('atlas.markCountry', { permission: 'db:write:atlas' })
@@ -44,7 +44,7 @@ export class AtlasRpc {
const userId = this.requireAtlasUser(ctx, 'writes');
const code = this.code(params.code, 'code');
await this.requireAtlasAddon();
this.atlas.markCountry(userId, code);
await this.atlas.markCountry(userId, code);
return { visited: true };
}
@@ -53,7 +53,7 @@ export class AtlasRpc {
const userId = this.requireAtlasUser(ctx, 'writes');
const code = this.code(params.code, 'code');
await this.requireAtlasAddon();
this.atlas.unmarkCountry(userId, code);
await this.atlas.unmarkCountry(userId, code);
return { visited: false };
}
@@ -67,7 +67,7 @@ export class AtlasRpc {
const regionCode = this.code(params.regionCode, 'regionCode');
const countryCode = this.code(params.countryCode, 'countryCode');
await this.requireAtlasAddon();
this.atlas.markRegion(userId, regionCode, regionName, countryCode);
await this.atlas.markRegion(userId, regionCode, regionName, countryCode);
return { visited: true };
}
@@ -76,7 +76,7 @@ export class AtlasRpc {
const userId = this.requireAtlasUser(ctx, 'writes');
const regionCode = this.code(params.regionCode, 'regionCode');
await this.requireAtlasAddon();
this.atlas.unmarkRegion(userId, regionCode);
await this.atlas.unmarkRegion(userId, regionCode);
return { visited: false };
}
@@ -87,7 +87,7 @@ export class AtlasRpc {
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bucket item name is required');
await this.requireAtlasAddon();
try {
return this.atlas.createBucketItem(userId, input as never);
return await this.atlas.createBucketItem(userId, input as never);
} catch (err) {
// #1898: a repeated wish is the caller's mistake, not ours. Without this the
// plugin sees a generic internal error and cannot tell the two apart.
@@ -101,7 +101,7 @@ export class AtlasRpc {
const userId = this.requireAtlasUser(ctx, 'writes');
const itemId = num(params.itemId, 'itemId');
await this.requireAtlasAddon();
if (!this.atlas.deleteBucketItem(userId, itemId)) {
if (!(await this.atlas.deleteBucketItem(userId, itemId))) {
throw new ForbiddenResource(`no bucket item ${itemId} for this user`);
}
return { deleted: true };
+51 -47
View File
@@ -3,6 +3,7 @@ import { CONTINENT_MAP, strongerVisitStatus, todayUtc, tripVisitStatus, VisitSta
import type { AtlasLocateResponse } from '@trek/shared';
import { Trip, Place } from '../../types';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import {
getCountryFromCoords,
getCountryGeoGz,
@@ -109,11 +110,14 @@ function markedSource(row: { source?: string | null } | undefined): string | nul
*/
@Injectable()
export class AtlasService {
constructor(private readonly db: DatabaseService) {}
constructor(
private readonly db: DatabaseService,
private readonly uow: UnitOfWork,
) {}
// ── Shared query: all trips the user owns or is a member of ───────────────
private getUserTrips(userId: number): Trip[] {
private async getUserTrips(userId: number): Promise<Trip[]> {
return this.db
.prepare(
`
@@ -126,7 +130,7 @@ export class AtlasService {
.all(userId, userId, userId) as Trip[];
}
private getPlacesForTrips(tripIds: number[]): Place[] {
private async getPlacesForTrips(tripIds: number[]): Promise<Place[]> {
if (tripIds.length === 0) return [];
const placeholders = tripIds.map(() => '?').join(',');
return this.db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
@@ -143,7 +147,7 @@ export class AtlasService {
// ── Country resolution (batch DB cache + sync fallback + background geocoding) ──
private resolvePlaceCountries(places: Place[]): Map<number, string> {
private async resolvePlaceCountries(places: Place[]): Promise<Map<number, string>> {
const out = new Map<number, string>();
const geoPlaces = places.filter((p) => p.lat && p.lng);
const placeIds = geoPlaces.map((p) => p.id);
@@ -204,11 +208,11 @@ export class AtlasService {
// ── Stats ─────────────────────────────────────────────────────────────────
async stats(userId: number) {
const trips = this.getUserTrips(userId);
const trips = await this.getUserTrips(userId);
const tripIds = trips.map((t) => t.id);
if (tripIds.length === 0) {
const hiddenOnly = this.getHiddenCountries(userId);
const hiddenOnly = await this.getHiddenCountries(userId);
const manualCountries = this.db
.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?')
.all(userId) as {
@@ -224,7 +228,7 @@ export class AtlasService {
};
}
const places = this.getPlacesForTrips(tripIds);
const places = await this.getPlacesForTrips(tripIds);
const now = todayUtc();
const tripStatus = this.tripStatusMap(trips, now);
@@ -234,7 +238,7 @@ export class AtlasService {
tripIds: Set<number>;
status: VisitStatus;
}
const placeCountries = this.resolvePlaceCountries(places);
const placeCountries = await this.resolvePlaceCountries(places);
const countrySet = new Map<string, CountryEntry>();
for (const place of places) {
const code = placeCountries.get(place.id);
@@ -302,7 +306,7 @@ export class AtlasService {
// Countries the user explicitly removed. Only the zero-count passes below are
// suppressed — a country with real places isn't removable in the UI anyway, and once
// the user adds a place there the tombstone should stop mattering (#1490).
const hidden = this.getHiddenCountries(userId);
const hidden = await this.getHiddenCountries(userId);
// Merge manually marked countries
const manualCountries = this.db
@@ -489,8 +493,8 @@ export class AtlasService {
// ── Country places ────────────────────────────────────────────────────────
countryPlaces(userId: number, code: string) {
const trips = this.getUserTrips(userId);
async countryPlaces(userId: number, code: string) {
const trips = await this.getUserTrips(userId);
const tripIds = trips.map((t) => t.id);
if (tripIds.length === 0) {
// Post-fold quirk fix: the legacy early return hardcoded manually_marked
@@ -508,7 +512,7 @@ export class AtlasService {
};
}
const places = this.getPlacesForTrips(tripIds);
const places = await this.getPlacesForTrips(tripIds);
const matchingPlaces: {
id: number;
@@ -565,14 +569,14 @@ export class AtlasService {
// ── Mark / unmark country ─────────────────────────────────────────────────
listVisitedCountries(userId: number): { country_code: string; created_at: string; source: string }[] {
async listVisitedCountries(userId: number): Promise<{ country_code: string; created_at: string; source: string }[]> {
return this.db
.prepare('SELECT country_code, created_at, source FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC')
.all(userId) as { country_code: string; created_at: string; source: string }[];
}
/** Countries the user explicitly removed, which stats() must not re-derive (#1490). */
getHiddenCountries(userId: number): Set<string> {
async getHiddenCountries(userId: number): Promise<Set<string>> {
const rows = this.db.prepare('SELECT country_code FROM hidden_countries WHERE user_id = ?').all(userId) as {
country_code: string;
}[];
@@ -589,8 +593,8 @@ export class AtlasService {
* Returns whether this actually added the country, so a caller reporting
* "3 countries added" is not counting the ones that were already there.
*/
markCountry(userId: number, code: string, source: 'manual' | 'dawarich' = 'manual'): boolean {
return this.db.transaction(() => {
async markCountry(userId: number, code: string, source: 'manual' | 'dawarich' = 'manual'): Promise<boolean> {
return this.uow.transactional(async () => {
const inserted = this.db
.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code, source) VALUES (?, ?, ?)')
.run(userId, code, source).changes > 0;
@@ -600,8 +604,8 @@ export class AtlasService {
});
}
unmarkCountry(userId: number, code: string): void {
this.db.transaction(() => {
async unmarkCountry(userId: number, code: string): Promise<void> {
await this.uow.transactional(async () => {
this.db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
this.db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?').run(userId, code);
// A country derived from a place or a transport endpoint has no visited_countries row,
@@ -613,7 +617,7 @@ export class AtlasService {
// ── Mark / unmark region ──────────────────────────────────────────────────
listManuallyVisitedRegions(userId: number): { region_code: string; region_name: string; country_code: string }[] {
async listManuallyVisitedRegions(userId: number): Promise<{ region_code: string; region_name: string; country_code: string }[]> {
return this.db
.prepare(
'SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC',
@@ -622,15 +626,15 @@ export class AtlasService {
}
/** Regions the user explicitly removed, which visitedRegions() must not re-derive. */
getHiddenRegions(userId: number): Set<string> {
async getHiddenRegions(userId: number): Promise<Set<string>> {
const rows = this.db.prepare('SELECT region_code FROM hidden_regions WHERE user_id = ?').all(userId) as {
region_code: string;
}[];
return new Set(rows.map((r) => r.region_code));
}
markRegion(userId: number, code: string, name: string, countryCode: string): void {
this.db.transaction(() => {
async markRegion(userId: number, code: string, name: string, countryCode: string): Promise<void> {
await this.uow.transactional(async () => {
this.db
.prepare(
'INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)',
@@ -651,9 +655,9 @@ export class AtlasService {
// True when the given country still has at least one region that would show as visited —
// derived from place_regions or manually marked — after excluding the given user's hidden
// regions. Used to decide whether removing a region should cascade into hiding the country.
private hasVisibleRegionForCountry(userId: number, countryCode: string, hidden: Set<string>): boolean {
const tripIds = this.getUserTrips(userId).map((t) => t.id);
const placeIds = this.getPlacesForTrips(tripIds)
private async hasVisibleRegionForCountry(userId: number, countryCode: string, hidden: Set<string>): Promise<boolean> {
const tripIds = (await this.getUserTrips(userId)).map((t) => t.id);
const placeIds = (await this.getPlacesForTrips(tripIds))
.filter((p) => p.lat && p.lng)
.map((p) => p.id);
const placeRegionCodes =
@@ -674,10 +678,10 @@ export class AtlasService {
return [...placeRegionCodes, ...manualRegionCodes].some((code) => !hidden.has(code));
}
unmarkRegion(userId: number, code: string): void {
async unmarkRegion(userId: number, code: string): Promise<void> {
// One transaction across the delete, the tombstone and the country cascade —
// better-sqlite3 turns the nested unmarkCountry() transaction into a savepoint.
this.db.transaction(() => {
// MikroORM turns the nested unmarkCountry() transaction into a savepoint.
await this.uow.transactional(async () => {
const region = this.db
.prepare('SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?')
.get(userId, code) as { country_code: string } | undefined;
@@ -695,9 +699,9 @@ export class AtlasService {
// If that was the country's last visible region, hide the country too — otherwise it
// keeps showing "visited" on the world map with nothing left to drill into.
const hidden = this.getHiddenRegions(userId);
if (!this.hasVisibleRegionForCountry(userId, countryCode, hidden)) {
this.unmarkCountry(userId, countryCode);
const hidden = await this.getHiddenRegions(userId);
if (!(await this.hasVisibleRegionForCountry(userId, countryCode, hidden))) {
await this.unmarkCountry(userId, countryCode);
}
}
});
@@ -711,9 +715,9 @@ export class AtlasService {
{ code: string; name: string; placeCount: number; status: VisitStatus; manuallyMarked?: boolean }[]
>;
}> {
const trips = this.getUserTrips(userId);
const trips = await this.getUserTrips(userId);
const tripIds = trips.map((t) => t.id);
const places = this.getPlacesForTrips(tripIds);
const places = await this.getPlacesForTrips(tripIds);
// Regions carry the same status as their country, otherwise zooming into a merely
// planned country would reveal regions painted as visited (#1048).
@@ -787,7 +791,7 @@ export class AtlasService {
}
// Merge manually marked regions
const manualRegions = this.listManuallyVisitedRegions(userId);
const manualRegions = await this.listManuallyVisitedRegions(userId);
for (const r of manualRegions) {
if (!result[r.country_code]) result[r.country_code] = [];
const existing = result[r.country_code].find((x) => x.code === r.region_code);
@@ -807,7 +811,7 @@ export class AtlasService {
// Suppress regions the user explicitly removed, same as stats() does for countries
// via getHiddenCountries (#1490) — otherwise a region derived fresh from place_regions
// (or a manual mark) on every request could never actually be dismissed.
const hidden = this.getHiddenRegions(userId);
const hidden = await this.getHiddenRegions(userId);
if (hidden.size > 0) {
for (const country of Object.keys(result)) {
result[country] = result[country].filter((r) => !hidden.has(r.code));
@@ -850,7 +854,7 @@ export class AtlasService {
// ── Bucket list CRUD ──────────────────────────────────────────────────────
bucketList(userId: number) {
async bucketList(userId: number) {
return this.db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(userId);
}
@@ -864,7 +868,7 @@ export class AtlasService {
* "no coordinates" instead of the NULL-is-never-equal SQLite default.
* `lower()` is ASCII-only in SQLite, which is what the client mirrors.
*/
private findDuplicateBucketItem(userId: number, key: BucketIdentity, excludeId?: number): { id: number } | undefined {
private async findDuplicateBucketItem(userId: number, key: BucketIdentity, excludeId?: number): Promise<{ id: number } | undefined> {
return this.db
.prepare(
`SELECT id FROM bucket_list
@@ -882,7 +886,7 @@ export class AtlasService {
| undefined;
}
createBucketItem(userId: number, data: CreateBucketData) {
async createBucketItem(userId: number, data: CreateBucketData) {
const identity: BucketIdentity = {
name: data.name.trim(),
lat: data.lat ?? null,
@@ -892,7 +896,7 @@ export class AtlasService {
};
// #1898: the same wish must not stack up. A different target date (or a
// different place under the same name) is a different wish and still lands.
if (this.findDuplicateBucketItem(userId, identity)) throw new BucketItemExistsError();
if (await this.findDuplicateBucketItem(userId, identity)) throw new BucketItemExistsError();
const result = this.db
.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)',
@@ -909,7 +913,7 @@ export class AtlasService {
return this.db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
}
updateBucketItem(userId: number, itemId: string | number, data: UpdateBucketData) {
async updateBucketItem(userId: number, itemId: string | number, data: UpdateBucketData) {
const item = this.db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId) as
| (BucketIdentity & { id: number })
| undefined;
@@ -924,7 +928,7 @@ export class AtlasService {
country_code: data.country_code !== undefined ? blankToNull(data.country_code) : blankToNull(item.country_code),
target_date: data.target_date !== undefined ? blankToNull(data.target_date) : blankToNull(item.target_date),
};
if (this.findDuplicateBucketItem(userId, next, item.id)) throw new BucketItemExistsError();
if (await this.findDuplicateBucketItem(userId, next, item.id)) throw new BucketItemExistsError();
// Post-fold quirk fixes: the value bindings use `?? null` (the legacy
// `|| null` wrote NULL for lat/lng 0 and empty-string notes), and the
// UPDATE + re-select are user-scoped (defense-in-depth; the ownership
@@ -959,7 +963,7 @@ export class AtlasService {
return this.db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
}
deleteBucketItem(userId: number, itemId: string | number): boolean {
async deleteBucketItem(userId: number, itemId: string | number): Promise<boolean> {
const item = this.db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
if (!item) return false;
// Post-fold quirk fix: user-scoped DELETE (defense-in-depth, see updateBucketItem).
@@ -1000,7 +1004,7 @@ export class AtlasService {
* much to spend on a label. An unresolved trip reports an empty list, which the
* caller renders as "no country" rather than as a wrong one.
*/
lastTrip(userId: number): { title: string; start_date: string | null; end_date: string | null; countries: string[] } | null {
async lastTrip(userId: number): Promise<{ title: string; start_date: string | null; end_date: string | null; countries: string[] } | null> {
const trip = this.db.get<{ id: number; title: string; start_date: string | null; end_date: string | null }>(`
SELECT t.id, t.title, t.start_date, t.end_date
FROM trips t
@@ -1030,7 +1034,7 @@ export class AtlasService {
};
}
getTravelStats(userId: number) {
async getTravelStats(userId: number) {
// The resolved region rides along so cityFromAddress can tell the city apart from
// the region sitting right above it in the same address (#1115).
const places = this.db.all<{ address: string | null; lat: number | null; lng: number | null; region_name: string | null }>(`
@@ -1131,7 +1135,7 @@ export class AtlasService {
// Countries the user removed in Atlas stay removed on the dashboard too, so the
// passport card and the Atlas map agree (#1490).
for (const code of this.getHiddenCountries(userId)) countryCodes.delete(code.toUpperCase());
for (const code of await this.getHiddenCountries(userId)) countryCodes.delete(code.toUpperCase());
return {
countries: [...countryCodes],
@@ -1140,7 +1144,7 @@ export class AtlasService {
totalTrips: tripStats?.trips || 0,
totalDays: tripStats?.days || 0,
totalPlaces: places.length,
totalDistanceKm: this.flightDistanceKm(userId),
totalDistanceKm: await this.flightDistanceKm(userId),
};
}
@@ -1170,7 +1174,7 @@ export class AtlasService {
(NOT EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id)
OR EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id AND rt.user_id = ?))`;
private flightDistanceKm(userId: number): number {
private async flightDistanceKm(userId: number): Promise<number> {
const rows = this.db.all<{ reservation_id: number; lat: number; lng: number }>(`
SELECT re.reservation_id, re.lat, re.lng
FROM reservation_endpoints re
@@ -46,13 +46,13 @@ export class PublicStatsController {
) {}
@Get('stats')
stats(@Req() req: Request): PublicApiStats {
async stats(@Req() req: Request): Promise<PublicApiStats> {
enforcePublicApiRateLimit(this.rl, req);
requireScope(req, 'stats');
const userId = requireUserId(req);
const travel = this.atlas.getTravelStats(userId);
const last = this.atlas.lastTrip(userId);
const travel = await this.atlas.getTravelStats(userId);
const last = await this.atlas.lastTrip(userId);
// Counts, not the arrays behind them. A consumer that wants the members asks
// /api/v1/trips; this endpoint exists for the one that wants a number.
@@ -28,7 +28,7 @@ export class TravelStatsController {
constructor(private readonly atlas: AtlasService) {}
@Get('travel-stats')
travelStats(@CurrentUser() user: User) {
async travelStats(@CurrentUser() user: User) {
return this.atlas.getTravelStats(user.id);
}
}
+5 -5
View File
@@ -160,21 +160,21 @@ export class AuthController {
async 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);
const { changedKeys = [], ...result } = await this.profile.updateMapsKey(user.id, body.maps_api_key);
await this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/api-keys')
async apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
const { changedKeys = [], ...result } = this.profile.updateApiKeys(user.id, body);
const { changedKeys = [], ...result } = await this.profile.updateApiKeys(user.id, body);
await this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/settings')
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
const result = this.profile.updateSettings(user.id, body);
const result = await this.profile.updateSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -183,8 +183,8 @@ export class AuthController {
}
@Get('me/settings')
getSettings(@CurrentUser() user: User) {
const result = this.profile.getSettings(user.id);
async getSettings(@CurrentUser() user: User) {
const result = await this.profile.getSettings(user.id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
+2 -2
View File
@@ -267,11 +267,11 @@ export class AuthService {
// 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 hasGoogleKey = !!(await resolveApiKey(this.db, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey)).key;
// The same question for Amap, asked the same way. The client needs both to
// tell "search is unavailable" from "search runs on OpenStreetMap", and to
// know whether the provider the admin selected actually has a credential.
const hasAmapKey = !!resolveApiKey(this.db, 'amap_api_key', authenticatedUser?.id ?? 0, readEnv().maps.amapApiKey).key;
const hasAmapKey = !!(await resolveApiKey(this.db, 'amap_api_key', authenticatedUser?.id ?? 0, readEnv().maps.amapApiKey)).key;
const placesProviderRow = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_provider'")?.value;
const placesProvider = isPlacesProviderChoice(placesProviderRow) ? placesProviderRow : 'auto';
const oidcDisplayName = readEnv().oidc.displayName ||
+35 -28
View File
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { readEnv, getAppUrl } from '../../app-config';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { StorageService } from '../storage/storage.service';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { avatarUrl } from '../common/avatarUrl';
@@ -35,6 +36,7 @@ export class UserProfileService {
constructor(
private readonly db: DatabaseService,
private readonly storage: StorageService,
private readonly uow: UnitOfWork,
) {}
/**
@@ -66,13 +68,13 @@ export class UserProfileService {
* 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(
private async storedKeyPlaintext(
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key',
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
isAdmin: boolean,
): string {
): Promise<string> {
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
const instance = readInstanceApiKey(this.db, name as InstanceApiKeyName);
const instance = await readInstanceApiKey(this.db, name as InstanceApiKeyName);
if (instance !== null) return instance;
}
return decrypt_api_key(current?.[name]) ?? '';
@@ -89,16 +91,21 @@ export class UserProfileService {
* `skipped` are the names a managed install refuses to write — auditing them
* would claim a change that never happened.
*/
private changedKeyNames(
private async changedKeyNames(
body: Record<string, unknown>,
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
isAdmin: boolean,
skipped: string[] = [],
): string[] {
): Promise<string[]> {
const norm = (v: unknown) => String(v ?? '').trim();
return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'] as const).filter(
(name) => body[name] !== undefined && !skipped.includes(name) && norm(body[name]) !== norm(this.storedKeyPlaintext(name, current, isAdmin))
);
// An explicit loop rather than `.filter`: storedKeyPlaintext reads the
// instance row and a filter predicate cannot await. Same order, same names.
const changed: string[] = [];
for (const name of ['maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'] as const) {
if (body[name] === undefined || skipped.includes(name)) continue;
if (norm(body[name]) !== norm(await this.storedKeyPlaintext(name, current, isAdmin))) changed.push(name);
}
return changed;
}
/**
@@ -109,40 +116,40 @@ export class UserProfileService {
* 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 {
private async mirrorInstanceKeys(body: Record<string, unknown>, isAdmin: boolean): Promise<void> {
if (!isAdmin) return;
for (const name of INSTANCE_API_KEY_NAMES) {
if (body[name] !== undefined) writeInstanceApiKey(this.db, name, body[name]);
if (body[name] !== undefined) await writeInstanceApiKey(this.db, name, body[name]);
}
}
updateMapsKey(userId: number, key: unknown) {
async 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'], changedKeys: [] };
}
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = this.changedKeyNames({ maps_api_key }, current, isAdmin);
this.db.transaction(() => {
const changedKeys = await this.changedKeyNames({ maps_api_key }, current, isAdmin);
await this.uow.transactional(async () => {
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);
await 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) {
async updateApiKeys(userId: number, rawBody: unknown) {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string };
const { blocked } = splitManagedKeys(body, this.managed);
for (const key of blocked) delete body[key as keyof typeof body];
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = this.changedKeyNames(body, current, isAdmin, blocked);
const changedKeys = await this.changedKeyNames(body, current, isAdmin, blocked);
this.db.transaction(() => {
await this.uow.transactional(async () => {
// `?? 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(
@@ -153,7 +160,7 @@ export class UserProfileService {
body.amap_api_key !== undefined ? maybe_encrypt_api_key(body.amap_api_key) : current?.amap_api_key ?? null,
userId
);
this.mirrorInstanceKeys(body, isAdmin);
await this.mirrorInstanceKeys(body, isAdmin);
});
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key' | 'avatar' | 'mfa_enabled'>>(
@@ -170,10 +177,10 @@ export class UserProfileService {
};
}
updateSettings(
async updateSettings(
userId: number,
rawBody: unknown
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] } {
): Promise<{ 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; amap_api_key?: string; username?: string; email?: string };
const { maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, username, email } = body;
@@ -217,14 +224,14 @@ export class UserProfileService {
// 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);
const changedKeys = keyLocked ? [] : await this.changedKeyNames(body, current, isAdmin, blocked);
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(userId);
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
if (!keyLocked) this.mirrorInstanceKeys(body, isAdmin);
if (!keyLocked) await this.mirrorInstanceKeys(body, isAdmin);
});
}
@@ -242,7 +249,7 @@ export class UserProfileService {
};
}
getSettings(userId: number): { error?: string; status?: number; settings?: Record<string, unknown> } {
async getSettings(userId: number): Promise<{ error?: string; status?: number; settings?: Record<string, unknown> }> {
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
userId
@@ -272,10 +279,10 @@ export class UserProfileService {
// instance value exists — on that install it is what the resolver picks too.
return {
settings: {
maps_api_key: readInstanceApiKey(this.db, 'maps_api_key') ?? decrypt_api_key(user.maps_api_key),
maps_api_key: (await 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: readInstanceApiKey(this.db, 'unsplash_api_key') ?? decrypt_api_key(user.unsplash_api_key),
amap_api_key: readInstanceApiKey(this.db, 'amap_api_key') ?? decrypt_api_key(user.amap_api_key),
unsplash_api_key: (await readInstanceApiKey(this.db, 'unsplash_api_key')) ?? decrypt_api_key(user.unsplash_api_key),
amap_api_key: (await readInstanceApiKey(this.db, 'amap_api_key')) ?? decrypt_api_key(user.amap_api_key),
},
};
}
@@ -349,7 +356,7 @@ export class UserProfileService {
// 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);
const { key: maps_api_key } = await 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
@@ -627,15 +627,15 @@ export class DawarichSuggestionsService {
* `INSERT OR IGNORE` underneath means a country already marked by hand keeps
* its own provenance — confirming it again does not relabel it as imported.
*/
acceptAtlasCountries(userId: number, codes: string[]): number {
async acceptAtlasCountries(userId: number, codes: string[]): Promise<number> {
let marked = 0;
this.db.transaction(() => {
await this.uow.transactional(async () => {
for (const raw of codes) {
const code = raw.trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) continue;
// Counted only when it was actually added: "3 countries added" must not
// include ones that were already on the map.
if (this.atlas.markCountry(userId, code, 'dawarich')) marked++;
if (await this.atlas.markCountry(userId, code, 'dawarich')) marked++;
}
});
return marked;
@@ -195,8 +195,8 @@ export class DawarichController {
@Post('atlas/accept')
@HttpCode(200)
acceptAtlasCountries(@CurrentUser() user: User, @Body() body: DawarichAtlasAcceptDto) {
return { marked: this.suggestions.acceptAtlasCountries(user.id, body.countryCodes) };
async acceptAtlasCountries(@CurrentUser() user: User, @Body() body: DawarichAtlasAcceptDto) {
return { marked: await this.suggestions.acceptAtlasCountries(user.id, body.countryCodes) };
}
// ── Track overlay ──────────────────────────────────────────────────────────
@@ -33,8 +33,8 @@ export class JourneyPublicController {
) {}
@Get(':token')
get(@Param('token') token: string) {
const data = this.journey.getPublicJourney(token);
async get(@Param('token') token: string) {
const data = await this.journey.getPublicJourney(token);
if (!data) {
throw new HttpException({ error: 'Not found' }, 404);
}
@@ -154,7 +154,7 @@ export class JourneyShareService {
return { ownerId: photo.owner_id || photo.journey_owner_id };
}
getPublicJourney(token: string) {
async getPublicJourney(token: string) {
const row = this.db.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
@@ -247,7 +247,7 @@ export class JourneyShareService {
// instance default and the managed-instance key in that order; carto_api_key is
// encrypted at rest but deliberately unmasked, since it is useless until it
// reaches a browser.
const ownerCartoKey = this.settings.getUserSettings(journey.user_id)['carto_api_key'];
const ownerCartoKey = (await this.settings.getUserSettings(journey.user_id))['carto_api_key'];
const cartoApiKey = typeof ownerCartoKey === 'string' ? ownerCartoKey.trim() : '';
return {
+1 -1
View File
@@ -94,7 +94,7 @@ export class JourneyService {
uploadToImmich(userId: number, relativePath: string, originalName: string) { return this.immich.uploadToImmich(userId, relativePath, originalName); }
// Public (share-token) access — no auth, validated by token.
getPublicJourney(token: string) { return this.share.getPublicJourney(token); }
async getPublicJourney(token: string) { return this.share.getPublicJourney(token); }
validateShareTokenForPhoto(token: string, photoId: number) { return this.share.validateShareTokenForPhoto(token, photoId); }
validateShareTokenForAsset(token: string, assetId: string) { return this.share.validateShareTokenForAsset(token, assetId); }
streamPhoto(res: Response, ownerId: number, photoId: number, kind: 'thumbnail' | 'original') { return this.photoResolver.streamPhoto(res, ownerId, photoId, kind); }
@@ -30,7 +30,7 @@ export class LlmConfigResolver {
*/
async resolve(userId: number): Promise<ResolvedLlmConfig | null> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING))) return null;
return this.readInstanceConfig() ?? this.readUserConfig(userId);
return this.readInstanceConfig() ?? (await this.readUserConfig(userId));
}
private readInstanceConfig(): ResolvedLlmConfig | null {
@@ -57,8 +57,8 @@ export class LlmConfigResolver {
};
}
private readUserConfig(userId: number): ResolvedLlmConfig | null {
const settings = this.settings.getUserSettings(userId);
private async readUserConfig(userId: number): Promise<ResolvedLlmConfig | null> {
const settings = await this.settings.getUserSettings(userId);
const provider = asProvider(settings.llm_provider);
const model = typeof settings.llm_model === 'string' ? settings.llm_model.trim() : '';
if (!provider || !model) return null;
@@ -72,7 +72,7 @@ export class LlmConfigResolver {
// an admin's own row. This is the choke point every consumer passes
// (booking import and the plugin RPC surface), and the only place that also
// catches values already sitting in the db.
const endpoints = this.settings.getAdminUserDefaults();
const endpoints = await this.settings.getAdminUserDefaults();
// 'local' is an endpoint choice too ("some address I name"), so without an
// admin-set local endpoint there is no config at all, never a silent
// redirect to a different provider.
@@ -82,7 +82,7 @@ export class LlmConfigResolver {
? endpoints.llm_base_url.trim()
: undefined;
const apiKey = this.settings.getDecryptedUserSetting(userId, 'llm_api_key') ?? undefined;
const apiKey = (await this.settings.getDecryptedUserSetting(userId, 'llm_api_key')) ?? undefined;
return {
provider,
model,
+18 -18
View File
@@ -1001,16 +1001,16 @@ export class MapsService {
* 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 } {
async resolveMapsKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
}
getMapsKey(userId: number): string | null {
return this.resolveMapsKey(userId).key;
async getMapsKey(userId: number): Promise<string | null> {
return (await this.resolveMapsKey(userId)).key;
}
/** The Amap credential, resolved through the identical three-step chain. */
resolveAmapKey(userId: number): { key: string | null; source: ApiKeySource | null } {
async resolveAmapKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
return resolveApiKey(this.database, 'amap_api_key', userId, readEnv().maps.amapApiKey);
}
@@ -1046,12 +1046,12 @@ export class MapsService {
* walked when there is no Google key, so an install on Google issues exactly
* the database reads it always did.
*/
keyedProvider(userId: number): KeyedProvider | null {
async keyedProvider(userId: number): Promise<KeyedProvider | null> {
const choice = this.placesProviderChoice();
if (choice === 'openstreetmap') return null;
if (choice !== 'amap') {
const google = this.resolveMapsKey(userId);
const google = await this.resolveMapsKey(userId);
if (google.key) return { id: 'google', key: google.key, source: google.source };
// An explicit 'google' choice with no key is not a reason to query Amap
// instead: this install is on Google and is misconfigured. OSM answers,
@@ -1059,15 +1059,15 @@ export class MapsService {
if (choice === 'google') return null;
}
const amap = this.resolveAmapKey(userId);
const amap = await this.resolveAmapKey(userId);
return amap.key
? { id: 'amap', provider: new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }) }
: null;
}
/** The Amap provider, when Amap holds the keyed slot; null otherwise. */
resolvePlacesProvider(userId: number): AmapPlacesProvider | null {
const keyed = this.keyedProvider(userId);
async resolvePlacesProvider(userId: number): Promise<AmapPlacesProvider | null> {
const keyed = await this.keyedProvider(userId);
return keyed?.id === 'amap' ? keyed.provider : null;
}
@@ -1085,9 +1085,9 @@ export class MapsService {
* install that has since dropped its Amap key. Callers treat that as a miss,
* not an error.
*/
private providerForPlaceId(userId: number, placeId: string): AmapPlacesProvider | null {
private async providerForPlaceId(userId: number, placeId: string): Promise<AmapPlacesProvider | null> {
if (!isAmapPlaceId(placeId)) return null;
const amap = this.resolveAmapKey(userId);
const amap = await this.resolveAmapKey(userId);
return amap.key ? new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }) : null;
}
@@ -2023,7 +2023,7 @@ export class MapsService {
locationBias?: { lat: number; lng: number; radius?: number },
opts: { googleIdentityOnly?: boolean } = {},
): Promise<{ places: Record<string, unknown>[]; source: string }> {
const keyed = this.keyedProvider(userId);
const keyed = await this.keyedProvider(userId);
const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null };
// The TREK index answers first, whether or not a Google key exists. It is
@@ -2179,7 +2179,7 @@ export class MapsService {
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
sessionToken?: string,
): Promise<MapsAutocompleteResult> {
const keyed = this.keyedProvider(userId);
const keyed = await this.keyedProvider(userId);
const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null };
// This is the path that mattered most. Nominatim's usage policy names
@@ -2468,7 +2468,7 @@ export class MapsService {
// (the 'de' the legacy service defaulted to was a development leftover;
// cache rows keyed 'de' for lang-less callers go cold once — 7-day TTL).
const langKey = toApiLang(lang);
const apiKey = this.getMapsKey(userId);
const apiKey = await this.getMapsKey(userId);
// No key means no way to resolve a Google id: they have no OpenStreetMap
// equivalent to fall back to. That is an empty result, not a client error.
// Search and autocomplete already answer their keyless case with the OSM
@@ -2567,7 +2567,7 @@ export class MapsService {
placeId: string,
lang?: string,
): Promise<{ place: Record<string, unknown> | null }> {
const provider = this.providerForPlaceId(userId, placeId);
const provider = await this.providerForPlaceId(userId, placeId);
if (!provider) return { place: null };
const langKey = toApiLang(lang);
@@ -2622,7 +2622,7 @@ export class MapsService {
}
const langKey = toApiLang(lang); // 'en' default — see getPlaceDetails
const apiKey = this.getMapsKey(userId);
const apiKey = await this.getMapsKey(userId);
// Same as the lean lookup above: an empty result, not a client error.
if (!apiKey) return { place: null };
@@ -2740,7 +2740,7 @@ export class MapsService {
const fetchPromise = (async (): Promise<{ attribution: string | null } | null> => {
await acquirePhotoFetchSlot();
try {
const apiKey = this.getMapsKey(userId);
const apiKey = await this.getMapsKey(userId);
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
// (right-click) places and as a fallback when a Google place yields no photo,
@@ -2890,7 +2890,7 @@ export class MapsService {
// operator env var and the instance-wide row, and nobody's personal key is
// read on somebody else's behalf (#1939). Nominatim stays the fallback, so
// an Amap outage does not take a right-click down with it.
const amap = this.resolvePlacesProvider(0);
const amap = await this.resolvePlacesProvider(0);
if (amap) {
const latNum = Number.parseFloat(lat);
const lngNum = Number.parseFloat(lng);
@@ -448,7 +448,7 @@ export class PlaceEnrichmentService {
/** The record the caller already holds; only its category is read. */
details: Record<string, unknown> | null,
): Promise<PlacePhotoCandidate[]> {
const apiKey = this.maps.getMapsKey(userId);
const apiKey = await this.maps.getMapsKey(userId);
const wantsGoogle = !!apiKey && !this.maps.photosDisabled() && isGooglePlaceId(placeId);
const { wikidata, wikipedia } = identity;
@@ -693,7 +693,7 @@ export class PlaceEnrichmentService {
const fromSite = await this.websiteDescription(placeId);
if (fromSite) return fromSite;
const apiKey = this.maps.getMapsKey(userId);
const apiKey = await this.maps.getMapsKey(userId);
if (apiKey && !this.maps.detailsDisabled() && isGooglePlaceId(placeId)) {
const summary = await this.maps.fetchEditorialSummary(placeId, apiKey, req.lang);
if (summary) {
+2 -2
View File
@@ -1488,7 +1488,7 @@ export class PlacesService {
async enrichImportedPlaces(tripId: string, userId: number, places: EnrichablePlace[], lang?: string): Promise<void> {
try {
if (!places.length) return;
if (!this.maps.getMapsKey(userId)) return;
if (!(await this.maps.getMapsKey(userId))) return;
await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => {
try {
await this.enrichOne(tripId, userId, place, lang);
@@ -1566,7 +1566,7 @@ export class PlacesService {
const place = this.dbs.get<Place>('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
if (!place) return { error: 'Place not found', status: 404 };
return this.unsplash.searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, this.unsplash.getUnsplashKey(userId));
return this.unsplash.searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, await this.unsplash.getUnsplashKey(userId));
}
// -------------------------------------------------------------------------
@@ -145,7 +145,7 @@ export class RoadtripPlanService {
throw new HttpException({ error: 'This trip exceeds the 150-visit calculation limit.' }, 400);
const asked = new Set<string>();
const distanceUnit: DistanceUnit =
this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric';
(await this.settings.getUserSettings(userId)).distance_unit === 'imperial' ? 'imperial' : 'metric';
const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => {
const points: { lat: number; lng: number }[] = [];
const stopAt: number[] = [];
@@ -151,7 +151,7 @@ export class RoadtripRouterService {
profile: string,
avoid: RouteAvoidClass[],
): Promise<RoadtripRoute> {
const settings = this.settings.getUserSettings(userId);
const settings = await this.settings.getUserSettings(userId);
const key = JSON.stringify([
userId,
tripId,
@@ -14,34 +14,34 @@ export class SchoolHolidaysController {
constructor(private readonly holidays: SchoolHolidaysService) {}
@Get()
catalog() { return this.holidays.catalog(); }
async catalog() { return this.holidays.catalog(); }
@Get('regions/:id')
region(@Param('id', ParseIntPipe) id: number) { return this.holidays.region(id); }
async region(@Param('id', ParseIntPipe) id: number) { return this.holidays.region(id); }
@Get('regions/:id/holidays/:year')
forYear(@Param('id', ParseIntPipe) id: number, @Param('year') year: string) {
async forYear(@Param('id', ParseIntPipe) id: number, @Param('year') year: string) {
if (!/^\d{4}$/.test(year)) throw new BadRequestException('Invalid year');
return this.holidays.holidays(id, year);
}
@Post('countries')
@UseGuards(AdminGuard)
createCountry(@Body() body: SchoolHolidayCountryDto) { return this.holidays.createCountry(body); }
async createCountry(@Body() body: SchoolHolidayCountryDto) { return this.holidays.createCountry(body); }
@Delete('countries/:code')
@UseGuards(AdminGuard)
deleteCountry(@Param('code') code: string) { return this.holidays.deleteCountry(code); }
async deleteCountry(@Param('code') code: string) { return this.holidays.deleteCountry(code); }
@Post('countries/:code/regions')
@UseGuards(AdminGuard)
createRegion(@Param('code') code: string, @Body() body: SchoolHolidayRegionDto) { return this.holidays.createRegion(code, body); }
async createRegion(@Param('code') code: string, @Body() body: SchoolHolidayRegionDto) { return this.holidays.createRegion(code, body); }
@Put('regions/:id')
@UseGuards(AdminGuard)
updateRegion(@Param('id', ParseIntPipe) id: number, @Body() body: SchoolHolidayRegionDto) { return this.holidays.updateRegion(id, body); }
async updateRegion(@Param('id', ParseIntPipe) id: number, @Body() body: SchoolHolidayRegionDto) { return this.holidays.updateRegion(id, body); }
@Delete('regions/:id')
@UseGuards(AdminGuard)
deleteRegion(@Param('id', ParseIntPipe) id: number, @Query('revision', ParseIntPipe) revision: number) { return this.holidays.deleteRegion(id, revision); }
async deleteRegion(@Param('id', ParseIntPipe) id: number, @Query('revision', ParseIntPipe) revision: number) { return this.holidays.deleteRegion(id, revision); }
}
@@ -10,9 +10,9 @@ import { SchoolHolidaysService } from './school-holidays.service';
export class SchoolHolidaysMcp {
constructor(private readonly holidays: SchoolHolidaysService, private readonly guards: McpToolGuardsService) {}
private adminWrite(ctx: McpContext, write: () => unknown) {
private async adminWrite(ctx: McpContext, write: () => unknown) {
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
try { return ok(write()); }
try { return ok(await write()); }
catch (error) {
if (error instanceof HttpException || error instanceof z.ZodError) return errorResult(error.message);
throw error;
@@ -25,7 +25,7 @@ export class SchoolHolidaysMcp {
inputSchema: {}, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'vacay', mode: 'read' },
})
catalog() { return ok(this.holidays.catalog()); }
async catalog() { return ok(await this.holidays.catalog()); }
@Tool({
name: 'get_manual_school_holiday_region',
@@ -33,7 +33,7 @@ export class SchoolHolidaysMcp {
inputSchema: { regionId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'vacay', mode: 'read' },
})
region({ regionId }: { regionId: number }) { return ok(this.holidays.region(regionId)); }
async region({ regionId }: { regionId: number }) { return ok(await this.holidays.region(regionId)); }
@Tool({
name: 'list_manual_school_holidays',
@@ -41,7 +41,7 @@ export class SchoolHolidaysMcp {
inputSchema: { regionId: idSchema, year: z.number().int().min(1000).max(9999) },
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'vacay', mode: 'read' },
})
forYear({ regionId, year }: { regionId: number; year: number }) { return ok({ holidays: this.holidays.holidays(regionId, String(year)) }); }
async forYear({ regionId, year }: { regionId: number; year: number }) { return ok({ holidays: await this.holidays.holidays(regionId, String(year)) }); }
@Tool({
name: 'create_manual_school_holiday_country',
@@ -49,7 +49,7 @@ export class SchoolHolidaysMcp {
inputSchema: schoolHolidayCountryRequestSchema.shape, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'vacay', mode: 'write' },
})
createCountry(body: SchoolHolidayCountryRequest, ctx: McpContext) {
async createCountry(body: SchoolHolidayCountryRequest, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.createCountry(schoolHolidayCountryRequestSchema.parse(body)));
}
@@ -59,7 +59,7 @@ export class SchoolHolidaysMcp {
inputSchema: { country: schoolHolidayCountryRequestSchema.shape.code, ...schoolHolidayRegionRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'vacay', mode: 'write' },
})
createRegion({ country, ...body }: SchoolHolidayRegionRequest & { country: string }, ctx: McpContext) {
async createRegion({ country, ...body }: SchoolHolidayRegionRequest & { country: string }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.createRegion(country, schoolHolidayRegionRequestSchema.parse(body)));
}
@@ -69,7 +69,7 @@ export class SchoolHolidaysMcp {
inputSchema: { regionId: idSchema, ...schoolHolidayRegionRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'vacay', mode: 'write' },
})
updateRegion({ regionId, ...body }: SchoolHolidayRegionRequest & { regionId: number }, ctx: McpContext) {
async updateRegion({ regionId, ...body }: SchoolHolidayRegionRequest & { regionId: number }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.updateRegion(regionId, schoolHolidayRegionRequestSchema.parse(body)));
}
@@ -79,7 +79,7 @@ export class SchoolHolidaysMcp {
inputSchema: { regionId: idSchema, revision: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
})
deleteRegion({ regionId, revision }: { regionId: number; revision: number }, ctx: McpContext) {
async deleteRegion({ regionId, revision }: { regionId: number; revision: number }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.deleteRegion(regionId, revision));
}
@@ -89,7 +89,7 @@ export class SchoolHolidaysMcp {
inputSchema: { code: schoolHolidayCountryRequestSchema.shape.code },
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
})
deleteCountry({ code }: { code: string }, ctx: McpContext) {
async deleteCountry({ code }: { code: string }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.deleteCountry(code));
}
}
@@ -1,33 +1,37 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayPeriod, SchoolHolidayRegion, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
@Injectable()
export class SchoolHolidaysService {
constructor(private readonly db: DatabaseService) {}
constructor(
private readonly db: DatabaseService,
private readonly uow: UnitOfWork,
) {}
catalog(): SchoolHolidayCatalog {
async catalog(): Promise<SchoolHolidayCatalog> {
return {
countries: this.db.all<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries ORDER BY name, code'),
regions: this.db.all<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions ORDER BY name, id"),
};
}
country(code: string): SchoolHolidayCountryRequest {
async country(code: string): Promise<SchoolHolidayCountryRequest> {
const country = this.db.get<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries WHERE code = ?', code);
if (!country) throw new NotFoundException('Country not found');
return country;
}
createCountry(country: SchoolHolidayCountryRequest) {
async createCountry(country: SchoolHolidayCountryRequest) {
const inserted = this.db.run('INSERT OR IGNORE INTO school_holiday_countries (code, name) VALUES (?, ?)', country.code, country.name);
if (!inserted.changes) throw new ConflictException('Country already exists');
return country;
}
deleteCountry(code: string) {
return this.db.transaction(() => {
this.country(code);
async deleteCountry(code: string) {
return this.uow.transactional(async () => {
await this.country(code);
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? LIMIT 1', code)) {
throw new ConflictException('Remove the regions before deleting this country');
}
@@ -36,50 +40,50 @@ export class SchoolHolidaysService {
});
}
region(id: number): SchoolHolidayRegionDetail {
async region(id: number): Promise<SchoolHolidayRegionDetail> {
const region = this.db.get<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions WHERE id = ?", id);
if (!region) throw new NotFoundException('Region not found');
return { ...region, holidays: this.db.all<SchoolHolidayPeriod>('SELECT name, start_date AS startDate, end_date AS endDate FROM school_holiday_periods WHERE region_id = ? ORDER BY start_date, end_date, name', id) };
}
private checkName(country: string, name: string, id: number) {
private async checkName(country: string, name: string, id: number) {
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? AND name = ? COLLATE NOCASE AND id != ?', country, name, id)) {
throw new ConflictException('A region with this name already exists');
}
}
private writePeriods(id: number, holidays: SchoolHolidayPeriod[]) {
private async writePeriods(id: number, holidays: SchoolHolidayPeriod[]) {
this.db.run('DELETE FROM school_holiday_periods WHERE region_id = ?', id);
const insert = this.db.prepare('INSERT INTO school_holiday_periods (region_id, name, start_date, end_date) VALUES (?, ?, ?, ?)');
for (const holiday of holidays) insert.run(id, holiday.name, holiday.startDate, holiday.endDate);
}
createRegion(country: string, body: SchoolHolidayRegionRequest) {
return this.db.transaction(() => {
this.country(country);
this.checkName(country, body.name, 0);
async createRegion(country: string, body: SchoolHolidayRegionRequest) {
return this.uow.transactional(async () => {
await this.country(country);
await this.checkName(country, body.name, 0);
if (body.revision !== 0) throw new ConflictException('New regions must have revision zero');
const inserted = this.db.run('INSERT INTO school_holiday_regions (country, name) VALUES (?, ?)', country, body.name);
const id = Number(inserted.lastInsertRowid);
this.writePeriods(id, body.holidays);
await this.writePeriods(id, body.holidays);
return this.region(id);
});
}
updateRegion(id: number, body: SchoolHolidayRegionRequest) {
return this.db.transaction(() => {
const region = this.region(id);
this.checkName(region.country, body.name, id);
async updateRegion(id: number, body: SchoolHolidayRegionRequest) {
return this.uow.transactional(async () => {
const region = await this.region(id);
await this.checkName(region.country, body.name, id);
const updated = this.db.run('UPDATE school_holiday_regions SET name = ?, revision = revision + 1 WHERE id = ? AND revision = ?', body.name, id, body.revision);
if (!updated.changes) throw new ConflictException('This region changed. Reopen it before saving again.');
this.writePeriods(id, body.holidays);
await this.writePeriods(id, body.holidays);
return this.region(id);
});
}
deleteRegion(id: number, revision: number) {
return this.db.transaction(() => {
const region = this.region(id);
async deleteRegion(id: number, revision: number) {
return this.uow.transactional(async () => {
const region = await this.region(id);
if (region.revision !== revision) throw new ConflictException('This region changed. Reload before deleting it.');
if (this.db.get("SELECT id FROM vacay_holiday_calendars WHERE type = 'school_holiday' AND region = ? LIMIT 1", region.code)) {
throw new ConflictException('This region is used by vacation calendars and cannot be deleted');
@@ -90,7 +94,7 @@ export class SchoolHolidaysService {
});
}
holidays(id: number, year: string): SchoolHolidayPeriod[] {
return this.region(id).holidays.filter(holiday => holiday.startDate <= `${year}-12-31` && holiday.endDate >= `${year}-01-01`);
async holidays(id: number, year: string): Promise<SchoolHolidayPeriod[]> {
return (await this.region(id)).holidays.filter(holiday => holiday.startDate <= `${year}-12-31` && holiday.endDate >= `${year}-01-01`);
}
}
@@ -47,7 +47,7 @@ const USER_ROW_SQL: Record<InstanceApiKeyName, string> = {
};
/** The instance-wide value in cleartext, or null when unset/cleared. */
export function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): string | null {
export async function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): Promise<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;
@@ -59,7 +59,7 @@ export function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName
* 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 {
export async function writeInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName, value: unknown): Promise<void> {
db.run(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
@@ -80,15 +80,15 @@ export function writeInstanceApiKey(db: DatabaseService, name: InstanceApiKeyNam
* `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(
export async function resolveApiKey(
db: DatabaseService,
name: InstanceApiKeyName,
userId: number,
operatorKey: string | undefined,
): { key: string | null; source: ApiKeySource | null } {
): Promise<{ key: string | null; source: ApiKeySource | null }> {
if (operatorKey) return { key: operatorKey, source: 'operator-env' };
const instance = readInstanceApiKey(db, name);
const instance = await readInstanceApiKey(db, name);
if (instance) return { key: instance, source: 'instance' };
if (!userId) return { key: null, source: null };
@@ -55,12 +55,12 @@ export class SettingsController {
}
@Get()
list(@CurrentUser() user: User) {
return { settings: this.settings.getUserSettings(user.id) };
async list(@CurrentUser() user: User) {
return { settings: await this.settings.getUserSettings(user.id) };
}
@Put()
upsert(@CurrentUser() user: User, @Body() body: SettingUpsertDto) {
async upsert(@CurrentUser() user: User, @Body() body: SettingUpsertDto) {
this.assertMayWriteInstanceEndpoint({ [body.key]: body.value });
// assertMayWriteInstanceEndpoint only covers llm_base_url and provider 'local'.
// llm_api_key and llm_model are writable by every user, and on a managed
@@ -72,17 +72,17 @@ export class SettingsController {
if (body.value === MASKED_SETTING_VALUE) {
return { success: true, key: body.key, unchanged: true };
}
this.settings.upsertSetting(user.id, body.key, body.value);
await this.settings.upsertSetting(user.id, body.key, body.value);
return { success: true, key: body.key, value: body.value };
}
@Post('bulk')
@HttpCode(200) // Express answers bulk with res.json (200), not the POST-default 201.
bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto) {
async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto) {
this.assertMayWriteInstanceEndpoint(body.settings);
const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged());
try {
const updated = this.settings.bulkUpsertSettings(user.id, allowed);
const updated = await this.settings.bulkUpsertSettings(user.id, allowed);
return { success: true, updated, ...(blocked.length ? { managed_keys: blocked } : {}) };
} catch (err) {
console.error('Error saving settings:', err);
@@ -108,7 +108,7 @@ export class AdminDefaultUserSettingsController {
) {}
@Get()
get() {
async get() {
return this.settings.getAdminUserDefaults();
}
@@ -123,7 +123,7 @@ export class AdminDefaultUserSettingsController {
body as unknown as Record<string, unknown>,
this.env.isManaged(),
);
this.settings.setAdminUserDefaults(allowed);
await this.settings.setAdminUserDefaults(allowed);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.default_user_settings_update',
+5 -5
View File
@@ -101,8 +101,8 @@ export class SettingsMcp {
* injection before this filter runs, so the caller sees the same effective
* value the web UI does, minus everything not named on the allow-list.
*/
private readDisplayPreferences(userId: number): Record<string, unknown> {
const all = this.settings.getUserSettings(userId);
private async readDisplayPreferences(userId: number): Promise<Record<string, unknown>> {
const all = await this.settings.getUserSettings(userId);
const picked: Record<string, unknown> = {};
for (const key of DISPLAY_PREFERENCE_KEYS) {
if (Object.hasOwn(all, key)) picked[key] = all[key];
@@ -118,7 +118,7 @@ export class SettingsMcp {
access: { group: 'settings', mode: 'read' },
})
async getDisplaySettings(_input: Record<string, never>, ctx: McpContext) {
return ok({ settings: this.readDisplayPreferences(ctx.userId) });
return ok({ settings: await this.readDisplayPreferences(ctx.userId) });
}
@Tool({
@@ -163,9 +163,9 @@ export class SettingsMcp {
validated[key] = parsed.data;
}
const updated = this.settings.bulkUpsertSettings(ctx.userId, validated);
const updated = await this.settings.bulkUpsertSettings(ctx.userId, validated);
// Read back rather than echoing the input: an admin default or the managed
// token injection can still shape what the user ends up seeing.
return ok({ success: true, updated, settings: this.readDisplayPreferences(ctx.userId) });
return ok({ success: true, updated, settings: await this.readDisplayPreferences(ctx.userId) });
}
}
+14 -10
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { MASKED_SETTING_VALUE, normalizeAppearance } from '@trek/shared';
import { readEnv } from '../../app-config';
@@ -149,9 +150,12 @@ function serializeValue(key: string, value: unknown): string {
*/
@Injectable()
export class SettingsService {
constructor(private readonly db: DatabaseService) {}
constructor(
private readonly db: DatabaseService,
private readonly uow: UnitOfWork,
) {}
getAdminUserDefaults(): Record<string, unknown> {
async getAdminUserDefaults(): Promise<Record<string, unknown>> {
const rows = this.db.all<{ key: string; value: string }>(
"SELECT key, value FROM app_settings WHERE key LIKE 'default_user_setting_%'"
);
@@ -167,14 +171,14 @@ export class SettingsService {
return defaults;
}
setAdminUserDefaults(partial: Record<string, unknown>): void {
async setAdminUserDefaults(partial: Record<string, unknown>): Promise<void> {
const upsert = this.db.prepare(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
);
const del = this.db.prepare("DELETE FROM app_settings WHERE key = ?");
this.db.transaction(() => {
await this.uow.transactional(async () => {
for (const [key, value] of Object.entries(partial)) {
if (!(DEFAULTABLE_USER_SETTING_KEYS as readonly string[]).includes(key)) {
throw new Error(`Invalid setting key: ${key}`);
@@ -206,8 +210,8 @@ export class SettingsService {
});
}
getUserSettings(userId: number): Record<string, unknown> {
const adminDefaults = this.getAdminUserDefaults();
async getUserSettings(userId: number): Promise<Record<string, unknown>> {
const adminDefaults = await this.getAdminUserDefaults();
const rows = this.db.all<{ key: string; value: string }>('SELECT key, value FROM settings WHERE user_id = ?', userId);
const userSettings: Record<string, unknown> = {};
@@ -277,20 +281,20 @@ export class SettingsService {
return merged;
}
upsertSetting(userId: number, key: string, value: unknown) {
async upsertSetting(userId: number, key: string, value: unknown) {
this.db.run(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
`, userId, key, serializeValue(key, value));
}
bulkUpsertSettings(userId: number, settings: Record<string, unknown>) {
async bulkUpsertSettings(userId: number, settings: Record<string, unknown>) {
const upsert = this.db.prepare(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
`);
let written = 0;
this.db.transaction(() => {
await this.uow.transactional(async () => {
for (const [key, value] of Object.entries(settings)) {
// The client echoes redacted secrets back unchanged — skip them so a
// bulk save can never overwrite a stored secret with the mask (the
@@ -309,7 +313,7 @@ export class SettingsService {
* returns the plaintext — for server-side use only (e.g. the LLM config
* resolver needs the real API key). Returns null when unset.
*/
getDecryptedUserSetting(userId: number, key: string): string | null {
async getDecryptedUserSetting(userId: number, key: string): Promise<string | null> {
const row = this.db.get<{ value: string }>('SELECT value FROM settings WHERE user_id = ? AND key = ?', userId, key);
if (!row || row.value === '' || row.value == null) return null;
if (ENCRYPTED_SETTING_KEYS.has(key)) return decrypt_api_key(row.value);
+1 -1
View File
@@ -431,7 +431,7 @@ export class ShareService {
// (`||` on purpose: an empty-string trip currency also falls back).
let baseCurrency = (trip as { currency?: string }).currency || 'EUR';
const ownerSettings: Record<string, unknown> = shareRow.created_by != null
? this.settings.getUserSettings(shareRow.created_by)
? await this.settings.getUserSettings(shareRow.created_by)
: {};
const ownerDefault = ownerSettings['default_currency'];
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
@@ -236,7 +236,7 @@ function stopFrom(stop: GoogleStop | undefined, fallback: GoogleLatLng | undefin
export class GoogleTransitProvider {
constructor(private readonly database: DatabaseService) {}
private resolveKey(userId: number): { key: string | null; source: ApiKeySource | null } {
private async resolveKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
}
@@ -248,7 +248,7 @@ export class GoogleTransitProvider {
*/
async isActive(userId: number): Promise<boolean> {
if ((await readTransitProvider(this.database)) !== 'google') return false;
return !!this.resolveKey(userId).key;
return !!(await this.resolveKey(userId)).key;
}
private async call(endpoint: string, label: string, apiKey: string, body: unknown, fieldMask: string): Promise<unknown> {
@@ -284,7 +284,7 @@ export class GoogleTransitProvider {
/** Station/place search for the from/to pickers. `near` biases results. */
async geocode(text: string, language: string | undefined, near: string | undefined, userId: number): Promise<{ results: TransitPlace[] }> {
const { key: apiKey, source } = this.resolveKey(userId);
const { key: apiKey, source } = await this.resolveKey(userId);
if (!apiKey) {
const err = new Error('Transit provider error (no Google API key configured)') as Error & { status: number };
err.status = 502;
@@ -333,7 +333,7 @@ export class GoogleTransitProvider {
/** Route search between two coordinates. Returns the same compact shape MOTIS is mapped to. */
async plan(q: PlanQuery, language: string | undefined, userId: number): Promise<{ itineraries: TransitItinerary[] }> {
const { key: apiKey, source } = this.resolveKey(userId);
const { key: apiKey, source } = await this.resolveKey(userId);
if (!apiKey) {
const err = new Error('Transit provider error (no Google API key configured)') as Error & { status: number };
err.status = 502;
+2 -2
View File
@@ -378,8 +378,8 @@ export class TripsService {
return this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip | undefined;
}
searchCoverImages(query: string, userId: number) {
return this.unsplash.searchUnsplashPhotos(query, 9, this.unsplash.getUnsplashKey(userId));
async searchCoverImages(query: string, userId: number) {
return this.unsplash.searchUnsplashPhotos(query, 9, await this.unsplash.getUnsplashKey(userId));
}
getOwner(tripId: string | number): { user_id: number } | undefined {
+2 -2
View File
@@ -66,8 +66,8 @@ export class UnsplashService {
* 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 {
return resolveApiKey(this.db, 'unsplash_api_key', userId, this.env.env().integrations.unsplashAccessKey).key;
async getUnsplashKey(userId: number): Promise<string | null> {
return (await resolveApiKey(this.db, 'unsplash_api_key', userId, this.env.env().integrations.unsplashAccessKey)).key;
}
async searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
+45 -45
View File
@@ -48,9 +48,9 @@ beforeEach(() => {
db.exec('DELETE FROM vacay_holiday_calendars; DELETE FROM school_holiday_periods; DELETE FROM school_holiday_regions; DELETE FROM school_holiday_countries;');
});
function seed() {
service.createCountry({ code: 'US', name: 'USA' });
return service.createRegion('US', { name: 'Seattle schools', revision: 0, holidays: [winter] });
async function seed() {
await service.createCountry({ code: 'US', name: 'USA' });
return await service.createRegion('US', { name: 'Seattle schools', revision: 0, holidays: [winter] });
}
describe('global manual school holidays', () => {
@@ -79,41 +79,41 @@ describe('global manual school holidays', () => {
const holidays = await request(app.getHttpServer()).get(`${base}/regions/${created.body.id}/holidays/${year}`).set('Cookie', sessionCookie(2)).expect(200);
expect(holidays.body).toEqual([winter]);
}
expect(service.holidays(created.body.id, '2025')).toEqual([]);
expect(await service.holidays(created.body.id, '2025')).toEqual([]);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it('validates dates and body shape before writing', async () => {
service.createCountry({ code: 'US', name: 'USA' });
await service.createCountry({ code: 'US', name: 'USA' });
for (const holidays of [[{ ...winter, endDate: '2026-01-01' }], [{ ...winter, startDate: '2026-02-30' }], [{ ...winter, name: ' ' }]]) {
await request(app.getHttpServer()).post(`${base}/countries/US/regions`).set('Cookie', sessionCookie(1)).send({ name: 'Seattle', revision: 0, holidays }).expect(400);
}
expect(service.catalog().regions).toEqual([]);
expect((await service.catalog()).regions).toEqual([]);
});
it('preserves ids on rename and rejects stale saves atomically', async () => {
const region = seed();
const region = await seed();
const updated = await request(app.getHttpServer()).put(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(1)).send({ name: 'Renamed', revision: 1, holidays: [] }).expect(200);
expect(updated.body).toMatchObject({ code: region.code, revision: 2, holidays: [] });
await request(app.getHttpServer()).put(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(1)).send({ name: 'Stale', revision: 1, holidays: [winter] }).expect(409);
expect(service.region(region.id).name).toBe('Renamed');
expect(service.region(region.id).holidays).toEqual([]);
expect((await service.region(region.id)).name).toBe('Renamed');
expect((await service.region(region.id)).holidays).toEqual([]);
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(409);
});
it('rejects duplicates, missing countries and nonzero initial revisions', () => {
seed();
expect(() => service.createCountry({ code: 'US', name: 'America' })).toThrow('already exists');
expect(() => service.createRegion('US', { name: 'SEATTLE SCHOOLS', revision: 0, holidays: [] })).toThrow('already exists');
expect(() => service.createRegion('CA', { name: 'School', revision: 0, holidays: [] })).toThrow('not found');
expect(() => service.createRegion('US', { name: 'School', revision: 1, holidays: [] })).toThrow('revision zero');
const other = service.createRegion('US', { name: 'Other', revision: 0, holidays: [] });
expect(() => service.updateRegion(other.id, { name: 'Seattle schools', revision: 1, holidays: [] })).toThrow('already exists');
it('rejects duplicates, missing countries and nonzero initial revisions', async () => {
await seed();
await expect(service.createCountry({ code: 'US', name: 'America' })).rejects.toThrow('already exists');
await expect(service.createRegion('US', { name: 'SEATTLE SCHOOLS', revision: 0, holidays: [] })).rejects.toThrow('already exists');
await expect(service.createRegion('CA', { name: 'School', revision: 0, holidays: [] })).rejects.toThrow('not found');
await expect(service.createRegion('US', { name: 'School', revision: 1, holidays: [] })).rejects.toThrow('revision zero');
const other = await service.createRegion('US', { name: 'Other', revision: 0, holidays: [] });
await expect(service.updateRegion(other.id, { name: 'Seattle schools', revision: 1, holidays: [] })).rejects.toThrow('already exists');
});
it('protects used regions and deletes unused regions with their periods', async () => {
const region = seed();
const region = await seed();
db.prepare('INSERT OR IGNORE INTO vacay_plans (id, owner_id) VALUES (1, 1)').run();
db.prepare("INSERT INTO vacay_holiday_calendars (plan_id, type, region) VALUES (1, 'school_holiday', ?)").run(region.code);
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(409);
@@ -122,11 +122,11 @@ describe('global manual school holidays', () => {
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(200);
expect(db.prepare('SELECT * FROM school_holiday_periods').all()).toEqual([]);
await request(app.getHttpServer()).delete(`${base}/countries/US`).set('Cookie', sessionCookie(1)).expect(200);
expect(service.catalog()).toEqual({ countries: [], regions: [] });
expect(await service.catalog()).toEqual({ countries: [], regions: [] });
});
it('reports missing regions and malformed year and id parameters', async () => {
const region = seed();
const region = await seed();
await request(app.getHttpServer()).get(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(2)).expect(200);
await request(app.getHttpServer()).get(`${base}/regions/999999`).set('Cookie', sessionCookie(2)).expect(404);
await request(app.getHttpServer()).get(`${base}/regions/invalid`).set('Cookie', sessionCookie(2)).expect(400);
@@ -134,19 +134,19 @@ describe('global manual school holidays', () => {
await request(app.getHttpServer()).delete(`${base}/countries/CA`).set('Cookie', sessionCookie(1)).expect(404);
});
it('exposes the same manual catalog and periods through MCP', () => {
const region = seed();
it('exposes the same manual catalog and periods through MCP', async () => {
const region = await seed();
const mcp = app.get(SchoolHolidaysMcp);
expect(() => createTestRegistry([mcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess })).not.toThrow();
expect(JSON.stringify(mcp.catalog())).toContain(region.code);
expect(JSON.stringify(mcp.forYear({ regionId: region.id, year: 2027 }))).toContain('Winter break');
expect(JSON.stringify(await mcp.catalog())).toContain(region.code);
expect(JSON.stringify(await mcp.forYear({ regionId: region.id, year: 2027 }))).toContain('Winter break');
});
it('denies every MCP mutation to normal users without changing the catalog', () => {
const region = seed();
it('denies every MCP mutation to normal users without changing the catalog', async () => {
const region = await seed();
const mcp = app.get(SchoolHolidaysMcp);
const member = { userId: 2, scopes: null, isStaticToken: false };
const before = service.region(region.id);
const before = await service.region(region.id);
const writes = [
() => mcp.createCountry({ code: 'CA', name: 'Canada' }, member),
() => mcp.createRegion({ country: 'US', name: 'Other', revision: 0, holidays: [] }, member),
@@ -154,28 +154,28 @@ describe('global manual school holidays', () => {
() => mcp.deleteRegion({ regionId: region.id, revision: 1 }, member),
() => mcp.deleteCountry({ code: 'US' }, member),
];
for (const write of writes) expect(write()).toHaveProperty('isError', true);
expect(service.region(region.id)).toEqual(before);
expect(service.catalog().countries).toHaveLength(1);
expect(service.catalog().regions).toHaveLength(1);
for (const write of writes) expect(await write()).toHaveProperty('isError', true);
expect(await service.region(region.id)).toEqual(before);
expect((await service.catalog()).countries).toHaveLength(1);
expect((await service.catalog()).regions).toHaveLength(1);
});
it('supports the complete admin MCP workflow with validation and revision protection', () => {
it('supports the complete admin MCP workflow with validation and revision protection', async () => {
const mcp = app.get(SchoolHolidaysMcp);
const admin = { userId: 1, scopes: null, isStaticToken: false };
expect(mcp.createCountry({ code: 'US', name: 'USA' }, admin)).not.toHaveProperty('isError', true);
expect(mcp.createRegion({ country: 'US', name: 'Seattle', revision: 0, holidays: [winter] }, admin)).not.toHaveProperty('isError', true);
const region = service.catalog().regions[0];
expect(JSON.stringify(mcp.region({ regionId: region.id }))).toContain('Winter break');
expect(await mcp.createCountry({ code: 'US', name: 'USA' }, admin)).not.toHaveProperty('isError', true);
expect(await mcp.createRegion({ country: 'US', name: 'Seattle', revision: 0, holidays: [winter] }, admin)).not.toHaveProperty('isError', true);
const region = (await service.catalog()).regions[0];
expect(JSON.stringify(await mcp.region({ regionId: region.id }))).toContain('Winter break');
const edit = { regionId: region.id, name: 'Seattle Public Schools', revision: 1, holidays: [{ ...winter, name: 'Winter holiday' }] };
expect(mcp.updateRegion(edit, admin)).not.toHaveProperty('isError', true);
expect(mcp.updateRegion(edit, admin)).toHaveProperty('isError', true);
expect(mcp.updateRegion({ ...edit, revision: 2, holidays: [{ ...winter, endDate: '2025-01-01' }] }, admin)).toHaveProperty('isError', true);
expect(service.region(region.id)).toMatchObject({ revision: 2, holidays: [{ ...winter, name: 'Winter holiday' }] });
expect(mcp.deleteCountry({ code: 'US' }, admin)).toHaveProperty('isError', true);
expect(mcp.deleteRegion({ regionId: region.id, revision: 1 }, admin)).toHaveProperty('isError', true);
expect(mcp.deleteRegion({ regionId: region.id, revision: 2 }, admin)).not.toHaveProperty('isError', true);
expect(mcp.deleteCountry({ code: 'US' }, admin)).not.toHaveProperty('isError', true);
expect(service.catalog()).toEqual({ countries: [], regions: [] });
expect(await mcp.updateRegion(edit, admin)).not.toHaveProperty('isError', true);
expect(await mcp.updateRegion(edit, admin)).toHaveProperty('isError', true);
expect(await mcp.updateRegion({ ...edit, revision: 2, holidays: [{ ...winter, endDate: '2025-01-01' }] }, admin)).toHaveProperty('isError', true);
expect(await service.region(region.id)).toMatchObject({ revision: 2, holidays: [{ ...winter, name: 'Winter holiday' }] });
expect(await mcp.deleteCountry({ code: 'US' }, admin)).toHaveProperty('isError', true);
expect(await mcp.deleteRegion({ regionId: region.id, revision: 1 }, admin)).toHaveProperty('isError', true);
expect(await mcp.deleteRegion({ regionId: region.id, revision: 2 }, admin)).not.toHaveProperty('isError', true);
expect(await mcp.deleteCountry({ code: 'US' }, admin)).not.toHaveProperty('isError', true);
expect(await service.catalog()).toEqual({ countries: [], regions: [] });
});
});
+5 -5
View File
@@ -229,23 +229,23 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
new CollabMcp(collabService, authService, addonsService, guards),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
new SchoolHolidaysMcp(new SchoolHolidaysService(dbService), guards),
new SchoolHolidaysMcp(new SchoolHolidaysService(dbService, await createTestUnitOfWork(dbService.connection)), guards),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService, guards),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache), authService, guards),
new FeedsMcp(new FeedsService(dbService, calendarService), dbService, new RuntimeEnvService(), guards),
new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)),
new MapsMcp(mapsService),
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain, assignmentsService, guards, await createTestUnitOfWork(dbService.connection)),
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService),
new TransitMcp(new TransitService(new GoogleTransitProvider(dbService)), daysService, reservationsService, dbService, authService, guards),
new AtlasMcp(new AtlasService(dbService), addonsService, authService),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService)), addonsService, authService, captureBackfill),
new AtlasMcp(new AtlasService(dbService, await createTestUnitOfWork(dbService.connection)), addonsService, authService),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection))), addonsService, authService, captureBackfill),
new MemoriesMcp(immichService, synologyService, dbService, addonsService),
new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService),
new AirtrailMcp(new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient()), addonsService),
new ReservationImportMcp(new AirtrailImportService(dbService, realtimeService, reservationsService, new AirtrailClient(), new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient())), dbService, authService, guards, addonsService),
new SettingsMcp(new SettingsService(dbService), authService),
new SettingsMcp(new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), authService),
new HelpMcp(), new AddonsMcp(addonsService),
new TripWarningsMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService), dbService),
new PluginSearchMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService)),
+2 -2
View File
@@ -103,12 +103,12 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
const unsplash = new UnsplashService(dbs, new RuntimeEnvService(), generalStorage);
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs));
const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub(), generalStorage);
const atlas = new AtlasService(dbs);
const atlas = new AtlasService(dbs, await createTestUnitOfWork(dbs.connection));
const dayNotes = new DayNotesService(dbs, permissions, realtime);
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey);
const membership = new TripMembershipService(dbs);
const notifications = makeNotificationsService(dbs, realtime);
const llmConfig = new LlmConfigResolver(new SettingsService(dbs), dbs, addons);
const llmConfig = new LlmConfigResolver(new SettingsService(dbs, await createTestUnitOfWork(dbs.connection)), dbs, addons);
const oauth = new PluginOAuthService(dbs);
const accommodations = new AccommodationsService(dbs, permissions, realtime, assignments, await createTestUnitOfWork(dbs.connection));
// After it: deleting a place cancels the nights booked at it through this one.
@@ -26,9 +26,9 @@ function controller(over: Partial<SettingsService> = {}) {
return { c: new AdminDefaultUserSettingsController(settings, { writeAudit } as unknown as AuditService, { isManaged: () => false } as unknown as RuntimeEnvService), settings };
}
const thrown = (run: () => unknown) => {
const thrown = async (run: () => unknown) => {
try {
run();
await run();
return null;
} catch (e) {
return e instanceof HttpException ? { status: e.getStatus(), body: e.getResponse() } : e;
@@ -40,8 +40,8 @@ describe('AdminDefaultUserSettingsController', () => {
vi.clearAllMocks();
});
it('DEFAULTS-001 GET returns the stored defaults verbatim', () => {
expect(controller().c.get()).toEqual({ theme: 'dark' });
it('DEFAULTS-001 GET returns the stored defaults verbatim', async () => {
expect(await controller().c.get()).toEqual({ theme: 'dark' });
});
it('DEFAULTS-002 PUT writes, audits, and answers with the STORED defaults', async () => {
@@ -55,26 +55,26 @@ describe('AdminDefaultUserSettingsController', () => {
);
});
it('DEFAULTS-003 a rejected write is a 400 carrying the message, and is not audited', () => {
it('DEFAULTS-003 a rejected write is a 400 carrying the message, and is not audited', async () => {
const { c } = controller({
setAdminUserDefaults: vi.fn(() => {
throw new Error('unknown setting: nope');
}),
} as Partial<SettingsService>);
expect(thrown(() => c.update(user, { nope: 1 } as never, req))).toEqual({
expect(await thrown(() => c.update(user, { nope: 1 } as never, req))).toEqual({
status: 400,
body: { error: 'unknown setting: nope' },
});
expect(writeAudit).not.toHaveBeenCalled();
});
it('DEFAULTS-004 a non-Error throw is stringified rather than swallowed', () => {
it('DEFAULTS-004 a non-Error throw is stringified rather than swallowed', async () => {
const { c } = controller({
setAdminUserDefaults: vi.fn(() => {
throw 'plain string';
}),
} as Partial<SettingsService>);
expect(thrown(() => c.update(user, {} as never, req))).toEqual({ status: 400, body: { error: 'plain string' } });
expect(await thrown(() => c.update(user, {} as never, req))).toEqual({ status: 400, body: { error: 'plain string' } });
});
it('DEFAULTS-005 the class is listed in its module controllers', () => {
+25 -25
View File
@@ -34,9 +34,9 @@ async function thrown(fn: () => unknown): Promise<{ status: number; body: unknow
}
describe('AtlasController (parity with the legacy /api/addons/atlas route)', () => {
it('GET /stats delegates with the user id', () => {
it('GET /stats delegates with the user id', async () => {
const stats = vi.fn().mockReturnValue({ countries: 3 });
expect(makeController({ stats }).stats(user)).toEqual({ countries: 3 });
expect(await makeController({ stats }).stats(user)).toEqual({ countries: 3 });
expect(stats).toHaveBeenCalledWith(8);
});
@@ -79,21 +79,21 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
});
describe('country', () => {
it('GET /country/:code upper-cases the code', () => {
it('GET /country/:code upper-cases the code', async () => {
const countryPlaces = vi.fn().mockReturnValue([]);
makeController({ countryPlaces }).countryPlaces(user, 'de');
await makeController({ countryPlaces }).countryPlaces(user, 'de');
expect(countryPlaces).toHaveBeenCalledWith(8, 'DE');
});
it('POST mark returns success and upper-cases', () => {
it('POST mark returns success and upper-cases', async () => {
const markCountry = vi.fn();
expect(makeController({ markCountry }).markCountry(user, 'de')).toEqual({ success: true });
expect(await makeController({ markCountry }).markCountry(user, 'de')).toEqual({ success: true });
expect(markCountry).toHaveBeenCalledWith(8, 'DE');
});
it('DELETE mark returns success', () => {
it('DELETE mark returns success', async () => {
const unmarkCountry = vi.fn();
expect(makeController({ unmarkCountry }).unmarkCountry(user, 'FR')).toEqual({ success: true });
expect(await makeController({ unmarkCountry }).unmarkCountry(user, 'FR')).toEqual({ success: true });
});
});
@@ -102,17 +102,17 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
// the global ZodValidationPipe (atlas.dto.ts / markRegionRequestSchema)
// before the handler runs — covered by the e2e suite.
it('marks a region, upper-casing both codes', () => {
it('marks a region, upper-casing both codes', async () => {
const markRegion = vi.fn();
expect(makeController({ markRegion }).markRegion(user, 'by', { name: 'Bavaria', country_code: 'de' })).toEqual({ success: true });
expect(await makeController({ markRegion }).markRegion(user, 'by', { name: 'Bavaria', country_code: 'de' })).toEqual({ success: true });
expect(markRegion).toHaveBeenCalledWith(8, 'BY', 'Bavaria', 'DE');
});
});
describe('bucket list', () => {
it('GET wraps the items', () => {
it('GET wraps the items', async () => {
const bucketList = vi.fn().mockReturnValue([{ id: 1 }]);
expect(makeController({ bucketList }).bucketList(user)).toEqual({ items: [{ id: 1 }] });
expect(await makeController({ bucketList }).bucketList(user)).toEqual({ items: [{ id: 1 }] });
});
it('400 on create with a blank name', () => {
@@ -121,9 +121,9 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
expect(r).toEqual({ status: 400, body: { error: 'Name is required' } }));
});
it('201-shape create returns { item }', () => {
it('201-shape create returns { item }', async () => {
const createBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Tokyo' });
expect(makeController({ createBucketItem }).createBucketItem(user, { name: 'Tokyo', lat: 35, lng: 139 }))
expect(await makeController({ createBucketItem }).createBucketItem(user, { name: 'Tokyo', lat: 35, lng: 139 }))
.toEqual({ item: { id: 1, name: 'Tokyo' } });
expect(createBucketItem).toHaveBeenCalledWith(8, { name: 'Tokyo', lat: 35, lng: 139, country_code: undefined, notes: undefined, target_date: undefined });
});
@@ -134,9 +134,9 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
expect(r).toEqual({ status: 404, body: { error: 'Item not found' } }));
});
it('updates an existing item', () => {
it('updates an existing item', async () => {
const updateBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Kyoto' });
expect(makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Kyoto' }))
expect(await makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Kyoto' }))
.toEqual({ item: { id: 1, name: 'Kyoto' } });
});
@@ -152,12 +152,12 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
expect(r).toEqual({ status: 409, body: { error: 'Already on your bucket list' } }));
});
it('lets any other create/update failure through untouched', () => {
it('lets any other create/update failure through untouched', async () => {
const boom = new Error('disk on fire');
const createBucketItem = vi.fn(() => { throw boom; });
expect(() => makeController({ createBucketItem }).createBucketItem(user, { name: 'Japan' })).toThrow(boom);
await expect(makeController({ createBucketItem }).createBucketItem(user, { name: 'Japan' })).rejects.toThrow(boom);
const updateBucketItem = vi.fn(() => { throw boom; });
expect(() => makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Japan' })).toThrow(boom);
await expect(makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Japan' })).rejects.toThrow(boom);
});
it('404 on delete of a missing item', () => {
@@ -166,9 +166,9 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
expect(r).toEqual({ status: 404, body: { error: 'Item not found' } }));
});
it('deletes an existing item', () => {
it('deletes an existing item', async () => {
const deleteBucketItem = vi.fn().mockReturnValue(true);
expect(makeController({ deleteBucketItem }).deleteBucketItem(user, '1')).toEqual({ success: true });
expect(await makeController({ deleteBucketItem }).deleteBucketItem(user, '1')).toEqual({ success: true });
});
});
});
@@ -183,19 +183,19 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
// ---------------------------------------------------------------------------
describe('TravelStatsController', () => {
it('ATLAS-TRAVEL-001: delegates to AtlasService.getTravelStats with the caller id', () => {
it('ATLAS-TRAVEL-001: delegates to AtlasService.getTravelStats with the caller id', async () => {
const getTravelStats = vi.fn().mockReturnValue({ countries: ['JP'], totalTrips: 2 });
const controller = new TravelStatsController({ getTravelStats } as unknown as AtlasService);
expect(controller.travelStats(user)).toEqual({ countries: ['JP'], totalTrips: 2 });
expect(await controller.travelStats(user)).toEqual({ countries: ['JP'], totalTrips: 2 });
expect(getTravelStats).toHaveBeenCalledWith(8);
});
it('ATLAS-TRAVEL-002: passes the payload through untouched', () => {
it('ATLAS-TRAVEL-002: passes the payload through untouched', async () => {
const payload = { countries: [], cities: [], coords: [], totalTrips: 0, totalDays: 0, totalPlaces: 0, totalDistanceKm: 0 };
const controller = new TravelStatsController({ getTravelStats: () => payload } as unknown as AtlasService);
expect(controller.travelStats(user)).toBe(payload);
expect(await controller.travelStats(user)).toBe(payload);
});
it('ATLAS-TRAVEL-003: still answers on /api/auth/travel-stats, so the move is not a breaking change', () => {
+97 -95
View File
@@ -43,10 +43,11 @@ import { getCountryFromCoords, getCountryFromAddress, isPointInCountryBox, rever
import { cacheKeyFor, getCached, setCached } from '../../../src/nest/geo/nominatim.client';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AtlasService, BucketItemExistsError } from '../../../src/nest/atlas/atlas.service';
import { createTestUnitOfWork } from '../../helpers/test-uow';
// Direct construction over the shared test connection — no TestingModule (repo
// convention for DI-native service unit tests).
const atlas = new AtlasService(new DatabaseService(testDb));
let atlas: AtlasService;
function insertReservationEndpoint(
db: any,
@@ -72,9 +73,10 @@ function insertPlace(db: any, tripId: number, name: string, address: string | nu
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
}
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
atlas = new AtlasService(new DatabaseService(testDb), await createTestUnitOfWork(testDb));
});
beforeEach(() => {
@@ -538,7 +540,7 @@ describe('unmarkCountryVisited — tombstones', () => {
const before = await atlas.stats(user.id);
expect(before.countries.map((c: { code: string }) => c.code)).toContain('JP');
atlas.unmarkCountry(user.id, 'JP');
await atlas.unmarkCountry(user.id, 'JP');
const after = await atlas.stats(user.id);
expect(after.countries.map((c: { code: string }) => c.code)).not.toContain('JP');
@@ -553,10 +555,10 @@ describe('unmarkCountryVisited — tombstones', () => {
insertReservationEndpoint(testDb, reservation.id, 'from', 0, 50.9014, 4.4844);
insertReservationEndpoint(testDb, reservation.id, 'to', 1, 35.6762, 139.6503);
atlas.unmarkCountry(user.id, 'JP');
await atlas.unmarkCountry(user.id, 'JP');
expect((await atlas.stats(user.id)).countries.map((c: { code: string }) => c.code)).not.toContain('JP');
atlas.markCountry(user.id, 'JP');
await atlas.markCountry(user.id, 'JP');
expect((await atlas.stats(user.id)).countries.map((c: { code: string }) => c.code)).toContain('JP');
});
@@ -569,7 +571,7 @@ describe('unmarkCountryVisited — tombstones', () => {
insertReservationEndpoint(testDb, reservation.id, 'from', 0, 50.9014, 4.4844);
insertReservationEndpoint(testDb, reservation.id, 'to', 1, 35.6762, 139.6503);
atlas.unmarkCountry(user.id, 'JP');
await atlas.unmarkCountry(user.id, 'JP');
expect((await atlas.stats(user.id)).countries.map((c: { code: string }) => c.code)).not.toContain('JP');
insertPlace(testDb, trip.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
@@ -864,23 +866,23 @@ describe('getStats — extended', () => {
// ── getCountryPlaces ─────────────────────────────────────────────────────────
describe('getCountryPlaces', () => {
it('ATLAS-UNIT-013: returns empty result when user has no trips', () => {
it('ATLAS-UNIT-013: returns empty result when user has no trips', async () => {
const { user } = createUser(testDb);
const result = atlas.countryPlaces(user.id, 'FR');
const result = await atlas.countryPlaces(user.id, 'FR');
expect(result.places).toHaveLength(0);
expect(result.trips).toHaveLength(0);
expect(result.manually_marked).toBe(false);
});
it('ATLAS-UNIT-014: returns matching places when place address resolves to the requested country', () => {
it('ATLAS-UNIT-014: returns matching places when place address resolves to the requested country', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'France Trip' });
insertPlace(testDb, trip.id, 'Louvre', '75001 Paris, France');
insertPlace(testDb, trip.id, 'Berlin Wall', 'Bernauer Str., Berlin, Germany');
const result = atlas.countryPlaces(user.id, 'FR');
const result = await atlas.countryPlaces(user.id, 'FR');
expect(result.places).toHaveLength(1);
expect(result.places[0].name).toBe('Louvre');
@@ -888,23 +890,23 @@ describe('getCountryPlaces', () => {
expect(result.trips[0].id).toBe(trip.id);
});
it('ATLAS-UNIT-015: manually_marked is true when country is in visited_countries', () => {
it('ATLAS-UNIT-015: manually_marked is true when country is in visited_countries', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
createTrip(testDb, user.id, { title: 'Japan' });
const result = atlas.countryPlaces(user.id, 'JP');
const result = await atlas.countryPlaces(user.id, 'JP');
expect(result.manually_marked).toBe(true);
});
it('ATLAS-UNIT-016: place with coordinates resolves via bbox when address is absent', () => {
it('ATLAS-UNIT-016: place with coordinates resolves via bbox when address is absent', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Coord Trip' });
// Paris coordinates (48.85°N, 2.35°E) — falls inside FR bounding box
insertPlaceWithCoords(testDb, trip.id, 'Secret Paris Spot', 48.85, 2.35);
const result = atlas.countryPlaces(user.id, 'FR');
const result = await atlas.countryPlaces(user.id, 'FR');
expect(result.places).toHaveLength(1);
expect(result.places[0].name).toBe('Secret Paris Spot');
@@ -1181,7 +1183,7 @@ describe('unmarkRegionVisited — tombstones + country cascade', () => {
const before = await atlas.visitedRegions(user.id);
expect(before.regions['US']?.map((r: any) => r.code)).toContain('US-CA');
atlas.unmarkRegion(user.id, 'US-CA');
await atlas.unmarkRegion(user.id, 'US-CA');
const after = await atlas.visitedRegions(user.id);
expect(after.regions['US']?.map((r: any) => r.code) ?? []).not.toContain('US-CA');
@@ -1193,10 +1195,10 @@ describe('unmarkRegionVisited — tombstones + country cascade', () => {
insertPlaceWithCoords(testDb, trip.id, 'Golden Gate Park', 37.7694, -122.4862);
await primeRegionCache(user.id);
atlas.unmarkRegion(user.id, 'US-CA');
await atlas.unmarkRegion(user.id, 'US-CA');
expect((await atlas.visitedRegions(user.id)).regions['US']?.map((r: any) => r.code) ?? []).not.toContain('US-CA');
atlas.markRegion(user.id, 'US-CA', 'California', 'US');
await atlas.markRegion(user.id, 'US-CA', 'California', 'US');
expect((await atlas.visitedRegions(user.id)).regions['US']?.map((r: any) => r.code)).toContain('US-CA');
});
@@ -1208,12 +1210,12 @@ describe('unmarkRegionVisited — tombstones + country cascade', () => {
// unmarkCountryVisited's own tombstone tests above use flight-endpoint-derived
// countries rather than real places for the same reason.
const { user } = createUser(testDb);
atlas.markRegion(user.id, 'JP-13', 'Tokyo', 'JP'); // also auto-marks JP visited
await atlas.markRegion(user.id, 'JP-13', 'Tokyo', 'JP'); // also auto-marks JP visited
const beforeStats = await atlas.stats(user.id);
expect(beforeStats.countries.map((c: any) => c.code)).toContain('JP');
atlas.unmarkRegion(user.id, 'JP-13');
await atlas.unmarkRegion(user.id, 'JP-13');
const afterStats = await atlas.stats(user.id);
expect(afterStats.countries.map((c: any) => c.code)).not.toContain('JP');
@@ -1226,7 +1228,7 @@ describe('unmarkRegionVisited — tombstones + country cascade', () => {
insertPlaceWithCoords(testDb, trip.id, 'Philly hotel', 39.9527237, -75.1635262); // PA
await primeRegionCache(user.id);
atlas.unmarkRegion(user.id, 'US-MA');
await atlas.unmarkRegion(user.id, 'US-MA');
const stats = await atlas.stats(user.id);
expect(stats.countries.map((c: any) => c.code)).toContain('US');
@@ -1237,12 +1239,12 @@ describe('unmarkRegionVisited — tombstones + country cascade', () => {
it('ATLAS-SVC-028: re-marking a region whose country was cascade-hidden brings the country back too', async () => {
const { user } = createUser(testDb);
atlas.markRegion(user.id, 'JP-13', 'Tokyo', 'JP');
await atlas.markRegion(user.id, 'JP-13', 'Tokyo', 'JP');
atlas.unmarkRegion(user.id, 'JP-13');
await atlas.unmarkRegion(user.id, 'JP-13');
expect((await atlas.stats(user.id)).countries.map((c: any) => c.code)).not.toContain('JP');
atlas.markRegion(user.id, 'JP-13', 'Tokyo', 'JP');
await atlas.markRegion(user.id, 'JP-13', 'Tokyo', 'JP');
const stats = await atlas.stats(user.id);
expect(stats.countries.map((c: any) => c.code)).toContain('JP');
@@ -1263,13 +1265,13 @@ describe('getCountryFromCoords coordinate pin', () => {
// ── Quirks fixed after the DI fold (trailing fix(server) commit) ─────────────
describe('atlas quirk fixes', () => {
it('ATLAS-SVC-031: countryPlaces reports manually_marked for a trip-less user', () => {
it('ATLAS-SVC-031: countryPlaces reports manually_marked for a trip-less user', async () => {
// The legacy early return hardcoded manually_marked: false, so a user who
// marked a country visited before creating any trip saw it as unmarked.
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
const result = atlas.countryPlaces(user.id, 'JP');
const result = await atlas.countryPlaces(user.id, 'JP');
// status rides along since #1048 — a manual mark is a visit even with no
// trips; marked_source since #2279 says who put the mark there, and a row
@@ -1281,52 +1283,52 @@ describe('atlas quirk fixes', () => {
marked_source: 'manual',
status: 'visited',
});
const unmarked = atlas.countryPlaces(user.id, 'FR');
const unmarked = await atlas.countryPlaces(user.id, 'FR');
expect(unmarked.manually_marked).toBe(false);
expect(unmarked.marked_source).toBeNull();
});
it('ATLAS-SVC-032: updateBucketItem persists lat/lng of exactly 0 (equator/prime meridian)', () => {
it('ATLAS-SVC-032: updateBucketItem persists lat/lng of exactly 0 (equator/prime meridian)', async () => {
const { user } = createUser(testDb);
const item = atlas.createBucketItem(user.id, { name: 'Null Island', lat: 10, lng: 10 }) as { id: number };
const item = await atlas.createBucketItem(user.id, { name: 'Null Island', lat: 10, lng: 10 }) as { id: number };
const updated = atlas.updateBucketItem(user.id, item.id, { lat: 0, lng: 0 }) as { lat: number; lng: number };
const updated = await atlas.updateBucketItem(user.id, item.id, { lat: 0, lng: 0 }) as { lat: number; lng: number };
// The legacy `|| null` binding read 0 as "clear" and wrote NULL.
expect(updated.lat).toBe(0);
expect(updated.lng).toBe(0);
});
it('ATLAS-SVC-033: updateBucketItem persists an explicit empty-string notes value', () => {
it('ATLAS-SVC-033: updateBucketItem persists an explicit empty-string notes value', async () => {
const { user } = createUser(testDb);
const item = atlas.createBucketItem(user.id, { name: 'Kyoto', notes: 'old notes' }) as { id: number };
const item = await atlas.createBucketItem(user.id, { name: 'Kyoto', notes: 'old notes' }) as { id: number };
const updated = atlas.updateBucketItem(user.id, item.id, { notes: '' }) as { notes: string | null };
const updated = await atlas.updateBucketItem(user.id, item.id, { notes: '' }) as { notes: string | null };
// The legacy `|| null` binding collapsed '' to NULL.
expect(updated.notes).toBe('');
});
it("ATLAS-SVC-034: bucket update/delete cannot touch another user's row (user-scoped SQL)", () => {
it("ATLAS-SVC-034: bucket update/delete cannot touch another user's row (user-scoped SQL)", async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const item = atlas.createBucketItem(user.id, { name: 'Mine' }) as { id: number; name: string };
const item = await atlas.createBucketItem(user.id, { name: 'Mine' }) as { id: number; name: string };
expect(atlas.updateBucketItem(other.id, item.id, { name: 'Stolen' })).toBeNull();
expect(atlas.deleteBucketItem(other.id, item.id)).toBe(false);
expect(await atlas.updateBucketItem(other.id, item.id, { name: 'Stolen' })).toBeNull();
expect(await atlas.deleteBucketItem(other.id, item.id)).toBe(false);
const row = testDb.prepare('SELECT name FROM bucket_list WHERE id = ?').get(item.id) as { name: string };
expect(row.name).toBe('Mine');
});
it('ATLAS-SVC-035: unmarkRegion is atomic — a failure mid-flow rolls the region delete back', () => {
it('ATLAS-SVC-035: unmarkRegion is atomic — a failure mid-flow rolls the region delete back', async () => {
const { user } = createUser(testDb);
atlas.markRegion(user.id, 'JP-13', 'Tokyo', 'JP');
await atlas.markRegion(user.id, 'JP-13', 'Tokyo', 'JP');
// Force the tombstone insert (statement 2 of the flow) to fail by hiding
// the hidden_regions table; the visited_regions DELETE must roll back.
testDb.exec('ALTER TABLE hidden_regions RENAME TO hidden_regions_gone');
try {
expect(() => atlas.unmarkRegion(user.id, 'JP-13')).toThrow();
await expect(atlas.unmarkRegion(user.id, 'JP-13')).rejects.toThrow();
} finally {
testDb.exec('ALTER TABLE hidden_regions_gone RENAME TO hidden_regions');
}
@@ -1337,12 +1339,12 @@ describe('atlas quirk fixes', () => {
expect(row).toBeDefined();
});
it('ATLAS-SVC-036: markCountry is atomic — a failure on the tombstone lift rolls the insert back', () => {
it('ATLAS-SVC-036: markCountry is atomic — a failure on the tombstone lift rolls the insert back', async () => {
const { user } = createUser(testDb);
testDb.exec('ALTER TABLE hidden_countries RENAME TO hidden_countries_gone');
try {
expect(() => atlas.markCountry(user.id, 'DE')).toThrow();
await expect(atlas.markCountry(user.id, 'DE')).rejects.toThrow();
} finally {
testDb.exec('ALTER TABLE hidden_countries_gone RENAME TO hidden_countries');
}
@@ -1365,99 +1367,99 @@ describe('bucket-list duplicates (#1898)', () => {
const countRows = (userId: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM bucket_list WHERE user_id = ?').get(userId) as { n: number }).n;
it('ATLAS-SVC-037: adding the same wish twice is refused and writes no second row', () => {
it('ATLAS-SVC-037: adding the same wish twice is refused and writes no second row', async () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP' });
await atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP' });
expect(() => atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP' })).toThrow(BucketItemExistsError);
await expect(atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP' })).rejects.toThrow(BucketItemExistsError);
expect(countRows(user.id)).toBe(1);
});
it('ATLAS-SVC-038: the same place with another target date is a separate entry', () => {
it('ATLAS-SVC-038: the same place with another target date is a separate entry', async () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP' });
await atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP' });
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2027-05' });
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2028-09' });
await atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2027-05' });
await atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2028-09' });
expect(countRows(user.id)).toBe(3);
// ...but each of those dates only once.
expect(() => atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2027-05' })).toThrow(
await expect(atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2027-05' })).rejects.toThrow(
BucketItemExistsError,
);
});
it('ATLAS-SVC-039: the name matches case- and whitespace-insensitively', () => {
it('ATLAS-SVC-039: the name matches case- and whitespace-insensitively', async () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Kyoto' });
await atlas.createBucketItem(user.id, { name: 'Kyoto' });
expect(() => atlas.createBucketItem(user.id, { name: ' kyoto ' })).toThrow(BucketItemExistsError);
await expect(atlas.createBucketItem(user.id, { name: ' kyoto ' })).rejects.toThrow(BucketItemExistsError);
expect(countRows(user.id)).toBe(1);
});
it('ATLAS-SVC-040: an empty string and NULL are the same "not set"', () => {
it('ATLAS-SVC-040: an empty string and NULL are the same "not set"', async () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Lisbon', country_code: '', target_date: '' });
await atlas.createBucketItem(user.id, { name: 'Lisbon', country_code: '', target_date: '' });
// The forms send '' where the map dialogs send null — both must land on the
// same identity, and the row itself is stored normalised.
const row = testDb.prepare('SELECT country_code, target_date FROM bucket_list WHERE user_id = ?').get(user.id);
expect(row).toEqual({ country_code: null, target_date: null });
expect(() => atlas.createBucketItem(user.id, { name: 'Lisbon', country_code: null, target_date: null })).toThrow(
await expect(atlas.createBucketItem(user.id, { name: 'Lisbon', country_code: null, target_date: null })).rejects.toThrow(
BucketItemExistsError,
);
});
it('ATLAS-SVC-041: the same name in another country, at other coordinates or for another user still goes through', () => {
it('ATLAS-SVC-041: the same name in another country, at other coordinates or for another user still goes through', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 });
await atlas.createBucketItem(user.id, { name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 });
atlas.createBucketItem(user.id, { name: 'Altstadt', country_code: 'AT', lat: 48.13, lng: 11.57 });
atlas.createBucketItem(user.id, { name: 'Altstadt', country_code: 'DE', lat: 50.94, lng: 6.96 });
atlas.createBucketItem(other.id, { name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 });
await atlas.createBucketItem(user.id, { name: 'Altstadt', country_code: 'AT', lat: 48.13, lng: 11.57 });
await atlas.createBucketItem(user.id, { name: 'Altstadt', country_code: 'DE', lat: 50.94, lng: 6.96 });
await atlas.createBucketItem(other.id, { name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 });
expect(countRows(user.id)).toBe(3);
expect(countRows(other.id)).toBe(1);
});
it('ATLAS-SVC-042: a coordinate-less wish does not collide with the same name pinned to a place', () => {
it('ATLAS-SVC-042: a coordinate-less wish does not collide with the same name pinned to a place', async () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Kyoto', country_code: 'JP' });
await atlas.createBucketItem(user.id, { name: 'Kyoto', country_code: 'JP' });
atlas.createBucketItem(user.id, { name: 'Kyoto', country_code: 'JP', lat: 35.01, lng: 135.76 });
await atlas.createBucketItem(user.id, { name: 'Kyoto', country_code: 'JP', lat: 35.01, lng: 135.76 });
expect(countRows(user.id)).toBe(2);
});
it('ATLAS-SVC-043: an update may not move a row onto another wish', () => {
it('ATLAS-SVC-043: an update may not move a row onto another wish', async () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2027-05' });
const second = atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2028-09' }) as {
await atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2027-05' });
const second = await atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2028-09' }) as {
id: number;
target_date: string;
};
expect(() => atlas.updateBucketItem(user.id, second.id, { target_date: '2027-05' })).toThrow(BucketItemExistsError);
await expect(atlas.updateBucketItem(user.id, second.id, { target_date: '2027-05' })).rejects.toThrow(BucketItemExistsError);
const row = testDb.prepare('SELECT target_date FROM bucket_list WHERE id = ?').get(second.id) as {
target_date: string;
};
expect(row.target_date).toBe('2028-09');
});
it('ATLAS-SVC-044: an update of a row against itself is not a collision', () => {
it('ATLAS-SVC-044: an update of a row against itself is not a collision', async () => {
const { user } = createUser(testDb);
const item = atlas.createBucketItem(user.id, { name: 'Kyoto', country_code: 'JP', target_date: '2027-05' }) as {
const item = await atlas.createBucketItem(user.id, { name: 'Kyoto', country_code: 'JP', target_date: '2027-05' }) as {
id: number;
};
// Same values again, plus a notes-only edit: neither may trip the guard.
expect(atlas.updateBucketItem(user.id, item.id, { name: 'Kyoto', target_date: '2027-05' })).toBeTruthy();
const updated = atlas.updateBucketItem(user.id, item.id, { notes: 'temples' }) as { notes: string };
expect(await atlas.updateBucketItem(user.id, item.id, { name: 'Kyoto', target_date: '2027-05' })).toBeTruthy();
const updated = await atlas.updateBucketItem(user.id, item.id, { notes: 'temples' }) as { notes: string };
expect(updated.notes).toBe('temples');
});
it('ATLAS-SVC-045: a legacy duplicate already in the table stays readable and deletable', () => {
it('ATLAS-SVC-045: a legacy duplicate already in the table stays readable and deletable', async () => {
const { user } = createUser(testDb);
// Rows written before the guard existed are left alone on purpose — no
// migration deletes user data for this.
@@ -1465,8 +1467,8 @@ describe('bucket-list duplicates (#1898)', () => {
insert.run(user.id, 'Japan', 'JP');
const legacy = insert.run(user.id, 'Japan', 'JP');
expect(atlas.bucketList(user.id)).toHaveLength(2);
expect(atlas.deleteBucketItem(user.id, Number(legacy.lastInsertRowid))).toBe(true);
expect(await atlas.bucketList(user.id)).toHaveLength(2);
expect(await atlas.deleteBucketItem(user.id, Number(legacy.lastInsertRowid))).toBe(true);
expect(countRows(user.id)).toBe(1);
});
});
@@ -1599,7 +1601,7 @@ describe('getStats — visited vs planned vs idea (#1048)', () => {
const before = await atlas.stats(user.id);
expect(before.countries.find((c: any) => c.code === 'JP')).toMatchObject({ status: 'planned' });
atlas.unmarkCountry(user.id, 'JP');
await atlas.unmarkCountry(user.id, 'JP');
const after = await atlas.stats(user.id);
expect(after.countries.map((c: any) => c.code)).not.toContain('JP');
@@ -1765,19 +1767,19 @@ describe('getCountryPlaces — status (#1048)', () => {
insertPlace(testDb, past.id, 'Louvre', '75001 Paris, France');
insertPlace(testDb, future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
expect(atlas.countryPlaces(user.id, 'FR').status).toBe('visited');
expect(atlas.countryPlaces(user.id, 'JP').status).toBe('planned');
expect((await atlas.countryPlaces(user.id, 'FR')).status).toBe('visited');
expect((await atlas.countryPlaces(user.id, 'JP')).status).toBe('planned');
// A country with no trip and no manual mark has nothing behind it at all.
expect(atlas.countryPlaces(user.id, 'BR').status).toBe('idea');
expect((await atlas.countryPlaces(user.id, 'BR')).status).toBe('idea');
});
it('ATLAS-UNIT-041: a manual mark makes the sheet visited even for a future-only country', async () => {
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Tokyo soon', start_date: FUTURE_START, end_date: FUTURE_END });
insertPlace(testDb, future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
atlas.markCountry(user.id, 'JP');
await atlas.markCountry(user.id, 'JP');
const result = atlas.countryPlaces(user.id, 'JP');
const result = await atlas.countryPlaces(user.id, 'JP');
expect(result.manually_marked).toBe(true);
expect(result.status).toBe('visited');
@@ -1815,7 +1817,7 @@ describe('getVisitedRegions — status (#1048)', () => {
expect((await atlas.visitedRegions(user.id)).regions['FR'][0].status).toBe('planned');
atlas.markRegion(user.id, 'FR-75', 'Île-de-France', 'FR');
await atlas.markRegion(user.id, 'FR-75', 'Île-de-France', 'FR');
const after = await atlas.visitedRegions(user.id);
// Still one entry — the manual mark upgrades the derived region rather than duplicating it.
@@ -1827,19 +1829,19 @@ describe('getVisitedRegions — status (#1048)', () => {
// ── lastTrip (#1367, feeds GET /api/v1/stats) ───────────────────────────────
describe('lastTrip', () => {
it('ATLAS-LAST-001: returns null when the user has no trips at all', () => {
it('ATLAS-LAST-001: returns null when the user has no trips at all', async () => {
const { user } = createUser(testDb);
expect(atlas.lastTrip(user.id)).toBeNull();
expect(await atlas.lastTrip(user.id)).toBeNull();
});
it('ATLAS-LAST-002: ignores trips that have not started yet', () => {
it('ATLAS-LAST-002: ignores trips that have not started yet', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Next spring', start_date: FUTURE_START, end_date: FUTURE_END });
// A booked trip is not one you have been on, so there is no "last trip" here.
expect(atlas.lastTrip(user.id)).toBeNull();
expect(await atlas.lastTrip(user.id)).toBeNull();
});
it('ATLAS-LAST-003: picks the most recent started trip and reports its countries', () => {
it('ATLAS-LAST-003: picks the most recent started trip and reports its countries', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Older', start_date: isoOffsetDays(-90), end_date: isoOffsetDays(-80) });
const recent = createTrip(testDb, user.id, { title: 'Recent', start_date: PAST_START, end_date: PAST_END });
@@ -1850,13 +1852,13 @@ describe('lastTrip', () => {
.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, 'fr', 'FR-75', 'Ile-de-France');
const last = atlas.lastTrip(user.id);
const last = await atlas.lastTrip(user.id);
expect(last).toMatchObject({ title: 'Recent', start_date: PAST_START, end_date: PAST_END });
// Upper-cased on the way out — place_regions is not consistent about it.
expect(last!.countries).toEqual(['FR']);
});
it('ATLAS-LAST-004: orders a multi-country trip by how many places sit in each', () => {
it('ATLAS-LAST-004: orders a multi-country trip by how many places sit in each', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Interrail', start_date: PAST_START, end_date: PAST_END });
const stamp = testDb.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
@@ -1865,29 +1867,29 @@ describe('lastTrip', () => {
stamp.run(insertPlaceWithCoords(testDb, trip.id, 'Brno', 49.19, 16.61).id, 'CZ', 'CZ-64', 'Brno');
// Two Czech stops beat one Austrian, so CZ leads and becomes `country`.
expect(atlas.lastTrip(user.id)!.countries).toEqual(['CZ', 'AT']);
expect((await atlas.lastTrip(user.id))!.countries).toEqual(['CZ', 'AT']);
});
it('ATLAS-LAST-005: a trip whose places were never geocoded reports no countries, not a wrong one', () => {
it('ATLAS-LAST-005: a trip whose places were never geocoded reports no countries, not a wrong one', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Roadtrip', start_date: PAST_START, end_date: PAST_END });
insertPlace(testDb, trip.id, 'That diner off the highway');
expect(atlas.lastTrip(user.id)!.countries).toEqual([]);
expect((await atlas.lastTrip(user.id))!.countries).toEqual([]);
});
it('ATLAS-LAST-006: a trip shared by membership counts as the caller own last trip', () => {
it('ATLAS-LAST-006: a trip shared by membership counts as the caller own last trip', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared', start_date: PAST_START, end_date: PAST_END });
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
expect(atlas.lastTrip(member.id)?.title).toBe('Shared');
expect((await atlas.lastTrip(member.id))?.title).toBe('Shared');
});
it('ATLAS-LAST-007: two trips ending the same day resolve deterministically', () => {
it('ATLAS-LAST-007: two trips ending the same day resolve deterministically', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'First', start_date: PAST_START, end_date: PAST_END });
createTrip(testDb, user.id, { title: 'Second', start_date: PAST_START, end_date: PAST_END });
// The id is the tie-break, so the answer cannot depend on storage order.
expect(atlas.lastTrip(user.id)?.title).toBe('Second');
expect((await atlas.lastTrip(user.id))?.title).toBe('Second');
});
});
@@ -365,10 +365,10 @@ describe('AuthController (authenticated)', () => {
});
it('update-settings + get-settings map errors, else return their payloads', async () => {
expect(thrown(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'Bad' } });
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'Bad' } });
expect(await 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' } });
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) }).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
expect(await ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) }).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
});
// travel-stats left with getTravelStats; it is covered by
@@ -1114,10 +1114,10 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
// ── Atlas ────────────────────────────────────────────────────────────────────
describe('DawarichSuggestionsService — the Atlas hand-off', () => {
it('DAWARICH-SUG-028: acceptAtlasCountries marks every code through the Atlas with the dawarich source', () => {
it('DAWARICH-SUG-028: acceptAtlasCountries marks every code through the Atlas with the dawarich source', async () => {
const { user } = createUser(testDb);
const marked = svc.acceptAtlasCountries(user.id, ['de', ' fr ', 'us']);
const marked = await svc.acceptAtlasCountries(user.id, ['de', ' fr ', 'us']);
expect(marked).toBe(3);
expect(atlasStub.markCountry).toHaveBeenCalledTimes(3);
@@ -1126,19 +1126,19 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
expect(atlasStub.markCountry).toHaveBeenNthCalledWith(3, user.id, 'US', 'dawarich');
});
it('DAWARICH-SUG-042: the count is what was added, not what was asked for', () => {
it('DAWARICH-SUG-042: the count is what was added, not what was asked for', async () => {
const { user } = createUser(testDb);
// A country already on the map is an INSERT OR IGNORE that changes nothing,
// and "2 countries added" for one country is a toast that lies.
atlasStub.markCountry.mockImplementation((_userId: number, code: string) => code === 'FR');
expect(svc.acceptAtlasCountries(user.id, ['de', 'fr'])).toBe(1);
expect(await svc.acceptAtlasCountries(user.id, ['de', 'fr'])).toBe(1);
});
it('DAWARICH-SUG-029: anything that is not a two-letter code is skipped rather than written', () => {
it('DAWARICH-SUG-029: anything that is not a two-letter code is skipped rather than written', async () => {
const { user } = createUser(testDb);
const marked = svc.acceptAtlasCountries(user.id, ['XYZ', 'D', '', ' ', 'D1', 'at']);
const marked = await svc.acceptAtlasCountries(user.id, ['XYZ', 'D', '', ' ', 'D1', 'at']);
expect(marked).toBe(1);
expect(atlasStub.markCountry).toHaveBeenCalledTimes(1);
@@ -353,9 +353,9 @@ describe('DawarichController suggestion routes', () => {
expect(clearBucketVisit).toHaveBeenCalledWith(7, 4);
});
it('DAWARICH-CTRL-047: POST atlas/accept wraps the marked count', () => {
it('DAWARICH-CTRL-047: POST atlas/accept wraps the marked count', async () => {
const acceptAtlasCountries = vi.fn().mockReturnValue(2);
expect(makeController({ suggestions: { acceptAtlasCountries } })
expect(await makeController({ suggestions: { acceptAtlasCountries } })
.acceptAtlasCountries(user, { countryCodes: ['DE', 'FR'] })).toEqual({ marked: 2 });
expect(acceptAtlasCountries).toHaveBeenCalledWith(7, ['DE', 'FR']);
});
@@ -41,17 +41,19 @@ import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.s
import { JourneyShareService } from '../../../src/nest/journey/journey-share.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { db as dbConn } from '../../../src/db/database';
import { createTestUnitOfWork } from '../../helpers/test-uow';
const dbs = new DatabaseService(dbConn);
const svc = new JourneyShareService(
dbs,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)),
new SettingsService(dbs),
);
let svc: JourneyShareService;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new JourneyShareService(
dbs,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)),
new SettingsService(dbs, await createTestUnitOfWork(testDb)),
);
});
beforeEach(() => {
@@ -412,12 +414,12 @@ describe('validateShareTokenForAsset', () => {
});
describe('getPublicJourney', () => {
it('JOURNEY-SHARE-016: returns null for invalid token', () => {
const result = svc.getPublicJourney('invalid-token');
it('JOURNEY-SHARE-016: returns null for invalid token', async () => {
const result = await svc.getPublicJourney('invalid-token');
expect(result).toBeNull();
});
it('JOURNEY-SHARE-017: returns journey data with entries, stats, and permissions', () => {
it('JOURNEY-SHARE-017: returns journey data with entries, stats, and permissions', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, {
title: 'Japan 2026',
@@ -442,7 +444,7 @@ describe('getPublicJourney', () => {
share_map: false,
});
const result = svc.getPublicJourney(token);
const result = await svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.journey.title).toBe('Japan 2026');
@@ -460,7 +462,7 @@ describe('getPublicJourney', () => {
expect(result!.permissions.share_map).toBe(false);
});
it('JOURNEY-SHARE-017b: a field the owner switched off is switched off for the reader too', () => {
it('JOURNEY-SHARE-017b: a field the owner switched off is switched off for the reader too', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
testDb.prepare('UPDATE journeys SET show_mood = 0, show_weather = 0 WHERE id = ?').run(journey.id);
@@ -469,12 +471,12 @@ describe('getPublicJourney', () => {
share_timeline: true, share_gallery: false, share_map: true,
});
const result = svc.getPublicJourney(token)!;
const result = (await svc.getPublicJourney(token))!;
expect(result.journey).toMatchObject({ show_mood: 0, show_weather: 0, show_verdict: 1 });
});
it('JOURNEY-SHARE-018: excludes skeleton entries from public view', () => {
it('JOURNEY-SHARE-018: excludes skeleton entries from public view', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
createJourneyEntry(testDb, journey.id, user.id, {
@@ -489,14 +491,14 @@ describe('getPublicJourney', () => {
});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.getPublicJourney(token);
const result = await svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.entries).toHaveLength(1);
expect(result!.entries[0].title).toBe('Visible Entry');
});
it('JOURNEY-SHARE-019: enriches entries with parsed tags and photos', () => {
it('JOURNEY-SHARE-019: enriches entries with parsed tags and photos', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
@@ -510,7 +512,7 @@ describe('getPublicJourney', () => {
insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.getPublicJourney(token);
const result = await svc.getPublicJourney(token);
expect(result).not.toBeNull();
const enriched = result!.entries[0];
@@ -518,12 +520,12 @@ describe('getPublicJourney', () => {
expect(enriched.photos).toHaveLength(2);
});
it('JOURNEY-SHARE-020: returns empty entries array for journey with no entries', () => {
it('JOURNEY-SHARE-020: returns empty entries array for journey with no entries', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Empty Journey' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.getPublicJourney(token);
const result = await svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.entries).toEqual([]);
@@ -532,7 +534,7 @@ describe('getPublicJourney', () => {
expect(result!.stats.places).toBe(0);
});
it('JOURNEY-SHARE-021: withholds timeline, gallery and GPS when all flags are off', () => {
it('JOURNEY-SHARE-021: withholds timeline, gallery and GPS when all flags are off', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Secret' });
const entry = createJourneyEntry(testDb, journey.id, user.id, {
@@ -544,13 +546,13 @@ describe('getPublicJourney', () => {
share_timeline: false, share_gallery: false, share_map: false,
});
const result = svc.getPublicJourney(token)!;
const result = (await svc.getPublicJourney(token))!;
expect(result.entries).toEqual([]); // no timeline / story / GPS leaked
expect(result.gallery).toEqual([]); // no gallery leaked
expect(result.stats.entries).toBe(1); // counts stay accurate
});
it('JOURNEY-SHARE-022: shares the timeline but strips GPS when the map flag is off', () => {
it('JOURNEY-SHARE-022: shares the timeline but strips GPS when the map flag is off', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
@@ -561,7 +563,7 @@ describe('getPublicJourney', () => {
share_timeline: true, share_gallery: true, share_map: false,
});
const result = svc.getPublicJourney(token)!;
const result = (await svc.getPublicJourney(token))!;
expect(result.entries).toHaveLength(1);
const e = result.entries[0] as Record<string, unknown>;
expect(e.story).toBe('notes'); // narrative present
@@ -569,7 +571,7 @@ describe('getPublicJourney', () => {
expect(e.location_lng).toBeNull();
});
it('JOURNEY-SHARE-023: map-only share exposes coordinates but not the story', () => {
it('JOURNEY-SHARE-023: map-only share exposes coordinates but not the story', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
@@ -580,7 +582,7 @@ describe('getPublicJourney', () => {
share_timeline: false, share_gallery: false, share_map: true,
});
const result = svc.getPublicJourney(token)!;
const result = (await svc.getPublicJourney(token))!;
expect(result.entries).toHaveLength(1);
const e = result.entries[0] as Record<string, unknown>;
expect(e.location_lat).toBe(48.8566); // coords for the map
@@ -590,7 +592,7 @@ describe('getPublicJourney', () => {
// #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', () => {
it('JOURNEY-SHARE-025: withholds photo capture coordinates when the map flag is off', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
@@ -604,7 +606,7 @@ describe('getPublicJourney', () => {
share_timeline: true, share_gallery: true, share_map: false,
});
const result = svc.getPublicJourney(token)!;
const result = (await svc.getPublicJourney(token))!;
const gallery = result.gallery as Record<string, unknown>[];
expect(gallery).toHaveLength(1);
expect(gallery[0].lat).toBeNull();
@@ -617,7 +619,7 @@ describe('getPublicJourney', () => {
expect(inline[0].lng).toBeNull();
});
it('JOURNEY-SHARE-026: hands out photo coordinates once the map is shared', () => {
it('JOURNEY-SHARE-026: hands out photo coordinates once the map is shared', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
@@ -631,12 +633,12 @@ describe('getPublicJourney', () => {
share_timeline: true, share_gallery: true, share_map: true,
});
const gallery = svc.getPublicJourney(token)!.gallery as Record<string, unknown>[];
const gallery = (await 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', () => {
it('JOURNEY-SHARE-024: strips inline entry photos (and their asset metadata) when the gallery is off', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
@@ -647,7 +649,7 @@ describe('getPublicJourney', () => {
share_timeline: true, share_gallery: false, share_map: true,
});
const result = svc.getPublicJourney(token)!;
const result = (await svc.getPublicJourney(token))!;
expect(result.gallery).toEqual([]); // gallery array withheld
expect(result.entries).toHaveLength(1);
expect((result.entries[0] as Record<string, unknown>).photos).toEqual([]); // inline photos withheld too
@@ -655,7 +657,7 @@ describe('getPublicJourney', () => {
// #2200: the reader of a shared journey gets the same chronology as the owner,
// so the public gallery cannot fall back to upload order.
it('JOURNEY-SHARE-031: the public gallery reads in capture order, not upload order', () => {
it('JOURNEY-SHARE-031: the public gallery reads in capture order, not upload order', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const day1 = createJourneyEntry(testDb, journey.id, user.id, {
@@ -673,19 +675,19 @@ describe('getPublicJourney', () => {
share_timeline: true, share_gallery: true, share_map: true,
});
const gallery = svc.getPublicJourney(token)!.gallery as Record<string, unknown>[];
const gallery = (await svc.getPublicJourney(token))!.gallery as Record<string, unknown>[];
expect(gallery.map(p => p.file_path)).toEqual(['/photos/day1.jpg', '/photos/day2.jpg']);
});
it('JOURNEY-SHARE-030: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', () => {
it('JOURNEY-SHARE-030: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true });
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('');
expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe('');
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('instance-key');
expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe('instance-key');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id);
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('owner-key');
expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe('owner-key');
});
});
@@ -442,9 +442,9 @@ describe('JourneyPublicController', () => {
expect((Reflect.getMetadata('__guards__', JourneyPublicController) ?? [])[0]).toBe(AddonGuard);
});
it('GET /:token 404 / json', () => {
expect(thrown(() => new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).get('tok'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>), storageStub).get('tok')).toEqual({ id: 1 });
it('GET /:token 404 / json', async () => {
expect(await thrownAsync(() => new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub).get('tok'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(await new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>), storageStub).get('tok')).toEqual({ id: 1 });
});
it('photo proxy 404 on invalid token, else streams', async () => {
+17 -17
View File
@@ -498,48 +498,48 @@ function keys(opts: { google?: string; amap?: string }) {
}
describe('MapsService.keyedProvider', () => {
it('AMAP-070: auto keeps Google when a Google key is configured', () => {
it('AMAP-070: auto keeps Google when a Google key is configured', async () => {
keys({ google: 'gkey', amap: 'akey' });
expect(svc.keyedProvider(1)).toMatchObject({ id: 'google', key: 'gkey', source: 'user-row' });
expect(svc.resolvePlacesProvider(1)).toBeNull();
expect(await svc.keyedProvider(1)).toMatchObject({ id: 'google', key: 'gkey', source: 'user-row' });
expect(await svc.resolvePlacesProvider(1)).toBeNull();
});
it('AMAP-071: auto falls to Amap only when there is no Google key', () => {
it('AMAP-071: auto falls to Amap only when there is no Google key', async () => {
keys({ amap: 'akey' });
expect(svc.keyedProvider(1)?.id).toBe('amap');
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
expect((await svc.keyedProvider(1))?.id).toBe('amap');
expect(await svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
});
it('AMAP-072: auto with no key at all means the OpenStreetMap stack', () => {
it('AMAP-072: auto with no key at all means the OpenStreetMap stack', async () => {
keys({});
expect(svc.keyedProvider(1)).toBeNull();
expect(svc.resolvePlacesProvider(1)).toBeNull();
expect(await svc.keyedProvider(1)).toBeNull();
expect(await svc.resolvePlacesProvider(1)).toBeNull();
});
it('AMAP-073: an explicit amap choice wins over a configured Google key', () => {
it('AMAP-073: an explicit amap choice wins over a configured Google key', async () => {
mockProviderGet.mockReturnValue({ value: 'amap' });
keys({ google: 'gkey', amap: 'akey' });
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
expect(await svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
});
it('AMAP-074: an explicit google choice never silently uses Amap instead', () => {
it('AMAP-074: an explicit google choice never silently uses Amap instead', async () => {
mockProviderGet.mockReturnValue({ value: 'google' });
keys({ amap: 'akey' });
// Misconfigured means "answer with OSM", not "bill somebody else's provider".
expect(svc.keyedProvider(1)).toBeNull();
expect(await svc.keyedProvider(1)).toBeNull();
});
it('AMAP-075: openstreetmap ignores both keys', () => {
it('AMAP-075: openstreetmap ignores both keys', async () => {
mockProviderGet.mockReturnValue({ value: 'openstreetmap' });
keys({ google: 'gkey', amap: 'akey' });
expect(svc.keyedProvider(1)).toBeNull();
expect(await svc.keyedProvider(1)).toBeNull();
});
it('AMAP-076: a hand-edited nonsense value degrades to auto instead of failing', () => {
it('AMAP-076: a hand-edited nonsense value degrades to auto instead of failing', async () => {
mockProviderGet.mockReturnValue({ value: 'not-a-provider' });
keys({ google: 'gkey' });
expect(svc.placesProviderChoice()).toBe('auto');
expect(svc.keyedProvider(1)?.id).toBe('google');
expect((await svc.keyedProvider(1))?.id).toBe('google');
});
it('AMAP-077: an Amap place stays with Amap even while Google is selected', async () => {
+12 -12
View File
@@ -480,32 +480,32 @@ describe('resolveMapsKey', () => {
else process.env.PLACES_API_KEY = ORIGINAL_PLACES_KEY;
});
it('MAPS-015: returns the caller own row key when nothing above it is set', () => {
it('MAPS-015: returns the caller own row key when nothing above it is set', async () => {
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
expect(await svc.resolveMapsKey(1)).toEqual({ key: 'user-api-key', source: 'user-row' });
expect(await 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)', () => {
it('MAPS-016: the instance-wide key wins over the caller own row (#1939)', async () => {
mockInstanceGet.mockReturnValueOnce({ value: 'instance-api-key' });
mockDbGet.mockReturnValueOnce({ maps_api_key: 'user-api-key' });
expect(svc.resolveMapsKey(1)).toEqual({ key: 'instance-api-key', source: 'instance' });
expect(await svc.resolveMapsKey(1)).toEqual({ key: 'instance-api-key', source: 'instance' });
});
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-017: returns null with no source when nothing is set anywhere', async () => {
expect(await svc.resolveMapsKey(1)).toEqual({ key: null, source: null });
expect(await svc.getMapsKey(1)).toBeNull();
});
it('MAPS-017b: the operator env key wins and the database is never asked', () => {
it('MAPS-017b: the operator env key wins and the database is never asked', async () => {
process.env.PLACES_API_KEY = 'operator-key';
expect(svc.resolveMapsKey(1)).toEqual({ key: 'operator-key', source: 'operator-env' });
expect(await 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);
it("MAPS-017c: never reads another user's row — the admin fallback is gone (#1939)", async () => {
await 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.
@@ -1292,7 +1292,7 @@ describe('enrichImportedPlaces', () => {
});
it('PLACE-SVC-059 — no-ops for an empty batch without touching the provider', async () => {
const getMapsKey = vi.fn(() => 'key');
const getMapsKey = vi.fn(() => Promise.resolve('key'));
await (await enrichSvc({ getMapsKey })).enrichImportedPlaces('1', 1, []);
expect(getMapsKey).not.toHaveBeenCalled();
});
@@ -33,7 +33,7 @@ const req = (userId: number | null = 7) =>
apiToken: FULL_GRANT,
}) as Request;
const travel = (o: Partial<ReturnType<AtlasService['getTravelStats']>> = {}) => ({
const travel = (o: Partial<Awaited<ReturnType<AtlasService['getTravelStats']>>> = {}) => ({
countries: ['JP', 'IT'],
cities: ['tokyo', 'kyoto', 'rome'],
coords: [],
@@ -51,8 +51,8 @@ function ctl(atlas: Partial<AtlasService> = {}, rl = new RateLimitService()) {
);
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
try { await fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
@@ -61,8 +61,8 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
}
describe('PublicStatsController', () => {
it('PUBSTATS-001: reports counts, not the arrays behind them', () => {
expect(ctl().stats(req())).toEqual({
it('PUBSTATS-001: reports counts, not the arrays behind them', async () => {
expect(await ctl().stats(req())).toEqual({
total_trips: 4,
total_countries: 2,
total_cities: 3,
@@ -73,19 +73,19 @@ describe('PublicStatsController', () => {
});
});
it('PUBSTATS-002: passes the authenticated user through to the service', () => {
it('PUBSTATS-002: passes the authenticated user through to the service', async () => {
const getTravelStats = vi.fn(() => travel());
const lastTrip = vi.fn(() => null);
ctl({ getTravelStats, lastTrip } as unknown as Partial<AtlasService>).stats(req(42));
await ctl({ getTravelStats, lastTrip } as unknown as Partial<AtlasService>).stats(req(42));
expect(getTravelStats).toHaveBeenCalledWith(42);
expect(lastTrip).toHaveBeenCalledWith(42);
});
it('PUBSTATS-003: last_trip carries the dominant country as the head of the list', () => {
it('PUBSTATS-003: last_trip carries the dominant country as the head of the list', async () => {
const lastTrip = vi.fn(() => ({
title: 'Interrail', start_date: '2026-03-01', end_date: '2026-03-12', countries: ['CZ', 'AT'],
}));
const out = ctl({ lastTrip } as unknown as Partial<AtlasService>).stats(req());
const out = await ctl({ lastTrip } as unknown as Partial<AtlasService>).stats(req());
expect(out.last_trip).toEqual({
title: 'Interrail',
start_date: '2026-03-01',
@@ -95,27 +95,27 @@ describe('PublicStatsController', () => {
});
});
it('PUBSTATS-004: an ungeocoded last trip reports country null rather than a wrong one', () => {
it('PUBSTATS-004: an ungeocoded last trip reports country null rather than a wrong one', async () => {
const lastTrip = vi.fn(() => ({ title: 'Roadtrip', start_date: null, end_date: null, countries: [] }));
const out = ctl({ lastTrip } as unknown as Partial<AtlasService>).stats(req());
const out = await ctl({ lastTrip } as unknown as Partial<AtlasService>).stats(req());
expect(out.last_trip).toMatchObject({ country: null, countries: [] });
});
it('PUBSTATS-005: shares one rate-limit budget with the rest of /api/v1', () => {
it('PUBSTATS-005: shares one rate-limit budget with the rest of /api/v1', async () => {
const rl = new RateLimitService();
const c = ctl({}, rl);
for (let i = 0; i < PUBLIC_API_RATE_MAX_PER_MINUTE; i++) c.stats(req());
expect(thrown(() => c.stats(req()))).toEqual({
for (let i = 0; i < PUBLIC_API_RATE_MAX_PER_MINUTE; i++) await c.stats(req());
expect(await thrown(() => c.stats(req()))).toEqual({
status: 429,
body: { error: 'Too many requests. Please slow down.' },
});
// A different caller still gets their own budget.
expect(() => c.stats(req(8))).not.toThrow();
await expect(c.stats(req(8))).resolves.not.toThrow();
});
it('PUBSTATS-006: a request with no resolved user is a 401, not a crash', () => {
it('PUBSTATS-006: a request with no resolved user is a 401, not a crash', async () => {
const getTravelStats = vi.fn(() => travel());
expect(thrown(() => ctl({ getTravelStats } as unknown as Partial<AtlasService>).stats(req(null)))).toEqual({
expect(await thrown(() => ctl({ getTravelStats } as unknown as Partial<AtlasService>).stats(req(null)))).toEqual({
status: 401,
body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' },
});
@@ -12,8 +12,8 @@ function svc(o: Partial<SettingsService> = {}): SettingsService {
return { getUserSettings: vi.fn(), upsertSetting: vi.fn(), bulkUpsertSettings: vi.fn(), ...o } as unknown as SettingsService;
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
try { await fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
@@ -24,32 +24,32 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
beforeEach(() => vi.clearAllMocks());
describe('SettingsController', () => {
it('GET / returns the settings', () => {
expect(new SettingsController(svc({ getUserSettings: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<SettingsService>), { isManaged: () => false } as unknown as RuntimeEnvService).list(user)).toEqual({ settings: { theme: 'dark' } });
it('GET / returns the settings', async () => {
expect(await new SettingsController(svc({ getUserSettings: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<SettingsService>), { isManaged: () => false } as unknown as RuntimeEnvService).list(user)).toEqual({ settings: { theme: 'dark' } });
});
// The 400-without-a-key path moved to the global ZodValidationPipe (via
// SettingUpsertDto) — pinned by the settings e2e, not reachable here.
it('PUT / no-ops on the masked sentinel without writing', () => {
it('PUT / no-ops on the masked sentinel without writing', async () => {
const upsertSetting = vi.fn();
const c = new SettingsController(svc({ upsertSetting } as Partial<SettingsService>), { isManaged: () => false } as unknown as RuntimeEnvService);
expect(c.upsert(user, { key: 'immich_api_key', value: '••••••••' })).toEqual({ success: true, key: 'immich_api_key', unchanged: true });
expect(await c.upsert(user, { key: 'immich_api_key', value: '••••••••' })).toEqual({ success: true, key: 'immich_api_key', unchanged: true });
expect(upsertSetting).not.toHaveBeenCalled();
});
it('PUT / writes a real value', () => {
it('PUT / writes a real value', async () => {
const upsertSetting = vi.fn();
const c = new SettingsController(svc({ upsertSetting } as Partial<SettingsService>), { isManaged: () => false } as unknown as RuntimeEnvService);
expect(c.upsert(user, { key: 'theme', value: 'dark' })).toEqual({ success: true, key: 'theme', value: 'dark' });
expect(await c.upsert(user, { key: 'theme', value: 'dark' })).toEqual({ success: true, key: 'theme', value: 'dark' });
expect(upsertSetting).toHaveBeenCalledWith(1, 'theme', 'dark');
});
it('POST /bulk 500 on a write error, else returns the count', () => {
it('POST /bulk 500 on a write error, else returns the count', async () => {
// The 400-without-an-object path moved to the global ZodValidationPipe
// (via SettingsBulkDto) — pinned by the settings e2e, not reachable here.
vi.spyOn(console, 'error').mockImplementation(() => {});
expect(thrown(() => new SettingsController(svc({ bulkUpsertSettings: vi.fn(() => { throw new Error('db'); }) } as Partial<SettingsService>), { isManaged: () => false } as unknown as RuntimeEnvService).bulk(user, { settings: { a: 1 } }))).toEqual({ status: 500, body: { error: 'Error saving settings' } });
expect(new SettingsController(svc({ bulkUpsertSettings: vi.fn().mockReturnValue(3) } as Partial<SettingsService>), { isManaged: () => false } as unknown as RuntimeEnvService).bulk(user, { settings: { a: 1, b: 2, c: 3 } })).toEqual({ success: true, updated: 3 });
expect(await thrown(() => new SettingsController(svc({ bulkUpsertSettings: vi.fn(() => { throw new Error('db'); }) } as Partial<SettingsService>), { isManaged: () => false } as unknown as RuntimeEnvService).bulk(user, { settings: { a: 1 } }))).toEqual({ status: 500, body: { error: 'Error saving settings' } });
expect(await new SettingsController(svc({ bulkUpsertSettings: vi.fn().mockReturnValue(3) } as Partial<SettingsService>), { isManaged: () => false } as unknown as RuntimeEnvService).bulk(user, { settings: { a: 1, b: 2, c: 3 } })).toEqual({ success: true, updated: 3 });
});
});
+89 -87
View File
@@ -47,12 +47,14 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { createTestUnitOfWork } from '../../helpers/test-uow';
const svc = new SettingsService(new DatabaseService(testDb));
let svc: SettingsService;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new SettingsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb));
});
beforeEach(() => {
@@ -66,56 +68,56 @@ afterAll(() => {
// ── getUserSettings ───────────────────────────────────────────────────────────
describe('getUserSettings', () => {
it('SET-SVC-001 — returns empty object when user has no settings', () => {
it('SET-SVC-001 — returns empty object when user has no settings', async () => {
const { user } = createUser(testDb);
expect(svc.getUserSettings(user.id)).toEqual({});
expect(await svc.getUserSettings(user.id)).toEqual({});
});
it('SET-SVC-002 — returns stored plain string values', () => {
it('SET-SVC-002 — returns stored plain string values', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(user.id);
const s = svc.getUserSettings(user.id);
const s = await svc.getUserSettings(user.id);
expect(s.theme).toBe('dark');
});
it('SET-SVC-003 — JSON-parses values that are valid JSON', () => {
it('SET-SVC-003 — JSON-parses values that are valid JSON', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'count', '42')").run(user.id);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'flag', 'true')").run(user.id);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'obj', '{\"x\":1}')").run(user.id);
const s = svc.getUserSettings(user.id);
const s = await svc.getUserSettings(user.id);
expect(s.count).toBe(42);
expect(s.flag).toBe(true);
expect(s.obj).toEqual({ x: 1 });
});
it('SET-SVC-004 — falls back to raw string when value is not valid JSON', () => {
it('SET-SVC-004 — falls back to raw string when value is not valid JSON', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'raw', 'not-json')").run(user.id);
const s = svc.getUserSettings(user.id);
const s = await svc.getUserSettings(user.id);
expect(s.raw).toBe('not-json');
});
it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', () => {
it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')").run(user.id);
const s = svc.getUserSettings(user.id);
const s = await svc.getUserSettings(user.id);
expect(s.webhook_url).toBe('••••••••');
});
it('SET-SVC-006 — webhook_url with empty value returns empty string', () => {
it('SET-SVC-006 — webhook_url with empty value returns empty string', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', '')").run(user.id);
const s = svc.getUserSettings(user.id);
const s = await svc.getUserSettings(user.id);
expect(s.webhook_url).toBe('');
});
it('SET-SVC-007 — only returns settings for the requesting user', () => {
it('SET-SVC-007 — only returns settings for the requesting user', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_a', '\"a\"')").run(a.id);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_b', '\"b\"')").run(b.id);
const s = svc.getUserSettings(a.id);
const s = await svc.getUserSettings(a.id);
expect(s).toHaveProperty('key_a');
expect(s).not.toHaveProperty('key_b');
});
@@ -127,93 +129,93 @@ describe('getUserSettings', () => {
"INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
).run(`default_user_setting_${settingKey}`, value);
it('SET-SVC-020 — new user with no rows inherits the admin default token', () => {
it('SET-SVC-020 — new user with no rows inherits the admin default token', async () => {
const { user } = createUser(testDb);
setAdminDefault('mapbox_access_token', 'pk.admin');
expect(svc.getUserSettings(user.id).mapbox_access_token).toBe('pk.admin');
expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.admin');
});
it('SET-SVC-021 — an empty user token falls through to the admin default', () => {
it('SET-SVC-021 — an empty user token falls through to the admin default', async () => {
const { user } = createUser(testDb);
setAdminDefault('mapbox_access_token', 'pk.admin');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', '')").run(user.id);
expect(svc.getUserSettings(user.id).mapbox_access_token).toBe('pk.admin');
expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.admin');
});
it('SET-SVC-022 — a non-empty user token overrides the admin default', () => {
it('SET-SVC-022 — a non-empty user token overrides the admin default', async () => {
const { user } = createUser(testDb);
setAdminDefault('mapbox_access_token', 'pk.admin');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', 'pk.user')").run(user.id);
expect(svc.getUserSettings(user.id).mapbox_access_token).toBe('pk.user');
expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.user');
});
it('SET-SVC-023 — an empty value with no admin default stays empty (no regression)', () => {
it('SET-SVC-023 — an empty value with no admin default stays empty (no regression)', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_style', '')").run(user.id);
expect(svc.getUserSettings(user.id).mapbox_style).toBe('');
expect((await svc.getUserSettings(user.id)).mapbox_style).toBe('');
});
it('SET-SVC-024 — a non-defaultable empty value is preserved even against a same-named default', () => {
it('SET-SVC-024 — a non-defaultable empty value is preserved even against a same-named default', async () => {
const { user } = createUser(testDb);
// 'theme' is not a defaultable key: an empty stored value must be returned as-is.
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', '')").run(user.id);
expect(svc.getUserSettings(user.id).theme).toBe('');
expect((await svc.getUserSettings(user.id)).theme).toBe('');
});
it('SET-SVC-025 — an inherited admin llm_api_key is masked, never returned in cleartext', () => {
it('SET-SVC-025 — an inherited admin llm_api_key is masked, never returned in cleartext', async () => {
const { user } = createUser(testDb);
setAdminDefault('llm_api_key', 'sk-admin-secret');
// No user row → the value is inherited from the admin default; it is a secret
// and must reach the client masked, not in cleartext.
expect(svc.getUserSettings(user.id).llm_api_key).toBe('••••••••');
expect((await svc.getUserSettings(user.id)).llm_api_key).toBe('••••••••');
});
it('SET-SVC-026 — an empty user llm_api_key falls back to the admin key, still masked', () => {
it('SET-SVC-026 — an empty user llm_api_key falls back to the admin key, still masked', async () => {
const { user } = createUser(testDb);
setAdminDefault('llm_api_key', 'sk-admin-secret');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'llm_api_key', '')").run(user.id);
expect(svc.getUserSettings(user.id).llm_api_key).toBe('••••••••');
expect((await svc.getUserSettings(user.id)).llm_api_key).toBe('••••••••');
});
// carto_api_key (#2054): encrypted at rest like the other credentials, but
// Leaflet builds the tile URL in the browser, so it must come back readable.
it('SET-SVC-031 — carto_api_key is stored encrypted and read back in cleartext, never masked', () => {
it('SET-SVC-031 — carto_api_key is stored encrypted and read back in cleartext, never masked', async () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'carto_api_key', 'carto-user');
await svc.upsertSetting(user.id, 'carto_api_key', 'carto-user');
// Passthrough crypto mock: the stored row is whatever maybe_encrypt_api_key returned.
const raw = testDb
.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'carto_api_key'")
.get(user.id) as { value: string };
expect(raw.value).toBe('carto-user');
expect(svc.getUserSettings(user.id).carto_api_key).toBe('carto-user');
expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-user');
// A digits-only key proves it takes the decrypt branch: a non-encrypted key
// would come back JSON-parsed, as the number 12345.
svc.upsertSetting(user.id, 'carto_api_key', '12345');
expect(svc.getUserSettings(user.id).carto_api_key).toBe('12345');
await svc.upsertSetting(user.id, 'carto_api_key', '12345');
expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('12345');
});
it('SET-SVC-032 — an empty user carto_api_key falls back to the admin default', () => {
it('SET-SVC-032 — an empty user carto_api_key falls back to the admin default', async () => {
const { user } = createUser(testDb);
setAdminDefault('carto_api_key', 'carto-admin');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', '')").run(user.id);
expect(svc.getUserSettings(user.id).carto_api_key).toBe('carto-admin');
expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-admin');
});
it('SET-SVC-033 — a managed instance injects the operator key over both the user and the admin value', () => {
it('SET-SVC-033 — a managed instance injects the operator key over both the user and the admin value', async () => {
const { user } = createUser(testDb);
setAdminDefault('carto_api_key', 'carto-admin');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', 'carto-user')").run(user.id);
vi.stubEnv('TREK_MANAGED', 'true');
vi.stubEnv('CARTO_API_KEY', 'carto-operator');
try {
expect(svc.getUserSettings(user.id).carto_api_key).toBe('carto-operator');
expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-operator');
} finally {
vi.unstubAllEnvs();
}
});
it('SET-SVC-034 — the injected CARTO key leaves map_provider alone', () => {
it('SET-SVC-034 — the injected CARTO key leaves map_provider alone', async () => {
const { user } = createUser(testDb);
vi.stubEnv('TREK_MANAGED', 'true');
vi.stubEnv('CARTO_API_KEY', 'carto-operator');
@@ -221,7 +223,7 @@ describe('getUserSettings', () => {
try {
// The Mapbox token flips a managed instance to GL because a token means a
// renderer. A basemap key says nothing about which renderer the user wants.
const s = svc.getUserSettings(user.id);
const s = await svc.getUserSettings(user.id);
expect(s.carto_api_key).toBe('carto-operator');
expect(s.map_provider).toBeUndefined();
} finally {
@@ -233,43 +235,43 @@ describe('getUserSettings', () => {
// ── upsertSetting ─────────────────────────────────────────────────────────────
describe('upsertSetting', () => {
it('SET-SVC-008 — inserts a new setting', () => {
it('SET-SVC-008 — inserts a new setting', async () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'language', 'en');
const s = svc.getUserSettings(user.id);
await svc.upsertSetting(user.id, 'language', 'en');
const s = await svc.getUserSettings(user.id);
expect(s.language).toBe('en');
});
it('SET-SVC-009 — updates an existing setting (ON CONFLICT)', () => {
it('SET-SVC-009 — updates an existing setting (ON CONFLICT)', async () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'language', 'en');
svc.upsertSetting(user.id, 'language', 'fr');
const s = svc.getUserSettings(user.id);
await svc.upsertSetting(user.id, 'language', 'en');
await svc.upsertSetting(user.id, 'language', 'fr');
const s = await svc.getUserSettings(user.id);
expect(s.language).toBe('fr');
});
it('SET-SVC-010 — serializes object values as JSON', () => {
it('SET-SVC-010 — serializes object values as JSON', async () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'prefs', { dark: true, size: 14 });
await svc.upsertSetting(user.id, 'prefs', { dark: true, size: 14 });
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'prefs'").get(user.id) as any;
expect(raw.value).toBe('{"dark":true,"size":14}');
});
it('SET-SVC-011 — serializes boolean values as strings', () => {
it('SET-SVC-011 — serializes boolean values as strings', async () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'notifications', true);
await svc.upsertSetting(user.id, 'notifications', true);
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'").get(user.id) as any;
expect(raw.value).toBe('true');
});
it('SET-SVC-012 — webhook_url passes through maybe_encrypt_api_key', () => {
it('SET-SVC-012 — webhook_url passes through maybe_encrypt_api_key', async () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'webhook_url', 'https://hook.example.com');
await svc.upsertSetting(user.id, 'webhook_url', 'https://hook.example.com');
// With passthrough mock, value is stored as-is
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(user.id) as any;
expect(raw.value).toBe('https://hook.example.com');
// But getUserSettings masks it
const s = svc.getUserSettings(user.id);
const s = await svc.getUserSettings(user.id);
expect(s.webhook_url).toBe('••••••••');
});
});
@@ -277,55 +279,55 @@ describe('upsertSetting', () => {
// ── bulkUpsertSettings ────────────────────────────────────────────────────────
describe('bulkUpsertSettings', () => {
it('SET-SVC-013 — inserts multiple settings in one call', () => {
it('SET-SVC-013 — inserts multiple settings in one call', async () => {
const { user } = createUser(testDb);
svc.bulkUpsertSettings(user.id, { a: 'alpha', b: 'beta', c: 'gamma' });
const s = svc.getUserSettings(user.id);
await svc.bulkUpsertSettings(user.id, { a: 'alpha', b: 'beta', c: 'gamma' });
const s = await svc.getUserSettings(user.id);
expect(s.a).toBe('alpha');
expect(s.b).toBe('beta');
expect(s.c).toBe('gamma');
});
it('SET-SVC-014 — returns the count of settings processed', () => {
it('SET-SVC-014 — returns the count of settings processed', async () => {
const { user } = createUser(testDb);
const count = svc.bulkUpsertSettings(user.id, { x: 1, y: 2, z: 3 });
const count = await svc.bulkUpsertSettings(user.id, { x: 1, y: 2, z: 3 });
expect(count).toBe(3);
});
it('SET-SVC-015 — updates existing keys (ON CONFLICT)', () => {
it('SET-SVC-015 — updates existing keys (ON CONFLICT)', async () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'theme', 'light');
svc.bulkUpsertSettings(user.id, { theme: 'dark', lang: 'en' });
const s = svc.getUserSettings(user.id);
await svc.upsertSetting(user.id, 'theme', 'light');
await svc.bulkUpsertSettings(user.id, { theme: 'dark', lang: 'en' });
const s = await svc.getUserSettings(user.id);
expect(s.theme).toBe('dark');
expect(s.lang).toBe('en');
});
it('SET-SVC-016 — returns 0 for empty settings object', () => {
it('SET-SVC-016 — returns 0 for empty settings object', async () => {
const { user } = createUser(testDb);
const count = svc.bulkUpsertSettings(user.id, {});
const count = await svc.bulkUpsertSettings(user.id, {});
expect(count).toBe(0);
});
it('SET-SVC-017 — all changes are committed atomically (transaction)', () => {
it('SET-SVC-017 — all changes are committed atomically (transaction)', async () => {
const { user } = createUser(testDb);
svc.bulkUpsertSettings(user.id, { p: '1', q: '2' });
await svc.bulkUpsertSettings(user.id, { p: '1', q: '2' });
const rows = testDb.prepare('SELECT key FROM settings WHERE user_id = ?').all(user.id) as any[];
const keys = rows.map((r: any) => r.key);
expect(keys).toContain('p');
expect(keys).toContain('q');
});
it('SET-SVC-018 — settings from different users do not interfere', () => {
it('SET-SVC-018 — settings from different users do not interfere', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
svc.bulkUpsertSettings(a.id, { shared_key: 'from-a' });
svc.bulkUpsertSettings(b.id, { shared_key: 'from-b' });
expect((svc.getUserSettings(a.id) as any).shared_key).toBe('from-a');
expect((svc.getUserSettings(b.id) as any).shared_key).toBe('from-b');
await svc.bulkUpsertSettings(a.id, { shared_key: 'from-a' });
await svc.bulkUpsertSettings(b.id, { shared_key: 'from-b' });
expect((await svc.getUserSettings(a.id) as any).shared_key).toBe('from-a');
expect((await svc.getUserSettings(b.id) as any).shared_key).toBe('from-b');
});
it('SET-SVC-019 — rolls back and re-throws when DB write fails mid-transaction', () => {
it('SET-SVC-019 — rolls back and re-throws when DB write fails mid-transaction', async () => {
const { user } = createUser(testDb);
const origPrepare = testDb.prepare.bind(testDb);
let intercepted = false;
@@ -334,7 +336,7 @@ describe('bulkUpsertSettings', () => {
intercepted = true;
return { run: () => { throw new Error('forced DB error'); } } as any;
});
expect(() => svc.bulkUpsertSettings(user.id, { k: 'v' })).toThrow('forced DB error');
await expect(svc.bulkUpsertSettings(user.id, { k: 'v' })).rejects.toThrow('forced DB error');
expect(intercepted).toBe(true);
vi.restoreAllMocks();
});
@@ -343,27 +345,27 @@ describe('bulkUpsertSettings', () => {
// ── post-migration quirk fixes ────────────────────────────────────────────────
describe('legacy quirk fixes', () => {
it('SET-SVC-027 — null serializes as the empty string, not the string "null"', () => {
it('SET-SVC-027 — null serializes as the empty string, not the string "null"', async () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'llm_api_key', null);
await svc.upsertSetting(user.id, 'llm_api_key', null);
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'llm_api_key'").get(user.id) as { value: string };
expect(raw.value).toBe('');
// The legacy "null" storage leaked back out of getDecryptedUserSetting as
// the literal string "null"; a cleared secret must read as null.
expect(svc.getDecryptedUserSetting(user.id, 'llm_api_key')).toBeNull();
expect(await svc.getDecryptedUserSetting(user.id, 'llm_api_key')).toBeNull();
});
it('SET-SVC-028 — a nulled defaultable key falls through to the admin default', () => {
it('SET-SVC-028 — a nulled defaultable key falls through to the admin default', async () => {
const { user } = createUser(testDb);
svc.setAdminUserDefaults({ mapbox_style: 'admin-style' });
svc.upsertSetting(user.id, 'mapbox_style', null);
expect(svc.getUserSettings(user.id).mapbox_style).toBe('admin-style');
await svc.setAdminUserDefaults({ mapbox_style: 'admin-style' });
await svc.upsertSetting(user.id, 'mapbox_style', null);
expect((await svc.getUserSettings(user.id)).mapbox_style).toBe('admin-style');
});
it('SET-SVC-029 — bulk skips the masked sentinel so a stored secret survives', () => {
it('SET-SVC-029 — bulk skips the masked sentinel so a stored secret survives', async () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'ntfy_token', 'tok-real');
const count = svc.bulkUpsertSettings(user.id, { ntfy_topic: 'trek', ntfy_token: '••••••••' });
await svc.upsertSetting(user.id, 'ntfy_token', 'tok-real');
const count = await svc.bulkUpsertSettings(user.id, { ntfy_topic: 'trek', ntfy_token: '••••••••' });
expect(count).toBe(1);
// Passthrough crypto mock: the stored value must still be the real token.
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_token'").get(user.id) as { value: string };
@@ -371,8 +373,8 @@ describe('legacy quirk fixes', () => {
expect(testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_topic'").get(user.id)).toEqual({ value: 'trek' });
});
it('SET-SVC-030 — bulk returns the count of keys actually written', () => {
it('SET-SVC-030 — bulk returns the count of keys actually written', async () => {
const { user } = createUser(testDb);
expect(svc.bulkUpsertSettings(user.id, { a: '1', b: '••••••••' })).toBe(1);
expect(await svc.bulkUpsertSettings(user.id, { a: '1', b: '••••••••' })).toBe(1);
});
});
@@ -54,86 +54,86 @@ 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');
it('INSTKEY-001: round-trips through encryption at rest', async () => {
await 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');
expect(await 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');
it('INSTKEY-002: a second write of the same value replaces the row (no second one)', async () => {
await writeInstanceApiKey(db, 'maps_api_key', 'same-key');
const first = storedValue('maps_api_key');
writeInstanceApiKey(db, 'maps_api_key', 'same-key');
await 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');
expect(await 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', ' ');
it('INSTKEY-003: a blank value reads back as unset but keeps the row', async () => {
await writeInstanceApiKey(db, 'unsplash_api_key', ' ');
expect(storedValue('unsplash_api_key')).toBe('');
expect(readInstanceApiKey(db, 'unsplash_api_key')).toBeNull();
expect(await readInstanceApiKey(db, 'unsplash_api_key')).toBeNull();
});
it('INSTKEY-004: a legacy plaintext row still reads', () => {
it('INSTKEY-004: a legacy plaintext row still reads', async () => {
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');
expect(await readInstanceApiKey(db, 'maps_api_key')).toBe('plain-old-key');
});
it('INSTKEY-005: the operator env key wins and the database is never read', () => {
it('INSTKEY-005: the operator env key wins and the database is never read', async () => {
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({
await writeInstanceApiKey(db, 'maps_api_key', 'instance-key');
expect(await 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', () => {
it('INSTKEY-006: the instance value wins over the caller own row', async () => {
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' });
await writeInstanceApiKey(db, 'maps_api_key', 'instance-key');
expect(await 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)", () => {
it("INSTKEY-007: without an instance value the caller's own row answers — and nobody else's (#1939)", async () => {
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({
expect(await 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 });
expect(await resolveApiKey(db, 'maps_api_key', member.id, undefined)).toEqual({ key: null, source: null });
});
it('INSTKEY-009: userId 0 asks about the instance only', () => {
it('INSTKEY-009: userId 0 asks about the instance only', async () => {
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' });
expect(await resolveApiKey(db, 'maps_api_key', 0, undefined)).toEqual({ key: null, source: null });
await writeInstanceApiKey(db, 'maps_api_key', 'instance-key');
expect(await 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', () => {
it('INSTKEY-008: an empty instance value does not fall through to the own row', async () => {
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', '');
await writeInstanceApiKey(db, 'unsplash_api_key', 'to-be-cleared');
await 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 });
expect(await resolveApiKey(db, 'unsplash_api_key', user.id, undefined)).toEqual({ key: null, source: null });
});
});
+10 -2
View File
@@ -60,12 +60,20 @@ import { ShareService, publicReservationMetadata } from '../../../src/nest/share
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import type { User } from '../../../src/types';
import { createTestUnitOfWork } from '../../helpers/test-uow';
const svc = new ShareService(new DatabaseService(testDb), new SettingsService(new DatabaseService(testDb)), permissionsStub, new QueryHelpersService(new DatabaseService(testDb)), photoCacheStub);
let svc: ShareService;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new ShareService(
new DatabaseService(testDb),
new SettingsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)),
permissionsStub,
new QueryHelpersService(new DatabaseService(testDb)),
photoCacheStub,
);
});
beforeEach(() => {
@@ -30,10 +30,15 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace, createReservation, addTripMember } from '../../helpers/factories';
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { createTestUnitOfWork } from '../../helpers/test-uow';
const atlas = new AtlasService(new DatabaseService(testDb));
let atlas: AtlasService;
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
atlas = new AtlasService(new DatabaseService(testDb), await createTestUnitOfWork(testDb));
});
beforeEach(() => { resetTestDb(testDb); vi.clearAllMocks(); });
afterAll(() => { testDb.close(); });
@@ -65,19 +70,19 @@ describe('getTravelStats', () => {
const PAST_START = '2023-05-01';
const PAST_END = '2023-05-10';
it('AUTH-DB-047: #1486 counts the from/to countries of a flight', () => {
it('AUTH-DB-047: #1486 counts the from/to countries of a flight', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const stats = atlas.getTravelStats(user.id);
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toContain('BE');
expect(stats.countries).toContain('JP');
});
it('AUTH-DB-048: #1486 a connecting-flight layover does NOT count as visited', () => {
it('AUTH-DB-048: #1486 a connecting-flight layover does NOT count as visited', async () => {
// The Atlas query grew a role filter for #1486 but this copy of it did not, so the
// dashboard passport card still counted a plane change as a visited country.
const { user } = createUser(testDb);
@@ -87,24 +92,24 @@ describe('getTravelStats', () => {
endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport
endpoint(res.id, 'to', 2, -33.8688, 151.2093); // Sydney
const stats = atlas.getTravelStats(user.id);
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toContain('BE');
expect(stats.countries).toContain('AU');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-049: #1490 a country removed in Atlas is not counted on the dashboard either', () => {
it('AUTH-DB-049: #1490 a country removed in Atlas is not counted on the dashboard either', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844);
endpoint(res.id, 'to', 1, 35.6762, 139.6503);
expect(atlas.getTravelStats(user.id).countries).toContain('JP');
expect((await atlas.getTravelStats(user.id)).countries).toContain('JP');
atlas.unmarkCountry(user.id, 'JP');
await atlas.unmarkCountry(user.id, 'JP');
const after = atlas.getTravelStats(user.id);
const after = await atlas.getTravelStats(user.id);
expect(after.countries).not.toContain('JP');
expect(after.countries).toContain('BE');
});
@@ -120,39 +125,39 @@ describe('getTravelStats', () => {
.run(place.id, countryCode, regionCode, regionCode);
}
it('AUTH-DB-094: #1048 a place in a future trip does not stamp its country; a past trip does', () => {
it('AUTH-DB-094: #1048 a place in a future trip does not stamp its country; a past trip does', async () => {
const { user } = createUser(testDb);
const past = createTrip(testDb, user.id, { title: 'Paris, last month', start_date: iso(-40), end_date: iso(-30) });
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
placeInRegion(past.id, 'FR', 'FR-75');
placeInRegion(future.id, 'JP', 'JP-13');
const stats = atlas.getTravelStats(user.id);
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toContain('FR');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-095: #1048 a trip with no dates at all stamps nothing', () => {
it('AUTH-DB-095: #1048 a trip with no dates at all stamps nothing', async () => {
const { user } = createUser(testDb);
const dateless = createTrip(testDb, user.id, { title: 'Someday: Japan' });
placeInRegion(dateless.id, 'JP', 'JP-13');
const res = createReservation(testDb, dateless.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
const stats = atlas.getTravelStats(user.id);
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toEqual([]);
});
it('AUTH-DB-096: #1048 a flight booked for a future trip does not stamp its endpoints', () => {
it('AUTH-DB-096: #1048 a flight booked for a future trip does not stamp its endpoints', async () => {
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
const res = createReservation(testDb, future.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const stats = atlas.getTravelStats(user.id);
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).not.toContain('BE');
expect(stats.countries).not.toContain('JP');
@@ -172,38 +177,38 @@ describe('getTravelStats', () => {
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', () => {
it('AUTH-DB-099: #1535 a plane change booked as two flights does not stamp the hub country', async () => {
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);
const stats = await 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', () => {
it('AUTH-DB-100: #1535 a stopover of two days still stamps the hub country', async () => {
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');
expect((await atlas.getTravelStats(user.id)).countries).toContain('FI');
});
it('AUTH-DB-101: #1490 removing a country still subtracts it around the layover pairing', () => {
it('AUTH-DB-101: #1490 removing a country still subtracts it around the layover pairing', async () => {
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');
await atlas.unmarkCountry(user.id, 'BE');
const stats = atlas.getTravelStats(user.id);
const stats = await 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', () => {
it('AUTH-DB-097: #1048 a manually marked country stays stamped regardless of trip dates', async () => {
// The manual list is the user's own word, not derived from a trip — the date
// filter must not reach it.
const { user } = createUser(testDb);
@@ -211,18 +216,18 @@ describe('getTravelStats', () => {
placeInRegion(future.id, 'JP', 'JP-13');
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
const stats = atlas.getTravelStats(user.id);
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toContain('JP');
});
});
describe('travel-stats quirk fixes', () => {
it('AUTH-DB-089: getTravelStats keeps a place at lat 0 / lng 0 (equator, prime meridian)', () => {
it('AUTH-DB-089: getTravelStats keeps a place at lat 0 / lng 0 (equator, prime meridian)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Null Island' });
testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, ?, ?)').run(trip.id, 'Null Island', 0, 0);
const stats = atlas.getTravelStats(user.id);
const stats = await atlas.getTravelStats(user.id);
expect(stats.coords).toContainEqual({ lat: 0, lng: 0 });
});
});
@@ -271,13 +276,13 @@ describe('personal figures on a shared trip (#1966)', () => {
return { alice, bob, trip, fromRome, fromMexico };
}
it('credits each traveler with their own flight only', () => {
it('credits each traveler with their own flight only', async () => {
const { alice, bob, fromRome, fromMexico } = sharedTrip();
assignTo(fromRome.id, alice.id);
assignTo(fromMexico.id, bob.id);
const a = atlas.getTravelStats(alice.id);
const b = atlas.getTravelStats(bob.id);
const a = await atlas.getTravelStats(alice.id);
const b = await atlas.getTravelStats(bob.id);
// Rome to New York is a good deal further than Mexico City to New York, so
// equal distances would mean both are still counting both flights.
@@ -286,48 +291,48 @@ describe('personal figures on a shared trip (#1966)', () => {
expect(a.totalDistanceKm).not.toBe(b.totalDistanceKm);
});
it('does not stamp another traveler s departure country on your passport', () => {
it('does not stamp another traveler s departure country on your passport', async () => {
const { alice, bob, fromRome, fromMexico } = sharedTrip();
assignTo(fromRome.id, alice.id);
assignTo(fromMexico.id, bob.id);
// Alice never went through Mexico.
expect(atlas.getTravelStats(alice.id).countries).not.toContain('MX');
expect(atlas.getTravelStats(bob.id).countries).not.toContain('IT');
expect((await atlas.getTravelStats(alice.id)).countries).not.toContain('MX');
expect((await atlas.getTravelStats(bob.id)).countries).not.toContain('IT');
});
/*
* The case that keeps this from being a breaking change. The assignment table
* is empty on every install upgrading from 3.4.1.
*/
it('still counts a booking nobody is named on, for everyone on the trip', () => {
it('still counts a booking nobody is named on, for everyone on the trip', async () => {
const { alice, bob } = sharedTrip();
const a = atlas.getTravelStats(alice.id);
const b = atlas.getTravelStats(bob.id);
const a = await atlas.getTravelStats(alice.id);
const b = await atlas.getTravelStats(bob.id);
expect(a.totalDistanceKm).toBeGreaterThan(0);
expect(a.totalDistanceKm).toBe(b.totalDistanceKm);
});
it('counts a booking they are both named on, once for each of them', () => {
it('counts a booking they are both named on, once for each of them', async () => {
const { alice, bob, fromRome, fromMexico } = sharedTrip();
assignTo(fromRome.id, alice.id);
assignTo(fromRome.id, bob.id);
assignTo(fromMexico.id, alice.id);
assignTo(fromMexico.id, bob.id);
const a = atlas.getTravelStats(alice.id);
const b = atlas.getTravelStats(bob.id);
const a = await atlas.getTravelStats(alice.id);
const b = await atlas.getTravelStats(bob.id);
expect(a.totalDistanceKm).toBe(b.totalDistanceKm);
expect(a.countries).toEqual(b.countries);
});
it('leaves a traveler assigned nothing with the unassigned bookings only', () => {
it('leaves a traveler assigned nothing with the unassigned bookings only', async () => {
const { alice, bob, fromRome } = sharedTrip();
assignTo(fromRome.id, bob.id);
// Alice keeps the Mexico flight (nobody named) but loses the Rome one.
const a = atlas.getTravelStats(alice.id);
const a = await atlas.getTravelStats(alice.id);
expect(a.countries).not.toContain('IT');
expect(a.totalDistanceKm).toBeGreaterThan(0);
});
@@ -159,37 +159,37 @@ describe('unsplashService.searchUnsplashPhotos', () => {
});
describe('unsplashService.getUnsplashKey', () => {
it('UNSPLASH-012: prefers the UNSPLASH_ACCESS_KEY env var over any stored key', () => {
it('UNSPLASH-012: prefers the UNSPLASH_ACCESS_KEY env var over any stored key', async () => {
process.env.UNSPLASH_ACCESS_KEY = 'env-key';
mockDbGet.mockReturnValue({ unsplash_api_key: 'user-key' });
expect(getUnsplashKey(1)).toBe('env-key');
expect(await getUnsplashKey(1)).toBe('env-key');
expect(mockDbGet).not.toHaveBeenCalled();
});
it('UNSPLASH-013: returns the user key when set and no env var', () => {
it('UNSPLASH-013: returns the user key when set and no env var', async () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockDbGet.mockReturnValueOnce({ unsplash_api_key: 'user-key' });
expect(getUnsplashKey(1)).toBe('user-key');
expect(await getUnsplashKey(1)).toBe('user-key');
});
it('UNSPLASH-014: the instance-wide key wins over the user own key (#1939)', () => {
it('UNSPLASH-014: the instance-wide key wins over the user own key (#1939)', async () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockInstanceGet.mockReturnValue({ value: 'instance-key' });
mockDbGet.mockReturnValue({ unsplash_api_key: 'user-key' });
expect(getUnsplashKey(1)).toBe('instance-key');
expect(await getUnsplashKey(1)).toBe('instance-key');
expect(mockDbGet).not.toHaveBeenCalled(); // the own row is not even read
});
it('UNSPLASH-015: returns null when neither env, instance, nor the user has a key', () => {
it('UNSPLASH-015: returns null when neither env, instance, nor the user has a key', async () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockDbGet.mockReturnValue(undefined);
expect(getUnsplashKey(1)).toBeNull();
expect(await getUnsplashKey(1)).toBeNull();
});
it("UNSPLASH-015b: never reads another user's key — the admin fallback is gone (#1939)", () => {
it("UNSPLASH-015b: never reads another user's key — the admin fallback is gone (#1939)", async () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockDbGet.mockReturnValue(undefined);
expect(getUnsplashKey(1)).toBeNull();
expect(await getUnsplashKey(1)).toBeNull();
// Both reads are scoped: the instance row and this caller's own row.
expect(mockDbGet).toHaveBeenCalledTimes(1);
expect(mockDbGet).toHaveBeenCalledWith(1);
@@ -43,14 +43,20 @@ 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 { createTestUnitOfWork } from '../../helpers/test-uow';
import { SEARCH_TEXT_FIELD_MASK } from '../../../src/nest/maps/maps.helpers';
const avatarsFx = makeStorageFixture('avatars/');
const profile = new UserProfileService(new DatabaseService(testDb), avatarsFx.storage);
let profile: UserProfileService;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
profile = new UserProfileService(
new DatabaseService(testDb),
avatarsFx.storage,
await createTestUnitOfWork(testDb),
);
});
beforeEach(() => {
@@ -67,60 +73,60 @@ afterAll(() => {
// ---------------------------------------------------------------------------
describe('updateSettings', () => {
it('AUTH-DB-001: updates username successfully', () => {
it('AUTH-DB-001: updates username successfully', async () => {
const { user } = createUser(testDb);
const result = profile.updateSettings(user.id, { username: 'newname' });
const result = await profile.updateSettings(user.id, { username: 'newname' });
expect(result.success).toBe(true);
expect(result.user?.username).toBe('newname');
});
it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', () => {
it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', async () => {
const { user } = createUser(testDb);
const result = profile.updateSettings(user.id, { username: 'x' });
const result = await profile.updateSettings(user.id, { username: 'x' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/between 2 and 50/i);
});
it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', () => {
it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', async () => {
const { user } = createUser(testDb);
const result = profile.updateSettings(user.id, { username: 'bad name' });
const result = await profile.updateSettings(user.id, { username: 'bad name' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/only contain/i);
});
it('AUTH-DB-004: returns 409 when username is already taken by another user', () => {
it('AUTH-DB-004: returns 409 when username is already taken by another user', async () => {
const { user: user1 } = createUser(testDb, { username: 'alice' });
const { user: user2 } = createUser(testDb, { username: 'bob' });
const result = profile.updateSettings(user2.id, { username: user1.username });
const result = await profile.updateSettings(user2.id, { username: user1.username });
expect(result.status).toBe(409);
expect(result.error).toMatch(/already taken/i);
});
it('AUTH-DB-005: updates email successfully', () => {
it('AUTH-DB-005: updates email successfully', async () => {
const { user } = createUser(testDb);
const result = profile.updateSettings(user.id, { email: 'new@example.com' });
const result = await profile.updateSettings(user.id, { email: 'new@example.com' });
expect(result.success).toBe(true);
expect(result.user?.email).toBe('new@example.com');
});
it('AUTH-DB-006: returns 400 for invalid email format', () => {
it('AUTH-DB-006: returns 400 for invalid email format', async () => {
const { user } = createUser(testDb);
const result = profile.updateSettings(user.id, { email: 'not-an-email' });
const result = await profile.updateSettings(user.id, { email: 'not-an-email' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/invalid email/i);
});
it('AUTH-DB-007: returns 409 when email is already taken by another user', () => {
it('AUTH-DB-007: returns 409 when email is already taken by another user', async () => {
const { user: user1 } = createUser(testDb, { email: 'taken@example.com' });
const { user: user2 } = createUser(testDb);
const result = profile.updateSettings(user2.id, { email: user1.email });
const result = await profile.updateSettings(user2.id, { email: user1.email });
expect(result.status).toBe(409);
expect(result.error).toMatch(/already taken/i);
});
it('AUTH-DB-008: returns success with no field changes when empty body is passed', () => {
it('AUTH-DB-008: returns success with no field changes when empty body is passed', async () => {
const { user } = createUser(testDb);
const result = profile.updateSettings(user.id, {});
const result = await profile.updateSettings(user.id, {});
expect(result.success).toBe(true);
});
});
@@ -130,32 +136,32 @@ describe('updateSettings', () => {
// ---------------------------------------------------------------------------
describe('getSettings', () => {
it('AUTH-DB-009: returns 403 for non-admin user', () => {
it('AUTH-DB-009: returns 403 for non-admin user', async () => {
const { user } = createUser(testDb);
const result = profile.getSettings(user.id);
const result = await profile.getSettings(user.id);
expect(result.status).toBe(403);
expect(result.error).toMatch(/admin/i);
});
it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', () => {
it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', async () => {
const { user } = createAdmin(testDb);
testDb
.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ? WHERE id = ?')
.run('maps-key-value', 'weather-key-value', user.id);
const result = profile.getSettings(user.id);
const result = await profile.getSettings(user.id);
expect(result.status).toBeUndefined();
expect(result.settings).toBeDefined();
expect(result.settings).toHaveProperty('maps_api_key');
expect(result.settings).toHaveProperty('openweather_api_key');
});
it('AUTH-DB-010b: round-trips unsplash_api_key through updateApiKeys — masked to the client, readable via getSettings', () => {
it('AUTH-DB-010b: round-trips unsplash_api_key through updateApiKeys — masked to the client, readable via getSettings', async () => {
const { user } = createAdmin(testDb);
const result = profile.updateApiKeys(user.id, { unsplash_api_key: 'unsplash-secret-key' });
const result = await profile.updateApiKeys(user.id, { unsplash_api_key: 'unsplash-secret-key' });
// Returned to the client masked, never in plaintext.
expect(result.user.unsplash_api_key).toBe('-----key');
// getSettings returns the stored key to the admin.
expect(profile.getSettings(user.id).settings?.unsplash_api_key).toBe('unsplash-secret-key');
expect((await profile.getSettings(user.id)).settings?.unsplash_api_key).toBe('unsplash-secret-key');
});
});
@@ -310,9 +316,9 @@ describe('validateKeys', () => {
});
describe('updateMapsKey / avatar', () => {
it('AUTH-DB-068: updateMapsKey stores and answers the masked key', () => {
it('AUTH-DB-068: updateMapsKey stores and answers the masked key', async () => {
const { user } = createUser(testDb);
const result = profile.updateMapsKey(user.id, 'maps-key-123456');
const result = await profile.updateMapsKey(user.id, 'maps-key-123456');
expect(result.success).toBe(true);
expect(result.maps_api_key).toBe('----3456');
});
@@ -367,9 +373,9 @@ describe('updateMapsKey / avatar', () => {
});
describe('profile quirk fixes', () => {
it('AUTH-DB-093: updateApiKeys degrades gracefully when the user row is gone (no TypeError/500)', () => {
expect(() => profile.updateApiKeys(999999, { maps_api_key: 'k' })).not.toThrow();
const result = profile.updateApiKeys(999999, { openweather_api_key: 'w' });
it('AUTH-DB-093: updateApiKeys degrades gracefully when the user row is gone (no TypeError/500)', async () => {
await expect(profile.updateApiKeys(999999, { maps_api_key: 'k' })).resolves.not.toThrow();
const result = await profile.updateApiKeys(999999, { openweather_api_key: 'w' });
expect(result.success).toBe(true);
});
});
@@ -386,9 +392,9 @@ 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', () => {
it('AUTH-DB-102: an admin save lands in app_settings AND in their own column', async () => {
const { user } = createAdmin(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'instance-google-key', unsplash_api_key: 'instance-unsplash-key' });
await 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.
@@ -396,46 +402,46 @@ describe('instance-wide API keys', () => {
expect(row.maps_api_key).toBe('instance-google-key');
});
it('AUTH-DB-103: a non-admin save never touches the instance value', () => {
it('AUTH-DB-103: a non-admin save never touches the instance value', async () => {
const { user: admin } = createAdmin(testDb);
profile.updateApiKeys(admin.id, { maps_api_key: 'admin-set' });
await profile.updateApiKeys(admin.id, { maps_api_key: 'admin-set' });
const { user } = createUser(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'members-own' });
await 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', () => {
it('AUTH-DB-104: clearing the field stores an empty instance value rather than dropping the row', async () => {
const { user } = createAdmin(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'to-be-removed' });
profile.updateApiKeys(user.id, { maps_api_key: '' });
await profile.updateApiKeys(user.id, { maps_api_key: 'to-be-removed' });
await 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', () => {
it('AUTH-DB-105: getSettings reads the instance value, not the admin own column', async () => {
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');
expect((await 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();
expect((await profile.getSettings(user.id)).settings?.openweather_api_key).toBeNull();
});
it('AUTH-DB-106: updateMapsKey mirrors for an admin and stays personal for a member', () => {
it('AUTH-DB-106: updateMapsKey mirrors for an admin and stays personal for a member', async () => {
const { user: admin } = createAdmin(testDb);
expect(profile.updateMapsKey(admin.id, 'via-maps-key-route').changedKeys).toEqual(['maps_api_key']);
expect((await 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');
await 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', () => {
it('AUTH-DB-107: updateSettings mirrors the key half without touching name/email handling', async () => {
const { user } = createAdmin(testDb);
const result = profile.updateSettings(user.id, { maps_api_key: 'from-settings-route', username: 'renamed' });
const result = await 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');
@@ -444,36 +450,36 @@ describe('instance-wide API keys', () => {
});
describe('changedKeys', () => {
it('AUTH-DB-108: only the names in the body count, and clearing counts as a change', () => {
it('AUTH-DB-108: only the names in the body count, and clearing counts as a change', async () => {
const { user } = createAdmin(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'k1', openweather_api_key: 'w1' });
await 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']);
expect((await profile.updateApiKeys(user.id, { openweather_api_key: 'w2' })).changedKeys).toEqual(['openweather_api_key']);
expect((await profile.updateApiKeys(user.id, { maps_api_key: '' })).changedKeys).toEqual(['maps_api_key']);
});
it('AUTH-DB-109: an unchanged value reports nothing, whitespace included', () => {
it('AUTH-DB-109: an unchanged value reports nothing, whitespace included', async () => {
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([]);
await profile.updateApiKeys(user.id, { maps_api_key: 'unchanged-key' });
expect((await 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([]);
expect((await 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']);
expect((await 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', () => {
it('AUTH-DB-110: a managed install reports no change for the names it refuses to write', async () => {
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' });
const result = await 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([]);
expect((await profile.updateMapsKey(user.id, 'operator-owns-this')).changedKeys).toEqual([]);
expect((await 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;