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:
Maurice
2026-08-07 19:34:31 +02:00
committed by GitHub
parent 7dea88120b
commit 90e35e6970
16 changed files with 105 additions and 53 deletions
@@ -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 {
@@ -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;