From 49c3c57c313d11fa7b539fc05f28232b06ac5cad Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 10 Aug 2026 18:15:14 +0200 Subject: [PATCH] Give the ephemeral tokens a provider, and clear the next wave of type errors ephemeral-tokens.ts was free functions, and AuthService owned the cleanup timer only because it happened to be the domain that mints tokens. Issuing them and sweeping them are different jobs, and FilesService and TokenService mint or consume them too. EphemeralTokenService owns the lifecycle and hands the three operations out. The store and the interval stay module state next door, and that is load-bearing rather than a leftover: src/websocket.ts consumes ws tokens on the upgrade handshake, outside the container, before any injector is in reach. Per-instance state would hand it an empty Map and close every connection with 4001, which no test would catch because the ws suites drive the transport directly. WS-027 through WS-029 pin the session gate that runs through exactly that path. The MAX_STORE_SIZE back-pressure is the second reason: createEphemeralToken returns null at 10,000 live tokens and TokenService turns that into a 503. A second store would raise the real ceiling with nobody noticing. Its own leaf module rather than a provider in AuthModule: TokensModule mints download and ws tokens as well, AuthModule already imports TokensModule, and TokensModule is documented as a deliberate leaf that three graphs reach for. Same shape as MailerModule, which broke AuthModule <-> NotificationsModule. Twelve hand-wired construction sites in tests needed the new collaborator. The typecheck named all twelve; before this branch they would have been undefined at runtime. Sixteen more test files are cleared here too, each at its root cause. Four of them were passing for the wrong reason: files.controller.test.ts drove two handlers with shifted arguments, a string tripId where the trip row belongs and undefined where tripId and id belong. It stayed green because FilesService is fully stubbed and the stubs ignore what they are given, so DELETE /:id/permanent and DELETE /trash/empty never covered that the trip row reaches the permission check. conflictUpdate.test.ts was missing real collaborators on PlacesService and PackingService. None of the exercised paths reach them, so it passed by luck. assignments.service.test.ts builds its service by hand, and the one consumer of the collaborator it was missing swallows every throw in a bare catch. A broken journey reconciliation would still have shown 28 green tests. scheduler.test.ts mocks two modules with paths that resolve one directory above the server root, so they mock nothing at all. Harmless only because src/scheduler.ts imports neither. --- server/src/nest/admin/admin.bridge.ts | 3 +- server/src/nest/auth/auth.bridge.ts | 4 +- server/src/nest/auth/auth.module.ts | 3 +- server/src/nest/auth/auth.service.ts | 18 +---- .../src/nest/auth/ephemeral-token.module.ts | 19 ++++++ .../src/nest/auth/ephemeral-token.service.ts | 59 +++++++++++++++++ server/src/nest/files/files.bridge.ts | 3 +- server/src/nest/files/files.module.ts | 3 +- server/src/nest/files/files.service.ts | 5 +- server/src/nest/tokens/token.service.ts | 11 ++-- server/src/nest/tokens/tokens.module.ts | 2 + server/src/nest/trips/trips.bridge.ts | 3 +- server/tests/helpers/mcp-test-controllers.ts | 5 +- server/tests/helpers/plugin-host.ts | 3 +- server/tests/unit/auto-backup.test.ts | 14 +++- .../unit/mcp/tools-budget-advanced.test.ts | 10 +-- server/tests/unit/nest/addons.service.test.ts | 31 +++++++-- server/tests/unit/nest/admin.service.test.ts | 3 +- .../nest/admin.version-notification.test.ts | 3 +- .../unit/nest/assignments.service.test.ts | 29 ++++++-- server/tests/unit/nest/auth.service.test.ts | 4 +- .../tests/unit/nest/files.controller.test.ts | 10 +-- server/tests/unit/nest/files.service.test.ts | 35 +++++++--- server/tests/unit/nest/maps.service.test.ts | 23 +++++-- .../nest/place-enrichment.service.test.ts | 10 ++- server/tests/unit/nest/places.service.test.ts | 34 +++++++++- server/tests/unit/nest/token.service.test.ts | 3 +- .../unit/nest/trip-read-model.service.test.ts | 3 +- .../tests/unit/nest/trips.controller.test.ts | 13 +++- server/tests/unit/nest/trips.service.test.ts | 3 +- server/tests/unit/places/places.rpc.test.ts | 22 +++++-- .../unit/plugins/operator-egress.test.ts | 12 +++- .../unit/plugins/supervisor-lifecycle.test.ts | 3 +- server/tests/unit/scheduler.test.ts | 66 +++++++++++-------- .../unit/services/conflictUpdate.test.ts | 25 ++++++- .../unit/systemNotices/conditions.test.ts | 29 ++++---- 36 files changed, 402 insertions(+), 124 deletions(-) create mode 100644 server/src/nest/auth/ephemeral-token.module.ts create mode 100644 server/src/nest/auth/ephemeral-token.service.ts diff --git a/server/src/nest/admin/admin.bridge.ts b/server/src/nest/admin/admin.bridge.ts index 74e6dc3a0..8cad2020e 100644 --- a/server/src/nest/admin/admin.bridge.ts +++ b/server/src/nest/admin/admin.bridge.ts @@ -16,6 +16,7 @@ import { NtfyService } from '../notifications/transports/ntfy.service'; import { WebhookService } from '../notifications/transports/webhook.service'; import { NotificationsService } from '../notifications/notifications.service'; import { AdminService } from './admin.service'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; /** * Non-Nest entry point for the admin domain — for code running OUTSIDE the Nest @@ -40,7 +41,7 @@ const webauthn = new WebauthnConfigService(dbs); const userCleanup = new UserCleanupService(dbs); const mailer = new MailerService(dbs); const notifPrefs = new NotificationPreferencesService(dbs, mailer); -const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, mailer); +const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, mailer, new EphemeralTokenService()); const admin = new AdminService( dbs, new AddonsService(dbs), diff --git a/server/src/nest/auth/auth.bridge.ts b/server/src/nest/auth/auth.bridge.ts index 1de18f0e6..6127a243f 100644 --- a/server/src/nest/auth/auth.bridge.ts +++ b/server/src/nest/auth/auth.bridge.ts @@ -8,6 +8,7 @@ import { MailerService } from '../notifications/mailer/mailer.service'; import { UserCleanupService } from './user-cleanup.service'; import { WebauthnConfigService } from './webauthn-config.service'; import { User } from '../../types'; +import { EphemeralTokenService } from './ephemeral-token.service'; /** * Non-Nest entry point for the auth domain — for code running OUTSIDE the @@ -45,9 +46,10 @@ const auth = new AuthService( new WebauthnConfigService(dbs), new UserCleanupService(dbs), new MailerService(dbs), + new EphemeralTokenService(), ); -const tokens = new TokenService(dbs); +const tokens = new TokenService(dbs, new EphemeralTokenService()); export { stripUserForClient } from './auth.helpers'; export { avatarUrl } from '../common/avatarUrl'; diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index e87a5fcf9..f970ad50c 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -16,6 +16,7 @@ import { AuditModule } from '../audit/audit.module'; import { MailerModule } from '../notifications/mailer/mailer.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; +import { EphemeralTokenModule } from './ephemeral-token.module'; /** * Auth module — public flows (login/register/reset/mfa-verify/logout) and the @@ -38,7 +39,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' * through auth.bridge.ts. */ @Module({ - imports: [RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule], + imports: [EphemeralTokenModule, RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule], controllers: [AuthPublicController, AuthController, PasskeyController], providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp], exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService], diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index bfb6a6db4..a0dd80b81 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -14,7 +14,7 @@ import { PermissionsService } from '../permissions/permissions.service'; import { validatePassword } from '../common/passwordPolicy'; import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto'; import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto'; -import { createEphemeralToken, startTokenCleanup, stopTokenCleanup } from './ephemeral-tokens'; +import { EphemeralTokenService } from './ephemeral-token.service'; // Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls // the whole tools fan-out (and via the domain bridges, the Nest services) into // every consumer of this module — a nest→mcp→nest module cycle. @@ -118,20 +118,7 @@ export interface ResetPasswordOutcome { * AtlasModule import AuthModule instead of going the other way around. */ @Injectable() -export class AuthService implements OnModuleInit, OnModuleDestroy { - /** - * The ephemeral-token store sweeps itself every minute. index.ts used to kick - * that off with a bare require() after listen(); it belongs to the domain that - * issues the tokens. - */ - onModuleInit(): void { - startTokenCleanup(); - } - - onModuleDestroy(): void { - stopTokenCleanup(); - } - +export class AuthService { constructor( private readonly db: DatabaseService, private readonly permissions: PermissionsService, @@ -139,6 +126,7 @@ export class AuthService implements OnModuleInit, OnModuleDestroy { private readonly webauthn: WebauthnConfigService, private readonly userCleanup: UserCleanupService, private readonly mailer: MailerService, + private readonly tokens: EphemeralTokenService, ) {} // Cookie diff --git a/server/src/nest/auth/ephemeral-token.module.ts b/server/src/nest/auth/ephemeral-token.module.ts new file mode 100644 index 000000000..c26e02226 --- /dev/null +++ b/server/src/nest/auth/ephemeral-token.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { EphemeralTokenService } from './ephemeral-token.service'; + +/** + * A leaf module whose only job is to hand EphemeralTokenService out. + * + * It cannot live in AuthModule: TokensModule mints download and ws tokens too, + * and AuthModule already imports TokensModule, so importing it back would close + * a cycle. TokensModule is also documented as a deliberate leaf that three + * graphs reach for, so a dependency added there lands in all of them. + * + * Same shape and same reason as MailerModule, which broke + * AuthModule <-> NotificationsModule, and PluginGuardsModule. + */ +@Module({ + providers: [EphemeralTokenService], + exports: [EphemeralTokenService], +}) +export class EphemeralTokenModule {} diff --git a/server/src/nest/auth/ephemeral-token.service.ts b/server/src/nest/auth/ephemeral-token.service.ts new file mode 100644 index 000000000..7cbc476da --- /dev/null +++ b/server/src/nest/auth/ephemeral-token.service.ts @@ -0,0 +1,59 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { + createEphemeralToken, + consumeEphemeralToken, + consumeEphemeralTokenWithMeta, + startTokenCleanup, + stopTokenCleanup, + type EphemeralTokenMeta, +} from './ephemeral-tokens'; + +/** + * The injectable face of the ephemeral-token store, and the owner of its + * cleanup timer. + * + * The store itself stays module state next door, and that is load-bearing + * rather than a leftover: `src/websocket.ts` consumes ws tokens from OUTSIDE + * the container, on the upgrade handshake, before any injector is in reach. + * Per-instance state would hand it an empty Map and every connection would + * close with 4001 "Invalid or expired token" — a failure that no test sees, + * because the ws suites drive the transport directly. Same reasoning as the + * geo throttle cursor and the permissions cache. + * + * The MAX_STORE_SIZE back-pressure is the other reason: `createEphemeralToken` + * returns null at 10,000 live tokens and TokenService turns that into a 503. + * A second store would raise the real ceiling without anyone noticing. + * + * The lifecycle moved off AuthService, which owned it only because it was the + * domain that mints tokens. Issuing them and sweeping them are different jobs, + * and FilesService and TokenService mint or consume them too. + */ +@Injectable() +export class EphemeralTokenService implements OnModuleInit, OnModuleDestroy { + onModuleInit(): void { + startTokenCleanup(); + } + + onModuleDestroy(): void { + stopTokenCleanup(); + } + + /** Returns null when the store is at capacity; callers answer 503. */ + create(userId: number, purpose: string, meta?: EphemeralTokenMeta): string | null { + return createEphemeralToken(userId, purpose, meta); + } + + /** Single-use: the token is deleted whether or not it validates. */ + consume(token: string, purpose: string): number | null { + return consumeEphemeralToken(token, purpose); + } + + /** + * The same, plus the password_version the token was minted with. Only the ws + * handshake needs it, for the session gate that rejects a token minted before + * a password change. + */ + consumeWithMeta(token: string, purpose: string): { userId: number; pv?: number } | null { + return consumeEphemeralTokenWithMeta(token, purpose); + } +} diff --git a/server/src/nest/files/files.bridge.ts b/server/src/nest/files/files.bridge.ts index ab0cc6bb0..45bc1d9c8 100644 --- a/server/src/nest/files/files.bridge.ts +++ b/server/src/nest/files/files.bridge.ts @@ -3,6 +3,7 @@ import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; import { FilesService } from './files.service'; import { PermissionsService } from '../permissions/permissions.service'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; /** * Non-Nest entry point for the files domain. Unlike todo.bridge.ts this does @@ -16,7 +17,7 @@ import { PermissionsService } from '../permissions/permissions.service'; * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. */ -const files = new FilesService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService()); +const files = new FilesService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService(), new EphemeralTokenService()); export function getAllowedExtensions(): string { return files.getAllowedExtensions(); diff --git a/server/src/nest/files/files.module.ts b/server/src/nest/files/files.module.ts index fd0277209..12946c0a0 100644 --- a/server/src/nest/files/files.module.ts +++ b/server/src/nest/files/files.module.ts @@ -7,9 +7,10 @@ import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { AppConfigModule } from '../app-config/app-config.module'; +import { EphemeralTokenModule } from '../auth/ephemeral-token.module'; @Module({ - imports: [PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule], + imports: [EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule], controllers: [FilesController, FilesDownloadController], providers: [FilesService, FilesRpc], exports: [FilesService], diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index 5f37cb73f..33b52768b 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -6,7 +6,7 @@ import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { avatarUrl } from '../common/avatarUrl'; -import { consumeEphemeralToken } from '../auth/ephemeral-tokens'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; import { verifyJwtAndLoadUser } from '../auth/jwt-verify'; import type { User, TripFile } from '../../types'; import { DatabaseService, type TripAccess } from '../database/database.service'; @@ -59,6 +59,7 @@ export class FilesService { private readonly db: DatabaseService, private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, + private readonly tokens: EphemeralTokenService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -103,7 +104,7 @@ export class FilesService { } if (queryToken) { - const uid = consumeEphemeralToken(queryToken, 'download'); + const uid = this.tokens.consume(queryToken, 'download'); if (!uid) return { error: 'Invalid or expired token', status: 401 }; return { userId: uid }; } diff --git a/server/src/nest/tokens/token.service.ts b/server/src/nest/tokens/token.service.ts index c8bdca40e..bb028f265 100644 --- a/server/src/nest/tokens/token.service.ts +++ b/server/src/nest/tokens/token.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { randomBytes, createHash } from 'crypto'; import { DatabaseService } from '../database/database.service'; -import { createEphemeralToken } from '../auth/ephemeral-tokens'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; // Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls // the whole tools fan-out (and via the domain bridges, the Nest services) into // every consumer of this module — a nest→mcp→nest module cycle. @@ -27,7 +27,10 @@ import { User } from '../../types'; */ @Injectable() export class TokenService { - constructor(private readonly db: DatabaseService) {} + constructor( + private readonly db: DatabaseService, + private readonly ephemeral: EphemeralTokenService, + ) {} listMcpTokens(userId: number) { return this.db.all( @@ -79,7 +82,7 @@ export class TokenService { // Bind the ws-token to the user's current password_version so a token minted // before a password reset is rejected on connect (defence-in-depth session gate). const pv = this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', userId)?.password_version ?? 0; - const token = createEphemeralToken(userId, 'ws', { pv }); + const token = this.ephemeral.create(userId, 'ws', { pv }); if (!token) return { error: 'Service unavailable', status: 503 }; return { token }; } @@ -89,7 +92,7 @@ export class TokenService { if (purpose !== 'download') { return { error: 'Invalid purpose', status: 400 }; } - const token = createEphemeralToken(userId, purpose); + const token = this.ephemeral.create(userId, purpose); if (!token) return { error: 'Service unavailable', status: 503 }; return { token }; } diff --git a/server/src/nest/tokens/tokens.module.ts b/server/src/nest/tokens/tokens.module.ts index 3b4b8c38e..dea63a7a4 100644 --- a/server/src/nest/tokens/tokens.module.ts +++ b/server/src/nest/tokens/tokens.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TokenService } from './token.service'; +import { EphemeralTokenModule } from '../auth/ephemeral-token.module'; /** * Tokens that are not the login JWT: the long-lived MCP tokens a user manages @@ -10,6 +11,7 @@ import { TokenService } from './token.service'; * for it, and any dependency added here lands in all three graphs. */ @Module({ + imports: [EphemeralTokenModule], providers: [TokenService], exports: [TokenService], }) diff --git a/server/src/nest/trips/trips.bridge.ts b/server/src/nest/trips/trips.bridge.ts index d50b80548..66a43b081 100644 --- a/server/src/nest/trips/trips.bridge.ts +++ b/server/src/nest/trips/trips.bridge.ts @@ -25,6 +25,7 @@ import { AccommodationsService } from '../accommodations/accommodations.service' import { TripMembersService } from '../trip-members/trip-members.service'; import { TripReadModelService } from '../trip-read-model/trip-read-model.service'; import { notificationsInstance } from '../notifications/notifications.instance'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; /** * Non-Nest entry point for the trip domain — for the two consumers that cannot @@ -75,7 +76,7 @@ const readModel = new TripReadModelService( new CollabService(dbs(), permissions, realtime, notificationsInstance()), places, new TodoService(dbs(), permissions, realtime), - new FilesService(dbs(), permissions, realtime), + new FilesService(dbs(), permissions, realtime, new EphemeralTokenService()), ); export function getTripOwner(tripId: string | number) { diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index f5432dde2..4fa2aba61 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -67,6 +67,7 @@ import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service import { makeNotificationsService, makeNotificationPreferencesService } from './notifications'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { notificationsStub } from './notifications'; +import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service'; /** * Hand-wired counterpart of the boot-time discovery in McpRegistryService, @@ -78,7 +79,7 @@ import { notificationsStub } from './notifications'; export function createMcpTestRegistry(): McpRegistry { const dbService = new DatabaseService(db); const permissionsService = new PermissionsService(dbService); - const authService = new AuthService(dbService, permissionsService, new AtlasService(dbService)); + const authService = new AuthService(dbService, permissionsService, new AtlasService(dbService), new EphemeralTokenService()); const realtimeService = new RealtimeService(); const queryHelpersService = new QueryHelpersService(dbService); const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService); @@ -115,7 +116,7 @@ export function createMcpTestRegistry(): McpRegistry { const readModelService = new TripReadModelService( dbService, membersService, daysService, accommodationsService, budgetService, packingService, reservationsService, collabService, placesService, todoService, - new FilesService(dbService, permissionsService, realtimeService), + new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService()), ); const calendarService = new CalendarService(dbService, reservationsService); // The nine addon-gated surfaces read their toggle off an injected service now diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 09cce17ca..25839f99f 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -63,6 +63,7 @@ import { JournalRpc } from '../../src/nest/journey/journal.rpc'; import { CollectionsRpc } from '../../src/nest/collections/collections.rpc'; import { makeNotificationsService } from './notifications'; import { notificationsStub } from './notifications'; +import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -83,7 +84,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF const queryHelpers = new QueryHelpersService(dbs); const todos = new TodoService(dbs, permissions, realtime); const packing = new PackingService(dbs, permissions, realtime, notificationsStub()); - const files = new FilesService(dbs, permissions, realtime); + const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService()); const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub()); const collab = new CollabService(dbs, permissions, realtime, notificationsStub()); const vacay = new VacayService(dbs, realtime, notificationsStub()); diff --git a/server/tests/unit/auto-backup.test.ts b/server/tests/unit/auto-backup.test.ts index 362d94ae2..30e2472ac 100644 --- a/server/tests/unit/auto-backup.test.ts +++ b/server/tests/unit/auto-backup.test.ts @@ -9,9 +9,12 @@ * which snapshots the DB with VACUUM INTO and bundles the key. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { PathLike } from 'node:fs'; const fsMock = vi.hoisted(() => ({ - existsSync: vi.fn(() => false), + // The parameter is declared so the double carries fs's real signature: the + // cases below replace this default with a path-dependent implementation. + existsSync: vi.fn((_p: PathLike) => false), mkdirSync: vi.fn(), readFileSync: vi.fn(() => '{}'), writeFileSync: vi.fn(), @@ -85,7 +88,14 @@ function scheduledRun(): () => Promise { fsMock.readFileSync.mockReturnValue(JSON.stringify({ enabled: true, interval: 'daily', keep_days: 7 })); // The same wiring index.ts does from the container, with the real service // function behind it — the run below is still the production code path. - setSchedulerDeps({ backups: { createBackup } }); + // Only the backup tick is started here; the scheduler's other two container + // collaborators are stubbed so the deps object is complete, and neither the + // place-photo sweep nor the AirTrail tick is scheduled by start(). + setSchedulerDeps({ + backups: { createBackup }, + placePhotos: { sweepOrphans: vi.fn(() => 0) }, + airtrail: { runAirtrailSync: vi.fn(async () => {}) }, + }); start(); return cronMock.schedule.mock.calls.at(-1)?.[1] as () => Promise; } diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index 1047d0771..cfc2ef4d8 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -38,7 +38,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories'; -import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { createTables(testDb); @@ -364,7 +364,7 @@ describe('Resource: trek://trips/{tripId}/budget/per-person', () => { const trip = createTrip(testDb, user.id); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` }); - const data = JSON.parse(result.contents[0].text as string); + const data = parseResourceResult(result); expect(Array.isArray(data)).toBe(true); }); }); @@ -375,7 +375,7 @@ describe('Resource: trek://trips/{tripId}/budget/per-person', () => { const trip = createTrip(testDb, other.id); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` }); - const data = JSON.parse(result.contents[0].text as string); + const data = parseResourceResult(result) as { error?: string }; expect(data.error).toBeDefined(); }); }); @@ -391,7 +391,9 @@ describe('Resource: trek://trips/{tripId}/budget/settlement', () => { const trip = createTrip(testDb, user.id); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/settlement` }); - const data = JSON.parse(result.contents[0].text as string); + // The resource may answer with a summary object or a bare array, and the + // assertion below accepts either, so keep the balances field optional. + const data = parseResourceResult(result) as { balances?: unknown }; expect(data).toBeDefined(); expect(Array.isArray(data.balances) || Array.isArray(data)).toBe(true); }); diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 81ac0a2b3..ab7b4e273 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -19,6 +19,30 @@ function svc() { return new AddonsService(new DatabaseService(dbConn)); } +type ListAddon = ReturnType['addons'][number]; + +type PhotoProviderField = { + key: string; + label: string; + input_type: string; + placeholder: string; + hint: string | null; + required: boolean; + secret: boolean; + settings_key: string | null; + payload_key: string | null; + sort_order: number; +}; + +// list() spreads plain addons and photo providers into one array, and a provider +// object structurally extends a plain one. TypeScript reduces the element union +// to the plain shape, so `config` and `fields` are invisible on res.addons even +// though the provider entries carry them at runtime. Provider assertions read +// the fields through here instead of casting at every call site. +function providerFields(addon: ListAddon): PhotoProviderField[] { + return (addon as ListAddon & { fields: PhotoProviderField[] }).fields; +} + // Feed list()'s reads in call order: addons, providers, fields (.all), then the // collab-features rows (.all) and the bag-tracking row (.get) from app_settings. function feedReads( @@ -140,8 +164,7 @@ describe('AddonsService.list', () => { ); const res = svc().list(); - const provider = res.addons[0] as { fields: Array> }; - expect(provider.fields).toEqual([ + expect(providerFields(res.addons[0])).toEqual([ { key: 'url', label: 'URL', @@ -188,7 +211,7 @@ describe('AddonsService.list', () => { ); const res = svc().list(); - const field = (res.addons[0] as { fields: Array> }).fields[0]; + const field = providerFields(res.addons[0])[0]; expect(field).toMatchObject({ placeholder: '', hint: null, @@ -217,7 +240,7 @@ describe('AddonsService.list', () => { ); const res = svc().list(); - expect((res.addons[0] as { fields: unknown[] }).fields).toEqual([]); + expect(providerFields(res.addons[0])).toEqual([]); }); it('concatenates regular addons before the photo providers', () => { diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index f9ad564e9..4044cf773 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -74,6 +74,7 @@ import { AdminService } from '../../../src/nest/admin/admin.service'; import { checkAndNotifyVersion as bridgeCheckAndNotifyVersion } from '../../../src/nest/admin/admin.bridge'; import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers'; import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); @@ -84,7 +85,7 @@ const userCleanup = new UserCleanupService(dbs); // and the mailer was missing entirely, so `auth` was built with its last four // collaborators shifted by one. Nothing failed, because none of the cases below // reach a path that uses them. -const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs)); +const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService()); const svc = new AdminService( dbs, new AddonsService(dbs), diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index 4403247ce..84e202137 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -56,13 +56,14 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se import { NotificationsService } from '../../../src/nest/notifications/notifications.service'; import { AdminService } from '../../../src/nest/admin/admin.service'; import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); const permissions = new PermissionsService(dbs); const webauthn = new WebauthnConfigService(dbs); const userCleanup = new UserCleanupService(dbs); -const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup); +const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup, new EphemeralTokenService()); const svc = new AdminService( dbs, new AddonsService(dbs), diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index 712aee6d8..74c84be5b 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -48,14 +48,35 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createTag } from '../../helpers/factories'; -import { DatabaseService } from '../../../src/nest/database/database.service'; +import { DatabaseService, type TripAccess } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; import { createAssignment, dayExists, placeExists, getAssignmentForTrip } from '../../../src/nest/assignments/assignments.bridge'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; -const svc = new AssignmentsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb))); +const dbs = new DatabaseService(testDb); +const realtime = new RealtimeService(); +const svc = new AssignmentsService( + dbs, + new PermissionsService(dbs), + realtime, + new QueryHelpersService(dbs), + // Real collaborator rather than a stub: reconcile() runs after every mutation + // and needs the same connection to see the rows these cases write. + new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)), +); + +/** + * canEdit only reads trip.user_id, so the case below hands it just that field. + * The cast names the omission instead of padding the fixture with an id and a + * currency no assertion looks at. + */ +function tripOwnedBy(userId: number): TripAccess { + return { user_id: userId } as TripAccess; +} beforeAll(() => { createTables(testDb); @@ -101,8 +122,8 @@ describe('canEdit', () => { const { user, trip } = fixture(); const { user: member } = createUser(testDb); addTripMember(testDb, trip.id, member.id); - expect(svc.canEdit({ user_id: trip.user_id }, user as never)).toBe(true); - expect(svc.canEdit({ user_id: trip.user_id }, member as never)).toBe(true); + expect(svc.canEdit(tripOwnedBy(trip.user_id), user)).toBe(true); + expect(svc.canEdit(tripOwnedBy(trip.user_id), member)).toBe(true); }); }); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 1bcb3302a..9211315c0 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -85,6 +85,7 @@ import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service' import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service'; import { revokeUserSessions } from '../../../src/mcp/sessionManager'; import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; // MailerService is injected since the notifications fold — a stub instead of a // module mock. sendPasswordResetEmail is the only thing auth reaches for. @@ -97,7 +98,7 @@ const membershipStub = { joinTripAsMember } as unknown as TripMembershipService; // Tokens left AuthService for tokens/token.service.ts. The two cases below still // need one as a fixture: changePassword prunes MCP tokens, and the bridge parity // case verifies a token it just minted. -const tokens = new TokenService(new DatabaseService(testDb)); +const tokens = new TokenService(new DatabaseService(testDb), new EphemeralTokenService()); const svc = new AuthService( new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), @@ -105,6 +106,7 @@ const svc = new AuthService( new WebauthnConfigService(new DatabaseService(testDb)), new UserCleanupService(new DatabaseService(testDb)), mailerStub, + new EphemeralTokenService(), ); // --------------------------------------------------------------------------- diff --git a/server/tests/unit/nest/files.controller.test.ts b/server/tests/unit/nest/files.controller.test.ts index c0272271d..da0a1abb4 100644 --- a/server/tests/unit/nest/files.controller.test.ts +++ b/server/tests/unit/nest/files.controller.test.ts @@ -129,16 +129,16 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route }); it('DELETE /:id/permanent 404 not in trash, else deletes', async () => { - await expect(new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial), new RuntimeEnvService()).permanent(user, '5', '9')).rejects.toBeInstanceOf(HttpException); + await expect(new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial), new RuntimeEnvService()).permanent(user, trip, '5', '9')).rejects.toBeInstanceOf(HttpException); const permanentDeleteFile = vi.fn().mockResolvedValue(undefined); const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), permanentDeleteFile, broadcast: vi.fn() } as Partial); - expect(await new FilesController(s, new RuntimeEnvService()).permanent(user, '5', '9')).toEqual({ success: true }); + expect(await new FilesController(s, new RuntimeEnvService()).permanent(user, trip, '5', '9')).toEqual({ success: true }); }); it('DELETE /trash/empty 403, else returns the count', async () => { - await expect(new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).emptyTrash(user, '5')).rejects.toBeInstanceOf(HttpException); + await expect(new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).emptyTrash(user, trip, '5')).rejects.toBeInstanceOf(HttpException); const s = fsvc({ emptyTrash: vi.fn().mockResolvedValue(3) } as Partial); - expect(await new FilesController(s, new RuntimeEnvService()).emptyTrash(user, '5')).toEqual({ success: true, deleted: 3 }); + expect(await new FilesController(s, new RuntimeEnvService()).emptyTrash(user, trip, '5')).toEqual({ success: true, deleted: 3 }); }); it('POST /:id/link 404 unknown file, else links', () => { @@ -158,7 +158,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route it('the trash + link routes all reject without file_delete / file_edit', async () => { const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) }); - await expect(new FilesController(denied(), new RuntimeEnvService()).permanent(user, '5', '9')).rejects.toMatchObject({ status: 403 }); + await expect(new FilesController(denied(), new RuntimeEnvService()).permanent(user, trip, '5', '9')).rejects.toMatchObject({ status: 403 }); expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).restore(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } }); expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).link(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } }); expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } }); diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index ede131512..59cc01c58 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -59,7 +59,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember, createPlace, createReservation, createDay, createDayAssignment, setAppSetting } from '../../helpers/factories'; -import { DatabaseService } from '../../../src/nest/database/database.service'; +import { DatabaseService, type TripAccess } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { FilesService } from '../../../src/nest/files/files.service'; import { getAllowedExtensions as bridgeGetAllowedExtensions } from '../../../src/nest/files/files.bridge'; @@ -74,8 +74,9 @@ import { } from '../../../src/nest/files/files.constants'; import type { TripFile, User } from '../../../src/types'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; -const svc = new FilesService(new DatabaseService(testDb), permissionsStub, new RealtimeService()); +const svc = new FilesService(new DatabaseService(testDb), permissionsStub, new RealtimeService(), new EphemeralTokenService()); beforeAll(() => { createTables(testDb); @@ -183,7 +184,7 @@ describe('getAllowedExtensions', () => { }); it('FILE-SVC-009: returns the default when the query throws (no app_settings table)', () => { - const bareSvc = new FilesService(new DatabaseService(bareDb), permissionsStub, new RealtimeService()); + const bareSvc = new FilesService(new DatabaseService(bareDb), permissionsStub, new RealtimeService(), new EphemeralTokenService()); expect(bareSvc.getAllowedExtensions()).toBe(DEFAULT_ALLOWED_EXTENSIONS); }); }); @@ -317,9 +318,9 @@ describe('toggleStarred / softDeleteFile / restoreFile', () => { it('FILE-SVC-019: toggleStarred flips 0→1 and 1→0', () => { const { user, trip } = seedTrip(); const file = makeFile(trip.id, user.id); - const on = svc.toggleStarred(file.id, 0) as Record; + const on = svc.toggleStarred(file.id, 0); expect(on.starred).toBe(1); - const off = svc.toggleStarred(file.id, on.starred) as Record; + const off = svc.toggleStarred(file.id, on.starred); expect(off.starred).toBe(0); }); @@ -444,21 +445,33 @@ describe('findForeignLinkTarget', () => { // ── file links ──────────────────────────────────────────────────────────────── +// The link queries go through the untyped DatabaseService.all, so the service +// hands back unknown[]. Name the columns these cases read instead of widening +// every row to Record and losing the id's type on the way into +// deleteFileLink. +type FileLinkRow = { + id: number; + file_id: number; + reservation_id: number | null; + place_id: number | null; + reservation_title?: string | null; +}; + describe('createFileLink / deleteFileLink / getFileLinks', () => { it('FILE-SVC-029: inserts with || null coercion, dedupes via INSERT OR IGNORE and returns the re-select', () => { const { user, trip } = seedTrip(); const reservation = createReservation(testDb, trip.id, { title: 'Ferry' }); const file = makeFile(trip.id, user.id); - const links = svc.createFileLink(file.id, { reservation_id: reservation.id, place_id: '' }) as Record[]; + const links = svc.createFileLink(file.id, { reservation_id: reservation.id, place_id: '' }) as FileLinkRow[]; expect(links).toHaveLength(1); expect(links[0].reservation_id).toBe(reservation.id); expect(links[0].place_id).toBeNull(); - const again = svc.createFileLink(file.id, { reservation_id: reservation.id }) as Record[]; + const again = svc.createFileLink(file.id, { reservation_id: reservation.id }) as FileLinkRow[]; expect(again).toHaveLength(1); // UNIQUE(file_id, reservation_id) + OR IGNORE - const hydrated = svc.getFileLinks(file.id) as Record[]; + const hydrated = svc.getFileLinks(file.id) as FileLinkRow[]; expect(hydrated[0].reservation_title).toBe('Ferry'); }); @@ -473,7 +486,7 @@ describe('createFileLink / deleteFileLink / getFileLinks', () => { const reservation = createReservation(testDb, trip.id); const file = makeFile(trip.id, user.id); const other = makeFile(trip.id, user.id); - const [link] = svc.createFileLink(file.id, { reservation_id: reservation.id }) as Record[]; + const [link] = svc.createFileLink(file.id, { reservation_id: reservation.id }) as FileLinkRow[]; svc.deleteFileLink(link.id, other.id); // wrong file — no-op expect(svc.getFileLinks(file.id)).toHaveLength(1); @@ -534,7 +547,9 @@ describe('verifyTripAccess / can / files.bridge', () => { const { user, trip } = seedTrip(); const guest = { id: user.id + 1, role: 'user' } as unknown as User; checkPermission.mockReturnValue(true); - const tripRow = { id: trip.id, user_id: user.id } as Record; + // can() reads only trip.user_id, so the row stays deliberately partial: + // TripAccess also carries currency, which no assertion here looks at. + const tripRow = { id: trip.id, user_id: user.id } as TripAccess; expect(svc.can('file_edit', tripRow, guest)).toBe(true); expect(checkPermission).toHaveBeenCalledWith('file_edit', 'user', user.id, guest.id, true); diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index b1350d9d2..f31587166 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -28,6 +28,13 @@ import { import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +// The seams below stand in for real collaborators, so they are typed from those +// collaborators' signatures rather than from their own default implementations. +// checkSsrf answers with a full SsrfResult; the guard path exercised here reads +// only these two fields, so the stub returns that slice instead of inventing a +// resolved IP and a privacy verdict no assertion looks at. +type SsrfCheckStub = Pick; + const { mockDbGet, mockDbRun, @@ -42,16 +49,20 @@ const { } = vi.hoisted(() => ({ mockDbGet: vi.fn(() => undefined as any), mockDbRun: vi.fn(), - mockCheckSsrf: vi.fn(async () => ({ allowed: true })), - mockCacheGet: vi.fn(() => null as any), - mockCacheGetErrored: vi.fn(() => false), + mockCheckSsrf: vi.fn(async (_url: string, _bypassInternalIpAllowed?: boolean): Promise => ({ + allowed: true, + })), + mockCacheGet: vi.fn((_placeId: string) => null as ReturnType), + mockCacheGetErrored: vi.fn((_placeId: string) => false), mockCacheMarkError: vi.fn(), mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, filePath: `/tmp/${placeId}.jpg`, attribution, })), - mockCacheGetInFlight: vi.fn(() => undefined), + mockCacheGetInFlight: vi.fn( + (_placeId: string) => undefined as ReturnType, + ), mockCacheSetInFlight: vi.fn(), mockServeFilePath: vi.fn((_placeId: string) => null as string | null), })); @@ -108,6 +119,8 @@ import { db } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { MapsService, withPhotoFetchSlot, readWikiIdentity } from '../../../src/nest/maps/maps.service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +// Type-only, so the module stays mocked: this import is erased at runtime. +import type { SsrfResult } from '../../../src/utils/ssrfGuard'; // The service under test, constructed over the mocked db stub — DatabaseService // routes get/run through the stubbed prepare(), so mockDbGet/mockDbRun keep @@ -1832,7 +1845,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { }); it('MAPS-043f: an in-flight lookup that found nothing resolves the waiter with photoUrl null', async () => { - mockCacheGetInFlight.mockReturnValue(Promise.resolve(null) as any); + mockCacheGetInFlight.mockReturnValue(Promise.resolve(null)); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); await expect(svc.getPlacePhoto(999, 'ChIJInFlight', 48.8, 2.3)).resolves.toEqual({ diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index 7b70e28f0..7754088a4 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -7,9 +7,15 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +/** Whatever the statement under test selects: a settings row, a cache row, or nothing. */ +type DbRow = Record | undefined; + const { mockDbGet, mockDbRun, mockSafeFetchFollow } = vi.hoisted(() => ({ - mockDbGet: vi.fn(() => undefined as any), - mockDbRun: vi.fn(), + // Signature taken from the seam below rather than from this default: the + // statement text is folded in ahead of the params, so a no-argument spy + // would reject both the call site and every mockImplementation here. + mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): DbRow => undefined), + mockDbRun: vi.fn((_sql: string, ..._params: unknown[]) => {}), mockSafeFetchFollow: vi.fn(async () => ({ ok: true, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer })), })); diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 1ff3a0769..66f123b29 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -54,6 +54,10 @@ vi.mock('../../../src/config', () => ({ // Spy on the photo-cache reclaim hook so delete tests assert the wiring without // touching disk. The removal logic itself is covered in placePhotoCache.test.ts. // Injected stub since the photo-cache fold (was a partial path mock). +// Only `removeIfUnreferenced` is stubbed: it is the single cache method anything +// in this file reaches. MapsService takes the same instance (as it does in +// production) but never gets far enough to call it, since every maps path here +// stops at the missing API key. const removeIfUnreferencedSpy = vi.fn(); const photoCacheStub = { removeIfUnreferenced: removeIfUnreferencedSpy } as unknown as PlacePhotoCacheService; @@ -69,13 +73,38 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { PlacesService } from '../../../src/nest/places/places.service'; import { MapsService } from '../../../src/nest/maps/maps.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; import { PLACE_IMAGES_DIR } from '../../../src/nest/places/place-image'; const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); const dbs = new DatabaseService(testDb); -const svc = new PlacesService(dbs, new PermissionsService(dbs), new RealtimeService(), new MapsService(dbs), new QueryHelpersService(dbs), new UnsplashService(dbs, new RuntimeEnvService()), photoCacheStub); + +/** + * Same collaborator set the container hands PlacesService (see trips.bridge.ts), + * built once here so the two construction sites cannot drift apart again. The + * journey domain is a real instance on the same in-memory DB rather than a stub: + * its place hooks are fire-and-forget behind a catch, so a missing one would have + * looked like a pass while silently swallowing a TypeError. No test in this file + * creates a journey, so every hook returns on its first lookup. + * `maps` is a parameter because the enrichment cases hand in their own provider. + */ +function makePlacesService(maps: MapsService = new MapsService(dbs, photoCacheStub)): PlacesService { + return new PlacesService( + dbs, + new PermissionsService(dbs), + new RealtimeService(), + maps, + new QueryHelpersService(dbs), + new UnsplashService(dbs, new RuntimeEnvService()), + photoCacheStub, + new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)), + ); +} + +const svc = makePlacesService(); beforeAll(() => { createTables(testDb); @@ -1012,8 +1041,9 @@ describe('PlacesService — automatic track colours (#776)', () => { // ── Import enrichment (#886) ────────────────────────────────────────────────── describe('enrichImportedPlaces', () => { + // Deliberately partial: each case stubs only the provider calls its path reaches. function enrichSvc(maps: Partial) { - return new PlacesService(dbs, new PermissionsService(dbs), new RealtimeService(), maps as MapsService, new QueryHelpersService(dbs), new UnsplashService(dbs, new RuntimeEnvService()), photoCacheStub); + return makePlacesService(maps as MapsService); } it('PLACE-SVC-058 — no-ops when no Google Maps key is configured', async () => { diff --git a/server/tests/unit/nest/token.service.test.ts b/server/tests/unit/nest/token.service.test.ts index a379b7d13..2c87dddf2 100644 --- a/server/tests/unit/nest/token.service.test.ts +++ b/server/tests/unit/nest/token.service.test.ts @@ -46,8 +46,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { createEphemeralToken } from '../../../src/nest/auth/ephemeral-tokens'; import { revokeUserSessions } from '../../../src/mcp/sessionManager'; import { expectRegisteredProvider } from '../../helpers/module-providers'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; -const svc = new TokenService(new DatabaseService(testDb)); +const svc = new TokenService(new DatabaseService(testDb), new EphemeralTokenService()); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index 59081e775..5c99e1fce 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -67,6 +67,7 @@ import { AccommodationsService } from '../../../src/nest/accommodations/accommod import { MapsService } from '../../../src/nest/maps/maps.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { notificationsStub } from '../../helpers/notifications'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; // Real sibling services over the same in-memory DB — the aggregation runs the // actual SQL of every domain it fans out to, so a shape change downstream shows @@ -86,7 +87,7 @@ const buildReadModel = (database: DatabaseService, roster: TripMembersService = new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()), placesSvc, new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()), - new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()), + new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new EphemeralTokenService()), ); const svc = buildReadModel(dbs()); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 08dc9dd41..1b09a4fac 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -20,6 +20,7 @@ const unsplashStub = { } as unknown as UnsplashService; import { TripsController } from '../../../src/nest/trips/trips.controller'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; import type { TripsService } from '../../../src/nest/trips/trips.service'; import type { CalendarService } from '../../../src/nest/calendar/calendar.service'; import type { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service'; @@ -51,6 +52,14 @@ function svc(o: Partial = {}): TripsService { } as unknown as TripsService; } +// The DTO ratchet types create()'s body from the shared contract, where +// day_count is a number. Two cases below deliberately hand the handler a string, +// because that is what an unvalidated client sends and what the handler still +// guards against with Number(day_count) || 7. Naming the pre-pipe shape keeps +// those payloads exactly as they were instead of retyping them into the contract. +type CreateBody = Parameters[1]; +const prePipeCreateBody = (body: unknown) => body as CreateBody; + function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -163,13 +172,13 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { it('infers start_date from end_date (-6 days) and parses day_count', () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tc(svc({ create } as Partial)).create(user, { title: 'T', end_date: '2026-07-07', day_count: '40' }, req); + tc(svc({ create } as Partial)).create(user, prePipeCreateBody({ title: 'T', end_date: '2026-07-07', day_count: '40' }), req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07', day_count: 40 })); }); it('clamps a non-numeric day_count to the default of 7', () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tc(svc({ create } as Partial)).create(user, { title: 'T', day_count: 'abc' }, req); + tc(svc({ create } as Partial)).create(user, prePipeCreateBody({ title: 'T', day_count: 'abc' }), req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 })); }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index a17aaa3d0..6a179ce4f 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -78,6 +78,7 @@ import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nes import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import fs from 'fs'; import { notificationsStub } from '../../helpers/notifications'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; // Real sibling services over the same in-memory DB — updateTrip's date-shift // resyncs and the summary/bundle aggregation run their actual SQL. @@ -106,7 +107,7 @@ const readModelSvc = new TripReadModelService( new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()), placesSvc, new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()), - new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()), + new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new EphemeralTokenService()), ); diff --git a/server/tests/unit/places/places.rpc.test.ts b/server/tests/unit/places/places.rpc.test.ts index d8fce8ccf..c807d8232 100644 --- a/server/tests/unit/places/places.rpc.test.ts +++ b/server/tests/unit/places/places.rpc.test.ts @@ -24,20 +24,26 @@ import { makeDeps } from '../../helpers/rpc-host-deps'; const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); -/** Trip 1 belongs to user 42; place 7 sits on it. */ +/** + * Trip 1 belongs to user 42; place 7 sits on it. + * + * The doubles keep their vi.fn types here and only wear the service type at the + * constructor. Stamping the service type onto the object itself puts the real method + * signature in front of the mock, and the cases below read .mock off these methods. + */ function build(opts: { canEdit?: boolean; journeyThrows?: boolean } = {}) { const places = { create: vi.fn((_t: string, i: Record) => ({ id: 10, ...i })), update: vi.fn((_t: string, id: string) => (id === '7' ? { id: 7, name: 'updated' } : null)), get: vi.fn((_t: string, id: string) => (id === '7' ? { id: 7 } : undefined)), remove: vi.fn((_t: string, id: string) => id === '7'), - } as unknown as PlacesService & Record>; + }; const journey = { onPlaceCreated: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), onPlaceUpdated: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), onPlaceDeleted: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), - } as unknown as JourneyDomainService & Record>; - const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; + }; + const realtime = { broadcast: vi.fn() }; const guards = new PluginGuards( { canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)), @@ -46,7 +52,13 @@ function build(opts: { canEdit?: boolean; journeyThrows?: boolean } = {}) { { checkPermission: vi.fn(() => opts.canEdit ?? true) } as unknown as PermissionsService, { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService, ); - const rpc = new PlacesRpc(places, journey, realtime, guards); + // Each double covers only the methods PlacesRpc reaches for, hence the cast. + const rpc = new PlacesRpc( + places as unknown as PlacesService, + journey as unknown as JourneyDomainService, + realtime as unknown as RealtimeService, + guards, + ); const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), createTestPluginRegistry([rpc])); return { places, journey, realtime, host }; } diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts index 40de0ba30..25392eabe 100644 --- a/server/tests/unit/plugins/operator-egress.test.ts +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -30,6 +30,7 @@ import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.s import { createPluginRuntime } from '../../helpers/plugin-host'; import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest'; import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) { testDb.prepare( @@ -159,13 +160,20 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', ( install('gotify', true); install('plain', false); - const before = new PluginsService(new DatabaseService(dbConn)).list().plugins; + // list() resolves required-addon dependencies through AddonsService, so it gets a real + // one over the same DB. These fixtures declare no dependencies, so it is never consulted. + const listPlugins = () => { + const dbs = new DatabaseService(dbConn); + return new PluginsService(dbs, new AddonsService(dbs)).list().plugins; + }; + + const before = listPlugins(); expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 }); // A plugin that never asked for it must never invite the admin to add hosts. expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 }); await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']); - const after = new PluginsService(new DatabaseService(dbConn)).list().plugins; + const after = listPlugins(); expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2); delete process.env.TREK_PLUGINS_ENABLED; }); diff --git a/server/tests/unit/plugins/supervisor-lifecycle.test.ts b/server/tests/unit/plugins/supervisor-lifecycle.test.ts index ce780c653..b52fb7a14 100644 --- a/server/tests/unit/plugins/supervisor-lifecycle.test.ts +++ b/server/tests/unit/plugins/supervisor-lifecycle.test.ts @@ -17,8 +17,9 @@ function makeSupervisor() { (s as any).spawn = spawn; return { s, spawn, dispose }; } +// Reaches into the private `running` map so a case can assert on the live entry. // eslint-disable-next-line @typescript-eslint/no-explicit-any -const entry = (s: PluginSupervisor, id: string, status: string) => (s as any).running.get(id); +const entry = (s: PluginSupervisor, id: string): { status: string } => (s as any).running.get(id); describe('supervisor re-activation after failure', () => { const supers: PluginSupervisor[] = []; diff --git a/server/tests/unit/scheduler.test.ts b/server/tests/unit/scheduler.test.ts index aea4d9455..f75801951 100644 --- a/server/tests/unit/scheduler.test.ts +++ b/server/tests/unit/scheduler.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; // Prevent node-cron from scheduling anything at import time vi.mock('node-cron', () => ({ @@ -41,6 +41,20 @@ vi.mock('../../src/nest/audit/audit-log.logger', () => ({ import fs from 'node:fs'; import { buildCronExpression, cleanupOldBackups } from '../../src/scheduler'; +// readdirSync and statSync are overloaded in node:fs, and vi.mocked() picks the last +// overload (Dirent[] / BigIntStats) rather than the one the scheduler calls. These handles +// point at the very same mock functions from the factory above, pinned to the plain +// string[] / Stats signatures that cleanupOldBackups actually uses. +const readdirSyncMock = fs.readdirSync as unknown as Mock<(path: string) => string[]>; +const statSyncMock = fs.statSync as unknown as Mock<(path: string) => fs.Stats>; +const unlinkSyncMock = fs.unlinkSync as unknown as Mock<(path: string) => void>; + +// cleanupOldBackups reads nothing but mtimeMs off a stat result, so the stubs below stay +// deliberately partial instead of faking a whole fs.Stats. +function statStub(partial: Partial): fs.Stats { + return partial as fs.Stats; +} + interface BackupSettings { enabled: boolean; interval: string; @@ -149,70 +163,70 @@ describe('cleanupOldBackups', () => { } beforeEach(() => { - vi.mocked(fs.readdirSync).mockReset(); - vi.mocked(fs.statSync).mockReset(); - vi.mocked(fs.unlinkSync as ReturnType).mockReset(); - (vi.mocked(fs.statSync) as ReturnType).mockReturnValue({ mtime: new Date(), mtimeMs: NOW, birthtimeMs: NOW, size: 0 }); + readdirSyncMock.mockReset(); + statSyncMock.mockReset(); + unlinkSyncMock.mockReset(); + statSyncMock.mockReturnValue(statStub({ mtime: new Date(), mtimeMs: NOW, birthtimeMs: NOW, size: 0 })); }); it('never deletes manual backup-*.zip files regardless of age', () => { const manual = isoFilename(365 * 5, 'backup'); const auto = isoFilename(0); - vi.mocked(fs.readdirSync).mockReturnValue([manual, auto] as unknown as string[]); + readdirSyncMock.mockReturnValue([manual, auto]); cleanupOldBackups(7, NOW); - const deleted = (vi.mocked(fs.unlinkSync as ReturnType)).mock.calls.map((c: unknown[]) => c[0] as string); - expect(deleted.some((p: string) => p.includes(manual))).toBe(false); + const deleted = unlinkSyncMock.mock.calls.map(([p]) => p); + expect(deleted.some(p => p.includes(manual))).toBe(false); }); it('keeps auto-backups newer than retention', () => { const recent = isoFilename(3); - vi.mocked(fs.readdirSync).mockReturnValue([recent] as unknown as string[]); + readdirSyncMock.mockReturnValue([recent]); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).not.toHaveBeenCalled(); + expect(unlinkSyncMock).not.toHaveBeenCalled(); }); it('deletes auto-backups older than retention', () => { const old = isoFilename(30); - vi.mocked(fs.readdirSync).mockReturnValue([old] as unknown as string[]); + readdirSyncMock.mockReturnValue([old]); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).toHaveBeenCalledOnce(); - const [calledPath] = (vi.mocked(fs.unlinkSync as ReturnType)).mock.calls[0] as string[]; + expect(unlinkSyncMock).toHaveBeenCalledOnce(); + const [calledPath] = unlinkSyncMock.mock.calls[0]; expect(calledPath).toContain(old); }); it('overlayfs regression: birthtimeMs=0 does not delete a same-day backup', () => { const fresh = isoFilename(0); - vi.mocked(fs.readdirSync).mockReturnValue([fresh] as unknown as string[]); - (vi.mocked(fs.statSync) as ReturnType).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW, mtime: new Date(NOW), size: 100 }); + readdirSyncMock.mockReturnValue([fresh]); + statSyncMock.mockReturnValue(statStub({ birthtimeMs: 0, mtimeMs: NOW, mtime: new Date(NOW), size: 100 })); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).not.toHaveBeenCalled(); + expect(unlinkSyncMock).not.toHaveBeenCalled(); }); it('malformed filename falls back to mtimeMs: keeps recent file', () => { - vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]); - (vi.mocked(fs.statSync) as ReturnType).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 1 * DAY, mtime: new Date(NOW - 1 * DAY), size: 0 }); + readdirSyncMock.mockReturnValue(['auto-backup-garbage.zip']); + statSyncMock.mockReturnValue(statStub({ birthtimeMs: 0, mtimeMs: NOW - 1 * DAY, mtime: new Date(NOW - 1 * DAY), size: 0 })); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).not.toHaveBeenCalled(); + expect(unlinkSyncMock).not.toHaveBeenCalled(); }); it('malformed filename falls back to mtimeMs: deletes stale file', () => { - vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]); - (vi.mocked(fs.statSync) as ReturnType).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 30 * DAY, mtime: new Date(NOW - 30 * DAY), size: 0 }); + readdirSyncMock.mockReturnValue(['auto-backup-garbage.zip']); + statSyncMock.mockReturnValue(statStub({ birthtimeMs: 0, mtimeMs: NOW - 30 * DAY, mtime: new Date(NOW - 30 * DAY), size: 0 })); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).toHaveBeenCalledOnce(); + expect(unlinkSyncMock).toHaveBeenCalledOnce(); }); it('ignores non-zip files and does not crash', () => { const old = isoFilename(30); - vi.mocked(fs.readdirSync).mockReturnValue([old, 'notes.txt'] as unknown as string[]); + readdirSyncMock.mockReturnValue([old, 'notes.txt']); cleanupOldBackups(7, NOW); - const calls = (vi.mocked(fs.unlinkSync as ReturnType)).mock.calls as string[][]; - expect(calls.every(([p]: string[]) => !p.includes('notes.txt'))).toBe(true); + const calls = unlinkSyncMock.mock.calls; + expect(calls.every(([p]) => !p.includes('notes.txt'))).toBe(true); expect(calls.length).toBe(1); }); it('swallows readdirSync errors without throwing', () => { - vi.mocked(fs.readdirSync).mockImplementation(() => { throw new Error('ENOENT'); }); + readdirSyncMock.mockImplementation(() => { throw new Error('ENOENT'); }); expect(() => cleanupOldBackups(7, NOW)).not.toThrow(); }); }); diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index fd9c2f52a..286fdcd6e 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -46,10 +46,31 @@ import { MapsService } from '../../../src/nest/maps/maps.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; +import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { notificationsStub } from '../../helpers/notifications'; -const packing = new PackingService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub()); -const places = new PlacesService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new MapsService(new DatabaseService(testDb)), new QueryHelpersService(new DatabaseService(testDb))); +const dbs = new DatabaseService(testDb); +const realtime = new RealtimeService(); +const runtimeEnv = new RuntimeEnvService(); +// One cache instance shared by maps and places, the way the container wires it: +// the service's stampede guard only works if there is exactly one of them. +const photoCache = new PlacePhotoCacheService(dbs, runtimeEnv); + +const packing = new PackingService(dbs, new PermissionsService(dbs), realtime, notificationsStub()); +const places = new PlacesService( + dbs, + new PermissionsService(dbs), + realtime, + new MapsService(dbs, photoCache), + new QueryHelpersService(dbs), + new UnsplashService(dbs, runtimeEnv), + photoCache, + new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)), +); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/systemNotices/conditions.test.ts b/server/tests/unit/systemNotices/conditions.test.ts index 0450f1474..2aa214295 100644 --- a/server/tests/unit/systemNotices/conditions.test.ts +++ b/server/tests/unit/systemNotices/conditions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { evaluate } from '../../../src/systemNotices/conditions.js'; -import type { SystemNotice } from '../../../src/systemNotices/types.js'; +import type { NoticeCondition, SystemNotice } from '../../../src/systemNotices/types.js'; const baseNotice: SystemNotice = { id: 'test', @@ -13,6 +13,11 @@ const baseNotice: SystemNotice = { publishedAt: '2026-01-01T00:00:00Z', }; +// Conditions have to be contextually typed as NoticeCondition, otherwise a literal like +// `roles: ['user']` widens to string[] and no longer matches the union's +// Array<'admin' | 'user'>. Going through this helper types every case at once. +const noticeWith = (...conditions: NoticeCondition[]): SystemNotice => ({ ...baseNotice, conditions }); + const baseCtx = { // noTrips joined the context when the 'noTrips' condition kind landed. 1 keeps // every case below reading as it did: the condition is `noTrips === 0`, so a @@ -23,7 +28,7 @@ const baseCtx = { }; describe('firstLogin', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'firstLogin' as const }] }; + const notice = noticeWith({ kind: 'firstLogin' }); it('passes when login_count <= 1', () => { expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true); }); @@ -33,7 +38,7 @@ describe('firstLogin', () => { }); describe('existingUserBeforeVersion', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'existingUserBeforeVersion' as const, version: '2.0.0' }] }; + const notice = noticeWith({ kind: 'existingUserBeforeVersion', version: '2.0.0' }); it('passes for user with first_seen_version < notice version when current >= notice version', () => { expect(evaluate(notice, baseCtx)).toBe(true); }); @@ -53,36 +58,36 @@ describe('existingUserBeforeVersion', () => { describe('dateWindow', () => { it('passes when now is inside window', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }] }; + const notice = noticeWith({ kind: 'dateWindow', startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }); expect(evaluate(notice, baseCtx)).toBe(true); }); it('fails when now is before start', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-07-01T00:00:00Z' }] }; + const notice = noticeWith({ kind: 'dateWindow', startsAt: '2026-07-01T00:00:00Z' }); expect(evaluate(notice, baseCtx)).toBe(false); }); it('passes when no endsAt', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-01-01T00:00:00Z' }] }; + const notice = noticeWith({ kind: 'dateWindow', startsAt: '2026-01-01T00:00:00Z' }); expect(evaluate(notice, baseCtx)).toBe(true); }); }); describe('role', () => { it('passes for matching role', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['user'] }] }; + const notice = noticeWith({ kind: 'role', roles: ['user'] }); expect(evaluate(notice, baseCtx)).toBe(true); }); it('fails for non-matching role', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['admin'] }] }; + const notice = noticeWith({ kind: 'role', roles: ['admin'] }); expect(evaluate(notice, baseCtx)).toBe(false); }); }); describe('AND logic', () => { it('requires all conditions to pass', () => { - const notice = { ...baseNotice, conditions: [ - { kind: 'firstLogin' as const }, - { kind: 'role' as const, roles: ['user'] }, - ]}; + const notice = noticeWith( + { kind: 'firstLogin' }, + { kind: 'role', roles: ['user'] }, + ); // login_count=1 passes firstLogin, role=user passes role → true expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true); // login_count=2 fails firstLogin → false