mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
refactor(atlas, school-holidays, settings): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user