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;