From 90e35e697093a162befc6fe0234dce23457f1e33 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:34:31 +0200 Subject: [PATCH] refactor(server): fold llmConfig and unsplash out of services/ (#1831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three leftovers in be-misc-fold. The third (wikiService) needs a Zod contract in @trek/shared first and is left for its own PR. llmConfig.ts was pure movement: 70 lines of encryption-aware config shaping with no state and no database, sitting in services/ while all four of its callers are in src/nest. It is nest/llm-parse/llm-config.ts now, same functions, same file. unsplashService.ts needed an actual provider. Its key resolution reads both the env and the users table (env var, then the caller's stored key, then any admin's), so the injected connection was unavoidable. Its own module because trips and places both reach it, and because folding it into either would have made the other import a trip/place service for an image search. The four exports became methods on UnsplashService. The SSRF guard in saveUnsplashCover is untouched — it still refuses any host but the Unsplash image CDN and still goes through safeFetch — as is the unauthenticated-endpoint fallback when no key resolves (#1449). The env read stays live through RuntimeEnvService rather than a registerAs token. readEnv() is what the function did before, and a boot-frozen token would change when an operator's UNSPLASH_ACCESS_KEY takes effect. Two test seams follow the code: the unsplash unit tests bind the four methods so every existing assertion reads unchanged, and the trips controller's path mock of the module becomes an injected stub. services/ is down to 17 top-level files. --- server/src/nest/admin/admin.service.ts | 2 +- .../src/nest/llm-parse/llm-client.factory.ts | 2 +- .../src/nest/llm-parse/llm-config.resolver.ts | 2 +- .../llm-parse/llm-config.ts} | 2 +- server/src/nest/places/places.module.ts | 3 +- server/src/nest/places/places.service.ts | 5 +- server/src/nest/trips/trips.bridge.ts | 5 +- server/src/nest/trips/trips.controller.ts | 8 +-- server/src/nest/trips/trips.module.ts | 3 +- server/src/nest/trips/trips.service.ts | 5 +- server/src/nest/unsplash/unsplash.module.ts | 15 ++++ .../unsplash/unsplash.service.ts} | 68 ++++++++++++------- .../nest/llm-parse/llm-client.factory.test.ts | 2 +- server/tests/unit/nest/places.service.test.ts | 6 +- .../tests/unit/nest/trips.controller.test.ts | 12 ++-- .../unsplash/unsplash.service.test.ts} | 18 +++-- 16 files changed, 105 insertions(+), 53 deletions(-) rename server/src/{services/llmConfig.ts => nest/llm-parse/llm-config.ts} (96%) create mode 100644 server/src/nest/unsplash/unsplash.module.ts rename server/src/{services/unsplashService.ts => nest/unsplash/unsplash.service.ts} (73%) rename server/tests/unit/{services/unsplashService.test.ts => nest/unsplash/unsplash.service.test.ts} (88%) diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index ba2194b83..ce7e3e5ae 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -17,7 +17,7 @@ import { emitUserDeleted } from '../../plugin-user-lifecycle'; import type { User, Addon } from '../../types'; import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { avatarUrl } from '../common/avatarUrl'; -import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../../services/llmConfig'; +import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../llm-parse/llm-config'; import { getPhotoProviderConfig } from '../../services/memories/helpersService'; import { validatePassword } from '../common/passwordPolicy'; import { deleteUserCompletely } from '../../services/userCleanupService'; diff --git a/server/src/nest/llm-parse/llm-client.factory.ts b/server/src/nest/llm-parse/llm-client.factory.ts index feab7a613..c39a8c39a 100644 --- a/server/src/nest/llm-parse/llm-client.factory.ts +++ b/server/src/nest/llm-parse/llm-client.factory.ts @@ -1,5 +1,5 @@ import type { LlmExtractionClient } from './llm-provider.interface'; -import type { ResolvedLlmConfig } from '../../services/llmConfig'; +import type { ResolvedLlmConfig } from './llm-config'; import { OpenAiCompatibleClient } from './clients/openai-compatible.client'; import { AnthropicClient } from './clients/anthropic.client'; diff --git a/server/src/nest/llm-parse/llm-config.resolver.ts b/server/src/nest/llm-parse/llm-config.resolver.ts index b2c7b7580..9fd69ef9e 100644 --- a/server/src/nest/llm-parse/llm-config.resolver.ts +++ b/server/src/nest/llm-parse/llm-config.resolver.ts @@ -1,6 +1,6 @@ import { ADDON_IDS } from '../../addons'; import { AddonsService } from '../addons/addons.service'; -import { decryptLlmApiKey, LLM_PROVIDERS, type LlmProvider, type ResolvedLlmConfig } from '../../services/llmConfig'; +import { decryptLlmApiKey, LLM_PROVIDERS, type LlmProvider, type ResolvedLlmConfig } from './llm-config'; import { DatabaseService } from '../database/database.service'; import { SettingsService } from '../settings/settings.service'; import { Injectable } from '@nestjs/common'; diff --git a/server/src/services/llmConfig.ts b/server/src/nest/llm-parse/llm-config.ts similarity index 96% rename from server/src/services/llmConfig.ts rename to server/src/nest/llm-parse/llm-config.ts index 6cb290b50..54bdbe08a 100644 --- a/server/src/services/llmConfig.ts +++ b/server/src/nest/llm-parse/llm-config.ts @@ -1,4 +1,4 @@ -import { maybe_encrypt_api_key, decrypt_api_key } from '../nest/common/crypto/apiKeyCrypto'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto'; /** * Shared types + helpers for the `llm_parsing` addon configuration. diff --git a/server/src/nest/places/places.module.ts b/server/src/nest/places/places.module.ts index ffbf86121..e791511e3 100644 --- a/server/src/nest/places/places.module.ts +++ b/server/src/nest/places/places.module.ts @@ -4,6 +4,7 @@ import { PlacesService } from './places.service'; import { PlacesMcp } from './places.mcp'; import { PermissionsModule } from '../permissions/permissions.module'; import { AppConfigModule } from '../app-config/app-config.module'; +import { UnsplashModule } from '../unsplash/unsplash.module'; import { QueryHelpersModule } from '../query-helpers/query-helpers.module'; import { MapsModule } from '../maps/maps.module'; import { AuthModule } from '../auth/auth.module'; @@ -17,7 +18,7 @@ import { AuthModule } from '../auth/auth.module'; * is no places.bridge.ts: nothing outside the container consumes this domain. */ @Module({ - imports: [PermissionsModule, QueryHelpersModule, MapsModule, AuthModule, AppConfigModule], + imports: [PermissionsModule, QueryHelpersModule, MapsModule, AuthModule, AppConfigModule, UnsplashModule], controllers: [PlacesController], providers: [PlacesService, PlacesMcp], exports: [PlacesService], diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index a485d66e6..c0c7feaec 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -19,7 +19,7 @@ import { parsePlacemarkNode, resolveCategoryIdForFolder, } from '../../services/kmlImport'; -import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashService'; +import { UnsplashService } from '../unsplash/unsplash.service'; import { type UpdateConflict, isUpdateConflict } from '../common/conflictResult'; import { reclaimPlaceImage } from '../../services/placeImage'; import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../../services/journeyService'; @@ -105,6 +105,7 @@ export class PlacesService { private readonly realtime: RealtimeService, private readonly maps: MapsService, private readonly queryHelpers: QueryHelpersService, + private readonly unsplash: UnsplashService, ) {} verifyTripAccess(tripId: string, userId: number) { @@ -1113,7 +1114,7 @@ export class PlacesService { const place = this.dbs.get('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); if (!place) return { error: 'Place not found', status: 404 }; - return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, getUnsplashKey(userId)); + return this.unsplash.searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, this.unsplash.getUnsplashKey(userId)); } // ------------------------------------------------------------------------- diff --git a/server/src/nest/trips/trips.bridge.ts b/server/src/nest/trips/trips.bridge.ts index be335b62a..851202bec 100644 --- a/server/src/nest/trips/trips.bridge.ts +++ b/server/src/nest/trips/trips.bridge.ts @@ -3,6 +3,8 @@ import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { QueryHelpersService } from '../query-helpers/query-helpers.service'; +import { UnsplashService } from '../unsplash/unsplash.service'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { TripsService } from './trips.service'; import { TodoService } from '../todo/todo.service'; import { PackingService } from '../packing/packing.service'; @@ -45,7 +47,8 @@ const trips = new TripsService( new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()), new VacayService(dbs(), new RealtimeService()), new RealtimeService(), - new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs())), + new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService())), + new UnsplashService(dbs(), new RuntimeEnvService()), ); export function getTripOwner(tripId: string | number) { diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index 85f008bb2..9aa44e356 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -33,7 +33,7 @@ import { logInfo } from '../audit/audit-log.logger'; import { AuditService } from '../audit/audit.service'; import { NotFoundError, ValidationError } from './trips.service'; import { TripCreateDto, TripUpdateDto, TripCopyDto, TripAddMemberDto, TripTransferOwnershipDto, TripCreateGuestDto, TripRenameGuestDto } from './trips.dto'; -import { saveUnsplashCover, isUnsplashCoverUrl } from '../../services/unsplashService'; +import { UnsplashService } from '../unsplash/unsplash.service'; const MAX_COVER_SIZE = 20 * 1024 * 1024; const coversDir = path.join(__dirname, '../../../uploads/covers'); @@ -69,7 +69,7 @@ const addDays = (d: Date, n: number) => { const r = new Date(d); r.setDate(r.get @Controller('api/trips') @UseGuards(JwtAuthGuard) export class TripsController { - constructor(private readonly trips: TripsService, private readonly audit: AuditService, private readonly env: RuntimeEnvService) {} + constructor(private readonly trips: TripsService, private readonly audit: AuditService, private readonly env: RuntimeEnvService, private readonly unsplash: UnsplashService) {} @Get() list(@CurrentUser() user: User, @Query('archived') archived?: string) { @@ -142,9 +142,9 @@ export class TripsController { } // A chosen Unsplash cover arrives as an images.unsplash.com hot-link; download // it into uploads/covers so the cover survives offline + CDN link-rot (#1277). - if (isUnsplashCoverUrl(body.cover_image)) { + if (this.unsplash.isUnsplashCoverUrl(body.cover_image)) { try { - const filename = await saveUnsplashCover(body.cover_image, coversDir); + const filename = await this.unsplash.saveUnsplashCover(body.cover_image, coversDir); body.cover_image = `/uploads/covers/${filename}`; } catch (e) { console.error('Unsplash cover download failed:', e); diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index 5c3d506c7..ba2388118 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -13,13 +13,14 @@ import { PlacesModule } from '../places/places.module'; import { TripsController } from './trips.controller'; import { TripsService } from './trips.service'; import { AppConfigModule } from '../app-config/app-config.module'; +import { UnsplashModule } from '../unsplash/unsplash.module'; import { TripsMcp } from './trips.mcp'; import { AuthModule } from '../auth/auth.module'; /** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does * not capture the nested sub-domain mounts (collab, files, ...). */ @Module({ - imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule], + imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule], controllers: [TripsController], providers: [TripsService, TripsMcp], // Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection). diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 2ddf230dd..f24d3c0cc 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -20,7 +20,7 @@ import { ReservationsService } from '../reservations/reservations.service'; import { FilesService } from '../files/files.service'; import { CollabService } from '../collab/collab.service'; import { VacayService } from '../vacay/vacay.service'; -import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashService'; +import { UnsplashService } from '../unsplash/unsplash.service'; export const MS_PER_DAY = 86400000; export const MAX_TRIP_DAYS = 365; @@ -227,6 +227,7 @@ export class TripsService { private readonly vacay: VacayService, private readonly realtime: RealtimeService, private readonly places: PlacesService, + private readonly unsplash: UnsplashService, ) {} private get db() { @@ -419,7 +420,7 @@ export class TripsService { } searchCoverImages(query: string, userId: number) { - return searchUnsplashPhotos(query, 9, getUnsplashKey(userId)); + return this.unsplash.searchUnsplashPhotos(query, 9, this.unsplash.getUnsplashKey(userId)); } getOwner(tripId: string | number): { user_id: number } | undefined { diff --git a/server/src/nest/unsplash/unsplash.module.ts b/server/src/nest/unsplash/unsplash.module.ts new file mode 100644 index 000000000..3e73f7034 --- /dev/null +++ b/server/src/nest/unsplash/unsplash.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { UnsplashService } from './unsplash.service'; +import { AppConfigModule } from '../app-config/app-config.module'; + +/** Unsplash cover search and download. No controller of its own — trips and + * places both reach it. Deliberately NOT @Global, matching PermissionsModule: + * e2e TestingModules resolve it through each consumer's explicit import. + * AppConfigModule is imported explicitly because @Global only applies to + * modules that are in the graph, which a single-domain TestingModule is not. */ +@Module({ + imports: [AppConfigModule], + providers: [UnsplashService], + exports: [UnsplashService], +}) +export class UnsplashModule {} diff --git a/server/src/services/unsplashService.ts b/server/src/nest/unsplash/unsplash.service.ts similarity index 73% rename from server/src/services/unsplashService.ts rename to server/src/nest/unsplash/unsplash.service.ts index c5b2ae31c..b002b29e7 100644 --- a/server/src/services/unsplashService.ts +++ b/server/src/nest/unsplash/unsplash.service.ts @@ -1,10 +1,11 @@ import path from 'path'; import fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; -import { readEnv } from '../app-config'; -import { safeFetch } from '../utils/ssrfGuard'; -import { db } from '../db/database'; -import { decrypt_api_key } from '../nest/common/crypto/apiKeyCrypto'; +import { Injectable } from '@nestjs/common'; +import { safeFetch } from '../../utils/ssrfGuard'; +import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { DatabaseService } from '../database/database.service'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; interface UnsplashSearchResponse { results?: { @@ -28,24 +29,47 @@ export interface UnsplashPhoto { link: string | null; } +const UNSPLASH_IMAGE_HOST = 'images.unsplash.com'; +const MAX_COVER_BYTES = 15 * 1024 * 1024; +const COVER_EXT_BY_TYPE: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', +}; + /** + * Unsplash search and cover download. + * + * Its own module rather than a method on trips or places: both call it, and the + * key resolution reads the env AND the users table, so it needs the injected + * connection either way. + */ +@Injectable() +export class UnsplashService { + constructor( + private readonly db: DatabaseService, + private readonly env: RuntimeEnvService, + ) {} + + /** * Resolve an Unsplash access key for a request, in precedence order: * the `UNSPLASH_ACCESS_KEY` env var (instance-wide override, matching the * SMTP/OIDC env-over-DB convention) → the caller's own stored key → any * admin's stored key. Mirrors `getMapsKey`. Returns null when none is set, * in which case the search falls back to the unauthenticated endpoint. */ -export function getUnsplashKey(userId: number): string | null { - const env_key = readEnv().integrations.unsplashAccessKey; - if (env_key) return env_key; - const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined; - const user_key = decrypt_api_key(user?.unsplash_api_key); - if (user_key) return user_key; - const admin = db.prepare("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1").get() as { unsplash_api_key: string } | undefined; - return decrypt_api_key(admin?.unsplash_api_key) || null; -} + getUnsplashKey(userId: number): string | null { + const env_key = this.env.env().integrations.unsplashAccessKey; + if (env_key) return env_key; + const user = this.db.get<{ unsplash_api_key: string | null }>('SELECT unsplash_api_key FROM users WHERE id = ?', userId); + const user_key = decrypt_api_key(user?.unsplash_api_key); + if (user_key) return user_key; + const admin = this.db.get<{ unsplash_api_key: string }>("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1"); + return decrypt_api_key(admin?.unsplash_api_key) || null; + } -export async function searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) { + async searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) { const trimmed = query.trim(); if (!trimmed) { return { error: 'Search query is required', status: 400 }; @@ -103,17 +127,8 @@ export async function searchUnsplashPhotos(query: string, perPage = 9, accessKey return { photos }; } -const UNSPLASH_IMAGE_HOST = 'images.unsplash.com'; -const MAX_COVER_BYTES = 15 * 1024 * 1024; -const COVER_EXT_BY_TYPE: Record = { - 'image/jpeg': '.jpg', - 'image/png': '.png', - 'image/webp': '.webp', - 'image/gif': '.gif', -}; - /** True when a cover_image value is an Unsplash CDN hot-link we should internalise. */ -export function isUnsplashCoverUrl(value: unknown): value is string { + isUnsplashCoverUrl(value: unknown): value is string { if (typeof value !== 'string') return false; try { return new URL(value).hostname.toLowerCase() === UNSPLASH_IMAGE_HOST; @@ -129,8 +144,8 @@ export function isUnsplashCoverUrl(value: unknown): value is string { * guard. Returns the saved filename. Throws on a non-Unsplash host, a failed * download, an unsupported content type, or an oversized image. */ -export async function saveUnsplashCover(url: string, destDir: string): Promise { - if (!isUnsplashCoverUrl(url)) throw new Error('Not an Unsplash image URL'); + async saveUnsplashCover(url: string, destDir: string): Promise { + if (!this.isUnsplashCoverUrl(url)) throw new Error('Not an Unsplash image URL'); const res = await safeFetch(url); if (!res.ok) throw new Error(`Unsplash image download failed (HTTP ${res.status})`); const type = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); @@ -143,3 +158,4 @@ export async function saveUnsplashCover(url: string, destDir: string): Promise ({ provider, model: 'm', baseUrl: 'http://x', multimodal: false } as unknown as ResolvedLlmConfig); diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index b19888fe6..be2bcb9ab 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -10,6 +10,8 @@ * are mocked where needed. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; +import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { TRACK_COLORS } from '@trek/shared'; // ── DB setup ────────────────────────────────────────────────────────────────── @@ -74,7 +76,7 @@ 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)); +const svc = new PlacesService(dbs, new PermissionsService(dbs), new RealtimeService(), new MapsService(dbs), new QueryHelpersService(dbs), new UnsplashService(dbs, new RuntimeEnvService())); beforeAll(() => { createTables(testDb); @@ -1012,7 +1014,7 @@ describe('PlacesService — automatic track colours (#776)', () => { describe('enrichImportedPlaces', () => { function enrichSvc(maps: Partial) { - return new PlacesService(dbs, new PermissionsService(dbs), new RealtimeService(), maps as MapsService, new QueryHelpersService(dbs)); + return new PlacesService(dbs, new PermissionsService(dbs), new RealtimeService(), maps as MapsService, new QueryHelpersService(dbs), new UnsplashService(dbs, new RuntimeEnvService())); } it('PLACE-SVC-058 — no-ops when no Google Maps key is configured', async () => { diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 7de402043..25f25e11e 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { HttpException } from '@nestjs/common'; import type { Request } from 'express'; @@ -7,12 +8,13 @@ vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => ' vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); const { isDemoEmail } = vi.hoisted(() => ({ isDemoEmail: vi.fn(() => false) })); vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail })); -// Mock the Unsplash cover internalisation so the controller test never hits the -// network; isUnsplashCoverUrl keeps its real (host-based) logic. -vi.mock('../../../src/services/unsplashService', () => ({ +// Injected stub since the unsplash fold (was a path mock of the service module), +// so the controller test never hits the network. isUnsplashCoverUrl keeps its +// real host-based logic. +const unsplashStub = { isUnsplashCoverUrl: (v: unknown) => typeof v === 'string' && v.startsWith('https://images.unsplash.com/'), saveUnsplashCover: vi.fn().mockResolvedValue('mock-cover.jpg'), -})); +} as unknown as UnsplashService; import { TripsController } from '../../../src/nest/trips/trips.controller'; import type { TripsService } from '../../../src/nest/trips/trips.service'; @@ -27,7 +29,7 @@ const req = { headers: {} } as Request; // wrapper keeps the historical construction sites positional. const writeAudit = vi.fn(); const audit = { writeAudit } as unknown as AuditService; -const tc = (s: TripsService) => new TripsController(s, audit, new RuntimeEnvService()); +const tc = (s: TripsService) => new TripsController(s, audit, new RuntimeEnvService(), unsplashStub); function svc(o: Partial = {}): TripsService { return { diff --git a/server/tests/unit/services/unsplashService.test.ts b/server/tests/unit/nest/unsplash/unsplash.service.test.ts similarity index 88% rename from server/tests/unit/services/unsplashService.test.ts rename to server/tests/unit/nest/unsplash/unsplash.service.test.ts index ab734187b..9e88c0c86 100644 --- a/server/tests/unit/services/unsplashService.test.ts +++ b/server/tests/unit/nest/unsplash/unsplash.service.test.ts @@ -7,13 +7,23 @@ import path from 'path'; // db is mocked so getUnsplashKey resolves from a controllable stub, and // decrypt_api_key is a passthrough so stored values compare as plaintext. const { safeFetch, mockDbGet } = vi.hoisted(() => ({ safeFetch: vi.fn(), mockDbGet: vi.fn(() => undefined as unknown) })); -vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetch })); -vi.mock('../../../src/db/database', () => ({ +vi.mock('../../../../src/utils/ssrfGuard', () => ({ safeFetch })); +vi.mock('../../../../src/db/database', () => ({ db: { prepare: () => ({ get: mockDbGet, all: vi.fn(() => []), run: vi.fn() }) }, })); -vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ decrypt_api_key: (v: string | null) => v })); +vi.mock('../../../../src/nest/common/crypto/apiKeyCrypto', () => ({ decrypt_api_key: (v: string | null) => v })); -import { searchUnsplashPhotos, getUnsplashKey, saveUnsplashCover, isUnsplashCoverUrl } from '../../../src/services/unsplashService'; +import { UnsplashService } from '../../../../src/nest/unsplash/unsplash.service'; +import { DatabaseService } from '../../../../src/nest/database/database.service'; +import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service'; +import { db } from '../../../../src/db/database'; + +// Same four entry points, now methods. The db mock above still feeds them. +const svc = new UnsplashService(new DatabaseService(db), new RuntimeEnvService()); +const searchUnsplashPhotos = svc.searchUnsplashPhotos.bind(svc); +const getUnsplashKey = svc.getUnsplashKey.bind(svc); +const saveUnsplashCover = svc.saveUnsplashCover.bind(svc); +const isUnsplashCoverUrl = svc.isUnsplashCoverUrl.bind(svc); const ORIGINAL_UNSPLASH_ENV = process.env.UNSPLASH_ACCESS_KEY;