feat(places): record which search result was picked, on this instance only

The corpus behind "is our own index good enough to replace Nominatim".
When somebody searches and then picks a result, the client posts the query,
the rank and the pick. Nothing else: no user, no trip, no session, and
coordinates only to three decimals.

Deliberately decoupled from the search endpoint. Correlating a response
with a later pick would mean handing out a ticket on POST /api/maps/search
and threading it through all fifteen call sites; the client already holds
everything the row needs at the moment of the pick.

Evaluation happens offline afterwards: export the corpus, run it against a
candidate index, count how often the pick came back in the top five. That
is why collection can start before such an index exists, which is the whole
point, because the corpus needs weeks of real use and the index needs a
server, and those two waits should not be serial.

Off unless an admin switches it on, and it reads === 'true' rather than
!== 'false'. The three older places switches fail open because a migration
backfilled a row for installs already using them; nothing uses this one, so
an instance that upgrades and never visits the admin panel logs nothing.

Reading is admin only, retention is 180 days, and the switch state is on the
unauthenticated app-config endpoint on purpose: whether an instance records
what its users search for is something a visitor may know before logging in.
This commit is contained in:
Maurice
2026-09-05 23:15:51 +02:00
committed by Maurice
parent 0a2fa32fc1
commit b3d69aa760
49 changed files with 1385 additions and 16 deletions
+35
View File
@@ -4257,6 +4257,41 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE journey_entries ADD COLUMN stats_excluded INTEGER NOT NULL DEFAULT 0');
}
},
/**
* Place shadow log: which search result a user actually picked.
*
* The corpus behind "would our own index have found that too". No user id,
* no trip, no session and no result list — the evaluation compares a query
* against a pick, and anything beyond that would be collecting for its own
* sake on an instance that promises not to.
*
* The table is created regardless of the switch: the switch decides whether
* rows are written, and a schema that appears only when a feature is
* enabled is a schema that differs between installs.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS place_shadow_picks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
query TEXT NOT NULL,
lang TEXT,
bias_lat REAL,
bias_lng REAL,
source TEXT NOT NULL,
live_rank INTEGER NOT NULL,
live_count INTEGER NOT NULL,
picked_name TEXT NOT NULL,
picked_lat REAL NOT NULL,
picked_lng REAL NOT NULL,
picked_place_id TEXT
)
`);
// Retention deletes by age, the export pages by id.
db.exec('CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)');
},
];
if (currentVersion < migrations.length) {
+10
View File
@@ -186,6 +186,16 @@ export class AddonsService {
getPlacesDetails() { return this.readFlag('places_details_enabled'); }
updatePlacesDetails(enabled: boolean) { return this.writeFlag('places_details_enabled', enabled); }
/**
* The shadow log, fail-CLOSED like the three above but for the opposite
* reason: they read `=== 'true'` because a migration backfilled a row for
* installs that were already using the feature. Nothing is using this one,
* so there is nothing to backfill and an absent row correctly means off.
* PlaceShadowService.enabled() reads the same key the same way.
*/
getPlaceShadow() { return this.readFlag('place_shadow_enabled'); }
updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); }
/**
* Enrichment reads fail-OPEN, unlike the three switches above.
*
+10
View File
@@ -233,6 +233,16 @@ export class AdminController {
return result;
}
@Get('place-shadow')
getPlaceShadow() { return this.addons.getPlaceShadow(); }
@Put('place-shadow')
updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlaceShadow(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.place_shadow', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('places-enrich')
getPlacesEnrich() { return this.addons.getPlacesEnrich(); }
+2 -1
View File
@@ -20,6 +20,7 @@ import { ManagedExtModule } from './managed/managed-ext.module';
import { MapsModule } from './maps/maps.module';
import { GeoModule } from './geo/geo.module';
import { PlaceEnrichmentModule } from './place-enrichment/place-enrichment.module';
import { PlaceShadowModule } from './place-shadow/place-shadow.module';
import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module';
import { NotificationsModule } from './notifications/notifications.module';
@@ -76,7 +77,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
+6
View File
@@ -285,6 +285,11 @@ export class AuthService {
const placesDetailsEnabled = placesDetailsSetting !== 'false';
const placesEnrichSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_enrich_enabled'")?.value;
const placesEnrichEnabled = placesEnrichSetting !== 'false';
// Fail-closed, and deliberately on this unauthenticated endpoint: whether an
// instance records what its users search for is something a visitor is
// entitled to know before logging in, not a detail to keep behind the door.
const placeShadowSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'")?.value;
const placeShadowEnabled = placeShadowSetting === 'true';
const setupComplete = userCount > 0 && !this.db.get("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1");
return {
@@ -333,6 +338,7 @@ export class AuthService {
places_autocomplete_enabled: placesAutocompleteEnabled,
places_details_enabled: placesDetailsEnabled,
places_enrich_enabled: placesEnrichEnabled,
place_shadow_enabled: placeShadowEnabled,
permissions: authenticatedUser ? this.permissions.getAllPermissions() : undefined,
// Case-sensitive on purpose (legacy parity).
dev_mode: readEnv().app.nodeEnv === 'development',
@@ -0,0 +1,54 @@
import { Body, Controller, Delete, Get, HttpCode, Post, Query, UseGuards } from '@nestjs/common';
import type {
PlaceShadowExportResult,
PlaceShadowPickResult,
PlaceShadowSummaryResult,
} from '@trek/shared';
import { AdminGuard } from '../auth/admin.guard';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { PlaceShadowPickDto } from './place-shadow.dto';
import { PlaceShadowService } from './place-shadow.service';
/**
* /api/place-shadow — the local corpus of "what was searched, what was picked".
*
* Writing is open to any signed-in user because any signed-in user is the one
* doing the picking; reading is admin only, because the corpus is everybody's
* searches and the person entitled to see that is the person running the
* instance. Nothing here leaves the instance on its own.
*/
@Controller('api/place-shadow')
@UseGuards(JwtAuthGuard)
export class PlaceShadowController {
constructor(private readonly shadow: PlaceShadowService) {}
/**
* 200 with `{ recorded: false }` when the log is off, rather than a 403.
* The client posts this and moves on; a rejection would be a permanent error
* in the console of every install that never switched the log on.
*/
@Post('pick')
@HttpCode(200)
pick(@Body() body: PlaceShadowPickDto): PlaceShadowPickResult {
return { recorded: this.shadow.record(body) };
}
@Get('summary')
@UseGuards(JwtAuthGuard, AdminGuard)
summary(): PlaceShadowSummaryResult {
return this.shadow.summary();
}
@Get('export')
@UseGuards(JwtAuthGuard, AdminGuard)
export(@Query('after') after?: string): PlaceShadowExportResult {
const parsed = Number(after);
return this.shadow.export(Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined);
}
@Delete()
@UseGuards(JwtAuthGuard, AdminGuard)
clear(): { removed: number } {
return { removed: this.shadow.clear() };
}
}
@@ -0,0 +1,9 @@
import { createZodDto } from 'nestjs-zod';
import { placeShadowPickRequestSchema } from '@trek/shared';
/**
* Zod-pipe wrapper for the shadow log body. Same pattern as every other write
* endpoint: the contract lives in @trek/shared, the class exists so a
* controller parameter can be typed with it.
*/
export class PlaceShadowPickDto extends createZodDto(placeShadowPickRequestSchema) {}
@@ -0,0 +1,37 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { logError, logInfo } from '../audit/audit-log.logger';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { PlaceShadowService } from './place-shadow.service';
/**
* Nightly retention for the shadow corpus.
*
* Runs even when the log is switched off, on purpose: switching it off should
* also let what it already collected age out, rather than freezing the rows in
* place until somebody remembers to press the wipe button.
*
* 3:40 AM keeps it clear of the 3:00 idempotency purge and the backup window.
*/
@Injectable()
export class PlaceShadowRetentionJob implements OnApplicationBootstrap {
constructor(
private readonly shadow: PlaceShadowService,
private readonly registrar: CronRegistrarService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
this.registrar.register('place-shadow-retention', '40 3 * * *', () => this.tick());
}
tick(): void {
try {
const removed = this.shadow.purgeExpired();
if (removed > 0) {
logInfo(`Place shadow retention: removed ${removed} expired row(s)`);
}
} catch (err: unknown) {
logError(`Place shadow retention: ${err instanceof Error ? err.message : err}`);
}
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { PlaceShadowController } from './place-shadow.controller';
import { PlaceShadowRetentionJob } from './place-shadow.job';
import { PlaceShadowService } from './place-shadow.service';
/**
* Place shadow log. Registered in AppModule; exports the service so the admin
* surface can read the summary without going through HTTP.
*/
@Module({
imports: [SchedulingModule],
controllers: [PlaceShadowController],
providers: [PlaceShadowService, PlaceShadowRetentionJob],
exports: [PlaceShadowService],
})
export class PlaceShadowModule {}
@@ -0,0 +1,196 @@
import { Injectable } from '@nestjs/common';
import type {
PlaceShadowExportResult,
PlaceShadowPickRequest,
PlaceShadowRow,
PlaceShadowSummaryResult,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
/** Rows older than this are removed nightly. */
export const RETENTION_DAYS = 180;
/** Cap on one export page, so a large corpus cannot be pulled in one response. */
export const EXPORT_PAGE_SIZE = 2000;
/**
* Coordinates are stored at three decimals, matching what the client sends for
* the bias. That is about 100 m, which is far finer than any ranking decision
* and far coarser than a home address.
*/
const COORD_DECIMALS = 3;
function round(value: number, decimals = COORD_DECIMALS): number {
const f = 10 ** decimals;
return Math.round(value * f) / f;
}
interface DbRow {
id: number;
created_at: string;
query: string;
lang: string | null;
bias_lat: number | null;
bias_lng: number | null;
source: string;
live_rank: number;
live_count: number;
picked_name: string;
picked_lat: number;
picked_lng: number;
picked_place_id: string | null;
}
function toRow(r: DbRow): PlaceShadowRow {
return {
id: r.id,
createdAt: r.created_at,
query: r.query,
lang: r.lang,
biasLat: r.bias_lat,
biasLng: r.bias_lng,
source: r.source,
liveRank: r.live_rank,
liveCount: r.live_count,
pickedName: r.picked_name,
pickedLat: r.picked_lat,
pickedLng: r.picked_lng,
pickedPlaceId: r.picked_place_id,
};
}
/**
* Records which search result a user actually picked, so a different index can
* be judged against real queries later.
*
* Off unless an admin switches it on, and it reads `=== 'true'` rather than
* `!== 'false'`: an absent row means off. That is the opposite of the three
* older places switches, which had to fail open because installs were already
* using those features when the switches arrived. Nothing is using this one, so
* the safe default is the silent one — an instance that upgrades and never
* visits the admin panel logs nothing.
*/
@Injectable()
export class PlaceShadowService {
constructor(private readonly db: DatabaseService) {}
enabled(): boolean {
const row = this.db.get<{ value: string }>(
"SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'",
);
return row?.value === 'true';
}
/**
* Returns whether the row was written. A disabled log is not an error: the
* client fires this and forgets it, and a 403 in the console every time
* somebody adds a place would be noise about a feature that is off on
* purpose.
*/
record(pick: PlaceShadowPickRequest): boolean {
if (!this.enabled()) return false;
// A rank outside the returned list means the client and the server disagree
// about what was on screen. Storing it would poison the very number the
// corpus exists to produce, so the row is dropped instead.
if (pick.liveRank >= pick.liveCount) return false;
this.db.run(
`INSERT INTO place_shadow_picks
(query, lang, bias_lat, bias_lng, source, live_rank, live_count,
picked_name, picked_lat, picked_lng, picked_place_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
pick.query,
pick.lang ?? null,
pick.biasLat === undefined ? null : round(pick.biasLat),
pick.biasLng === undefined ? null : round(pick.biasLng),
pick.source,
pick.liveRank,
pick.liveCount,
pick.pickedName,
round(pick.pickedLat),
round(pick.pickedLng),
pick.pickedPlaceId ?? null,
);
return true;
}
/**
* One page of the corpus, oldest first. `after` is the last id of the
* previous page; paging by id rather than by offset keeps the pages stable
* while new rows arrive underneath.
*/
export(after?: number, limit = EXPORT_PAGE_SIZE): PlaceShadowExportResult {
const size = Math.min(Math.max(1, limit), EXPORT_PAGE_SIZE);
const rows = this.db.all<DbRow>(
`SELECT * FROM place_shadow_picks
WHERE id > ?
ORDER BY id
LIMIT ?`,
after ?? 0,
size + 1,
);
const page = rows.slice(0, size);
return {
version: 1,
generatedAt: new Date().toISOString(),
rows: page.map(toRow),
nextAfter: rows.length > size && page.length > 0 ? page[page.length - 1].id : null,
};
}
summary(): PlaceShadowSummaryResult {
const enabled = this.enabled();
const totals = this.db.get<{ total: number; oldest: string | null; newest: string | null }>(
'SELECT COUNT(*) AS total, MIN(created_at) AS oldest, MAX(created_at) AS newest FROM place_shadow_picks',
);
const total = totals?.total ?? 0;
const bySource = this.db.all<{ source: string; count: number }>(
`SELECT source, COUNT(*) AS count FROM place_shadow_picks
GROUP BY source ORDER BY count DESC`,
);
// Buckets rather than a mean: the question is "did the user find it near
// the top", and an average rank is dragged around by the rare query that
// scrolled to result 40.
const ranks = this.db.all<{ live_rank: number; count: number }>(
'SELECT live_rank, COUNT(*) AS count FROM place_shadow_picks GROUP BY live_rank',
);
const counted = (test: (rank: number) => boolean): number =>
ranks.filter(r => test(r.live_rank)).reduce((sum, r) => sum + r.count, 0);
const topOne = counted(r => r === 0);
const topFive = counted(r => r < 5);
return {
enabled,
total,
oldest: totals?.oldest ?? null,
newest: totals?.newest ?? null,
retentionDays: RETENTION_DAYS,
bySource,
liveRankBuckets: [
{ bucket: '1', count: topOne },
{ bucket: '2-5', count: counted(r => r >= 1 && r < 5) },
{ bucket: '6-10', count: counted(r => r >= 5 && r < 10) },
{ bucket: '11+', count: counted(r => r >= 10) },
],
liveTopOneShare: total ? topOne / total : 0,
liveTopFiveShare: total ? topFive / total : 0,
};
}
/** Admin wipe. Returns how many rows went. */
clear(): number {
return this.db.run('DELETE FROM place_shadow_picks').changes;
}
/** Nightly retention. Returns how many rows went. */
purgeExpired(retentionDays = RETENTION_DAYS): number {
return this.db.run(
`DELETE FROM place_shadow_picks
WHERE created_at < datetime('now', ?)`,
`-${retentionDays} days`,
).changes;
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* /api/place-shadow e2e — the real JwtAuthGuard, the real AdminGuard and the
* real Zod pipe against a temp SQLite db.
*
* The three things worth booting a server for: that a non-admin cannot read
* other people's searches, that a switched-off log answers 200 instead of an
* error the client would log forever, and that the pipe rejects a malformed
* body before the service ever sees it.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { APP_PIPE } from '@nestjs/core';
import { ZodValidationPipe } from 'nestjs-zod';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
tmp.exec(`CREATE TABLE place_shadow_picks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
query TEXT NOT NULL, lang TEXT, bias_lat REAL, bias_lng REAL, source TEXT NOT NULL,
live_rank INTEGER NOT NULL, live_count INTEGER NOT NULL,
picked_name TEXT NOT NULL, picked_lat REAL NOT NULL, picked_lng REAL NOT NULL,
picked_place_id TEXT);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
import { PlaceShadowModule } from '../../src/nest/place-shadow/place-shadow.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const PICK = {
query: 'kaffee bar am dobi',
lang: 'de',
source: 'search:nominatim',
liveRank: 1,
liveCount: 5,
pickedName: 'Baltic Brothers Coffee',
pickedLat: 54.0885,
pickedLng: 12.1395,
};
const USER = 1;
const ADMIN = 2;
function enable(on: boolean) {
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
.run('place_shadow_enabled', on ? 'true' : 'false');
}
describe('/api/place-shadow e2e (real guards + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [DatabaseModule, PlaceShadowModule],
providers: [{ provide: APP_PIPE, useClass: ZodValidationPipe }],
}).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: USER });
seedUser(db as never, { id: ADMIN, email: 'admin@example.com', role: 'admin' });
app = await build();
server = app.getHttpServer();
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
db.prepare('DELETE FROM place_shadow_picks').run();
enable(true);
});
describe('POST pick', () => {
it('401 without a cookie', async () => {
expect((await request(server).post('/api/place-shadow/pick').send(PICK)).status).toBe(401);
});
it('200 { recorded: true } and one row for a signed-in user', async () => {
const res = await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK);
expect(res.status).toBe(200);
expect(res.body).toEqual({ recorded: true });
expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 1 });
});
it('200 { recorded: false } while the log is off, not an error status', async () => {
enable(false);
const res = await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK);
expect(res.status).toBe(200);
expect(res.body).toEqual({ recorded: false });
expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
});
it('400 from the pipe on a malformed body, and nothing is written', async () => {
for (const bad of [
{ ...PICK, query: '' },
{ ...PICK, pickedLat: 200 },
{ ...PICK, liveRank: -1 },
{ ...PICK, source: undefined },
{ ...PICK, query: 'x'.repeat(201) },
]) {
const res = await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(bad);
expect(res.status, JSON.stringify(bad).slice(0, 60)).toBe(400);
}
expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
});
});
describe('reading is admin only', () => {
for (const [method, path] of [['get', '/api/place-shadow/summary'], ['get', '/api/place-shadow/export'], ['delete', '/api/place-shadow']] as const) {
it(`403 for a non-admin on ${method.toUpperCase()} ${path}`, async () => {
expect((await request(server)[method](path).set('Cookie', sessionCookie(USER))).status).toBe(403);
});
it(`401 without a cookie on ${method.toUpperCase()} ${path}`, async () => {
expect((await request(server)[method](path)).status).toBe(401);
});
}
it('an admin gets the summary', async () => {
await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK);
await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send({ ...PICK, liveRank: 0 });
const res = await request(server).get('/api/place-shadow/summary').set('Cookie', sessionCookie(ADMIN));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
enabled: true,
total: 2,
bySource: [{ source: 'search:nominatim', count: 2 }],
liveTopOneShare: 0.5,
liveTopFiveShare: 1,
});
});
it('an admin gets the corpus, and the wipe empties it', async () => {
await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK);
const dump = await request(server).get('/api/place-shadow/export').set('Cookie', sessionCookie(ADMIN));
expect(dump.status).toBe(200);
expect(dump.body.version).toBe(1);
expect(dump.body.rows).toHaveLength(1);
expect(dump.body.rows[0]).toMatchObject({ query: PICK.query, pickedName: PICK.pickedName, liveRank: 1 });
const wiped = await request(server).delete('/api/place-shadow').set('Cookie', sessionCookie(ADMIN));
expect(wiped.status).toBe(200);
expect(wiped.body).toEqual({ removed: 1 });
expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
});
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from 'vitest';
import { PlaceShadowController } from '../../../src/nest/place-shadow/place-shadow.controller';
import type { PlaceShadowService } from '../../../src/nest/place-shadow/place-shadow.service';
import type { PlaceShadowPickDto } from '../../../src/nest/place-shadow/place-shadow.dto';
function makeController(svc: Partial<PlaceShadowService>) {
return new PlaceShadowController(svc as PlaceShadowService);
}
const BODY = {
query: 'ecklife rostock',
source: 'search:nominatim',
liveRank: 2,
liveCount: 6,
pickedName: 'Ecklife',
pickedLat: 54.083,
pickedLng: 12.132,
} as PlaceShadowPickDto;
describe('PlaceShadowController', () => {
describe('POST /api/place-shadow/pick', () => {
it('answers 200 { recorded: false } when the log is off, never an error', () => {
const record = vi.fn().mockReturnValue(false);
expect(makeController({ record }).pick(BODY)).toEqual({ recorded: false });
expect(record).toHaveBeenCalledWith(BODY);
});
it('reports a written row', () => {
expect(makeController({ record: vi.fn().mockReturnValue(true) }).pick(BODY)).toEqual({ recorded: true });
});
});
describe('GET /api/place-shadow/export', () => {
const page = { version: 1 as const, generatedAt: 'now', rows: [], nextAfter: null };
it('passes a positive cursor through', () => {
const exp = vi.fn().mockReturnValue(page);
makeController({ export: exp }).export('42');
expect(exp).toHaveBeenCalledWith(42);
});
it('starts from the beginning for anything that is not a positive integer', () => {
const exp = vi.fn().mockReturnValue(page);
const c = makeController({ export: exp });
for (const bad of [undefined, '', 'abc', '0', '-5', '1.5', '1e400']) {
c.export(bad);
}
expect(exp.mock.calls.every(([arg]) => arg === undefined)).toBe(true);
});
});
describe('DELETE /api/place-shadow', () => {
it('reports how many rows went', () => {
expect(makeController({ clear: vi.fn().mockReturnValue(17) }).clear()).toEqual({ removed: 17 });
});
});
describe('GET /api/place-shadow/summary', () => {
it('hands the service summary straight back', () => {
const summary = { enabled: true, total: 3 };
expect(makeController({ summary: vi.fn().mockReturnValue(summary) }).summary()).toBe(summary);
});
});
});
@@ -0,0 +1,65 @@
/**
* The shadow log's nightly retention provider: registration, the test gate, and
* that a failing purge cannot take the scheduler down with it.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
import { PlaceShadowRetentionJob } from '../../../src/nest/place-shadow/place-shadow.job';
import type { PlaceShadowService } from '../../../src/nest/place-shadow/place-shadow.service';
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
function registrarStub(enabled = true) {
return { isEnabled: vi.fn(() => enabled), register: vi.fn(() => enabled), unregister: vi.fn() };
}
function makeJob(shadow: Partial<PlaceShadowService>, registrar = registrarStub()) {
return {
job: new PlaceShadowRetentionJob(shadow as PlaceShadowService, registrar as unknown as CronRegistrarService),
registrar,
};
}
beforeEach(() => vi.clearAllMocks());
describe('PlaceShadowRetentionJob', () => {
it('registers at 3:40, clear of the 3:00 idempotency purge', () => {
const { job, registrar } = makeJob({ purgeExpired: vi.fn(() => 0) });
job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('place-shadow-retention', '40 3 * * *', expect.any(Function));
});
it('does not register under the test gate', () => {
const { job, registrar } = makeJob({ purgeExpired: vi.fn() }, registrarStub(false));
job.onApplicationBootstrap();
expect(registrar.register).not.toHaveBeenCalled();
});
it('logs only when it actually removed something', () => {
const { job } = makeJob({ purgeExpired: vi.fn(() => 0) });
job.tick();
expect(logMock.logInfo).not.toHaveBeenCalled();
const loud = makeJob({ purgeExpired: vi.fn(() => 12) });
loud.job.tick();
expect(logMock.logInfo).toHaveBeenCalledWith(expect.stringContaining('12'));
});
it('contains a failing purge instead of letting it escape into the scheduler', () => {
const { job } = makeJob({ purgeExpired: vi.fn(() => { throw new Error('database is locked'); }) });
expect(() => job.tick()).not.toThrow();
expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
});
it('runs the purge even while the log itself is switched off', () => {
// Switching collection off should let what it already gathered age out, so
// the job must not consult `enabled()` before purging.
const enabled = vi.fn(() => false);
const purgeExpired = vi.fn(() => 3);
makeJob({ enabled, purgeExpired }).job.tick();
expect(purgeExpired).toHaveBeenCalled();
expect(enabled).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,241 @@
/**
* PlaceShadowService against a real in-memory database.
*
* The interesting parts of this service ARE the SQL — the id-paged export, the
* rank bucketing, the age-based retention — so a mocked DatabaseService would
* assert that the strings were passed along and prove nothing about what they
* do. The table is created here rather than by running the migration array, so
* appending the next migration cannot drag this file along.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { PlaceShadowService, RETENTION_DAYS } from '../../../src/nest/place-shadow/place-shadow.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PlaceShadowPickRequest } from '@trek/shared';
const SCHEMA = `
CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE place_shadow_picks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
query TEXT NOT NULL,
lang TEXT,
bias_lat REAL,
bias_lng REAL,
source TEXT NOT NULL,
live_rank INTEGER NOT NULL,
live_count INTEGER NOT NULL,
picked_name TEXT NOT NULL,
picked_lat REAL NOT NULL,
picked_lng REAL NOT NULL,
picked_place_id TEXT
);
`;
let conn: Database.Database;
let svc: PlaceShadowService;
/** Just the three methods the service uses, backed by the real connection. */
function dbFacade(c: Database.Database): DatabaseService {
return {
get: <T>(sql: string, ...p: unknown[]) => c.prepare(sql).get(...p) as T | undefined,
all: <T>(sql: string, ...p: unknown[]) => c.prepare(sql).all(...p) as T[],
run: (sql: string, ...p: unknown[]) => c.prepare(sql).run(...p),
} as unknown as DatabaseService;
}
const PICK: PlaceShadowPickRequest = {
query: 'losteria rostock',
lang: 'de',
biasLat: 54.0887,
biasLng: 12.1404,
source: 'search:nominatim',
liveRank: 0,
liveCount: 8,
pickedName: "L'Osteria",
pickedLat: 54.0891,
pickedLng: 12.1372,
pickedPlaceId: 'osm:node/1',
};
function enable(on = true) {
conn.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
.run('place_shadow_enabled', on ? 'true' : 'false');
}
beforeEach(() => {
conn = new Database(':memory:');
conn.exec(SCHEMA);
svc = new PlaceShadowService(dbFacade(conn));
});
describe('PlaceShadowService', () => {
describe('the switch', () => {
it('is off when the setting row is absent', () => {
expect(svc.enabled()).toBe(false);
expect(svc.record(PICK)).toBe(false);
expect(conn.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
});
it('is off for any value that is not exactly "true"', () => {
for (const value of ['false', '1', 'yes', 'TRUE', '']) {
conn.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
.run('place_shadow_enabled', value);
expect(svc.enabled(), `value ${JSON.stringify(value)}`).toBe(false);
}
});
it('writes once switched on', () => {
enable();
expect(svc.record(PICK)).toBe(true);
expect(svc.summary().total).toBe(1);
});
});
describe('record', () => {
beforeEach(() => enable());
it('rounds every coordinate to three decimals', () => {
svc.record({ ...PICK, pickedLat: 54.0891234, pickedLng: 12.1372987, biasLat: 54.08871, biasLng: 12.14049 });
const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, number>;
expect(row.picked_lat).toBe(54.089);
expect(row.picked_lng).toBe(12.137);
expect(row.bias_lat).toBe(54.089);
expect(row.bias_lng).toBe(12.14);
});
it('stores an absent bias and place id as NULL rather than inventing zeroes', () => {
svc.record({ ...PICK, biasLat: undefined, biasLng: undefined, pickedPlaceId: undefined });
const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, unknown>;
expect(row.bias_lat).toBeNull();
expect(row.bias_lng).toBeNull();
expect(row.picked_place_id).toBeNull();
});
it('drops a row whose rank is outside the list it claims to come from', () => {
expect(svc.record({ ...PICK, liveRank: 8, liveCount: 8 })).toBe(false);
expect(svc.record({ ...PICK, liveRank: 99, liveCount: 3 })).toBe(false);
expect(svc.summary().total).toBe(0);
// The boundary case one below is a legitimate last-result pick.
expect(svc.record({ ...PICK, liveRank: 7, liveCount: 8 })).toBe(true);
});
});
describe('summary', () => {
beforeEach(() => {
enable();
const ranks = [0, 0, 0, 1, 4, 5, 9, 10, 42];
ranks.forEach((rank, i) =>
svc.record({ ...PICK, liveRank: rank, liveCount: 50, source: i % 2 ? 'search:nominatim' : 'autocomplete:google' }),
);
});
it('buckets the live rank instead of averaging it', () => {
expect(svc.summary().liveRankBuckets).toEqual([
{ bucket: '1', count: 3 },
{ bucket: '2-5', count: 2 },
{ bucket: '6-10', count: 2 },
{ bucket: '11+', count: 2 },
]);
});
it('reports the top-one and top-five shares over the whole corpus', () => {
const s = svc.summary();
expect(s.total).toBe(9);
expect(s.liveTopOneShare).toBeCloseTo(3 / 9, 6);
expect(s.liveTopFiveShare).toBeCloseTo(5 / 9, 6);
});
it('counts by source, busiest first', () => {
expect(svc.summary().bySource).toEqual([
{ source: 'autocomplete:google', count: 5 },
{ source: 'search:nominatim', count: 4 },
]);
});
it('answers on an empty corpus without dividing by zero', () => {
conn.exec('DELETE FROM place_shadow_picks');
const s = svc.summary();
expect(s).toMatchObject({ total: 0, liveTopOneShare: 0, liveTopFiveShare: 0, oldest: null, newest: null });
expect(s.retentionDays).toBe(RETENTION_DAYS);
});
it('reports the switch state it was asked about', () => {
expect(svc.summary().enabled).toBe(true);
enable(false);
expect(svc.summary().enabled).toBe(false);
});
});
describe('export', () => {
beforeEach(() => {
enable();
for (let i = 0; i < 7; i++) svc.record({ ...PICK, query: `q${i}` });
});
it('pages by id and hands back the cursor for the next page', () => {
const first = svc.export(undefined, 3);
expect(first.rows.map(r => r.query)).toEqual(['q0', 'q1', 'q2']);
expect(first.nextAfter).toBe(first.rows[2].id);
const second = svc.export(first.nextAfter ?? undefined, 3);
expect(second.rows.map(r => r.query)).toEqual(['q3', 'q4', 'q5']);
const third = svc.export(second.nextAfter ?? undefined, 3);
expect(third.rows.map(r => r.query)).toEqual(['q6']);
// Nothing beyond, so no cursor — that is how a reader knows to stop.
expect(third.nextAfter).toBeNull();
});
it('maps snake_case columns onto the camelCase contract', () => {
const row = svc.export(undefined, 1).rows[0];
expect(row).toMatchObject({
query: 'q0',
lang: 'de',
source: 'search:nominatim',
liveRank: 0,
liveCount: 8,
pickedName: "L'Osteria",
pickedPlaceId: 'osm:node/1',
});
expect(typeof row.createdAt).toBe('string');
});
it('stamps the version so an evaluator can refuse a dump it does not know', () => {
expect(svc.export().version).toBe(1);
});
});
describe('retention and wipe', () => {
beforeEach(() => enable());
it('removes only rows past the window', () => {
svc.record(PICK);
svc.record(PICK);
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = 1").run();
expect(svc.purgeExpired()).toBe(1);
expect(svc.summary().total).toBe(1);
});
it('keeps a row that is one day short of the window', () => {
svc.record(PICK);
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', ?)")
.run(`-${RETENTION_DAYS - 1} days`);
expect(svc.purgeExpired()).toBe(0);
});
it('runs even while the log is switched off, so old rows still age out', () => {
svc.record(PICK);
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days')").run();
enable(false);
expect(svc.purgeExpired()).toBe(1);
});
it('clear() empties the table and reports how many went', () => {
svc.record(PICK);
svc.record(PICK);
expect(svc.clear()).toBe(2);
expect(svc.summary().total).toBe(0);
});
});
});