mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(server): fold llmConfig and unsplash out of services/ (#1831)
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.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
@@ -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],
|
||||
|
||||
@@ -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<Place>('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));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
+42
-26
@@ -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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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<string> {
|
||||
if (!isUnsplashCoverUrl(url)) throw new Error('Not an Unsplash image URL');
|
||||
async saveUnsplashCover(url: string, destDir: string): Promise<string> {
|
||||
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<s
|
||||
fs.writeFileSync(path.join(destDir, filename), buf);
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { createLlmClient } from '../../../../src/nest/llm-parse/llm-client.factory';
|
||||
import { OpenAiCompatibleClient } from '../../../../src/nest/llm-parse/clients/openai-compatible.client';
|
||||
import { AnthropicClient } from '../../../../src/nest/llm-parse/clients/anthropic.client';
|
||||
import type { ResolvedLlmConfig } from '../../../../src/services/llmConfig';
|
||||
import type { ResolvedLlmConfig } from '../../../../src/nest/llm-parse/llm-config';
|
||||
|
||||
const cfg = (provider: string): ResolvedLlmConfig =>
|
||||
({ provider, model: 'm', baseUrl: 'http://x', multimodal: false } as unknown as ResolvedLlmConfig);
|
||||
|
||||
@@ -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<MapsService>) {
|
||||
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 () => {
|
||||
|
||||
@@ -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> = {}): TripsService {
|
||||
return {
|
||||
|
||||
+14
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user