Files
TREK/server/tests/unit/nest/unsplash/unsplash.service.test.ts
T
Maurice 90e35e6970 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.
2026-08-07 19:34:31 +02:00

163 lines
7.9 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import os from 'os';
import fs from 'fs';
import path from 'path';
// safeFetch is mocked so saveUnsplashCover never hits the network.
// 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', () => ({
db: { prepare: () => ({ get: mockDbGet, all: vi.fn(() => []), run: vi.fn() }) },
}));
vi.mock('../../../../src/nest/common/crypto/apiKeyCrypto', () => ({ decrypt_api_key: (v: string | null) => v }));
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;
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
mockDbGet.mockReturnValue(undefined);
if (ORIGINAL_UNSPLASH_ENV === undefined) delete process.env.UNSPLASH_ACCESS_KEY;
else process.env.UNSPLASH_ACCESS_KEY = ORIGINAL_UNSPLASH_ENV;
});
function fakeRes(init: { ok: boolean; status?: number; type?: string; bytes?: number; json?: unknown }): Response {
return {
ok: init.ok,
status: init.status ?? (init.ok ? 200 : 500),
headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? init.type ?? '' : null) },
arrayBuffer: async () => new ArrayBuffer(init.bytes ?? 8),
json: async () => init.json ?? {},
} as unknown as Response;
}
describe('unsplashService.isUnsplashCoverUrl', () => {
it('UNSPLASH-001: accepts only the Unsplash image CDN host', () => {
expect(isUnsplashCoverUrl('https://images.unsplash.com/photo-1?w=1080')).toBe(true);
expect(isUnsplashCoverUrl('https://evil.example.com/x.jpg')).toBe(false);
expect(isUnsplashCoverUrl('/uploads/covers/local.jpg')).toBe(false);
expect(isUnsplashCoverUrl(null)).toBe(false);
expect(isUnsplashCoverUrl(undefined)).toBe(false);
});
});
describe('unsplashService.searchUnsplashPhotos', () => {
it('UNSPLASH-002: rejects an empty query without hitting the network', async () => {
expect(await searchUnsplashPhotos(' ')).toEqual({ error: 'Search query is required', status: 400 });
});
it('UNSPLASH-003: maps a non-ok response to an error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes({ ok: false, status: 429, type: 'application/json', json: { errors: ['Rate limited'] } })));
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Rate limited', status: 429 });
});
it('UNSPLASH-004: returns normalised photos on success and drops entries missing a url/thumb', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes({
ok: true,
type: 'application/json',
json: {
results: [
{ id: 'a', urls: { regular: 'https://images.unsplash.com/a', small: 'https://images.unsplash.com/a-s' }, user: { name: 'Alice' }, links: { html: 'https://unsplash.com/a' } },
{ id: 'b', urls: {} }, // dropped — no url/thumb
],
},
})));
const res = await searchUnsplashPhotos('paris') as { photos: { id: string }[] };
expect(res.photos).toHaveLength(1);
expect(res.photos[0]).toMatchObject({ id: 'a', photographer: 'Alice', link: 'https://unsplash.com/a' });
});
it('UNSPLASH-010: hits the unauthenticated web endpoint when no access key is given', async () => {
const fetchMock = vi.fn().mockResolvedValue(fakeRes({ ok: true, type: 'application/json', json: { results: [] } }));
vi.stubGlobal('fetch', fetchMock);
await searchUnsplashPhotos('paris');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('unsplash.com/napi/search/photos');
expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
});
it('UNSPLASH-011: hits the official API with a Client-ID header when an access key is given', async () => {
const fetchMock = vi.fn().mockResolvedValue(fakeRes({ ok: true, type: 'application/json', json: { results: [] } }));
vi.stubGlobal('fetch', fetchMock);
await searchUnsplashPhotos('paris', 9, 'my-access-key');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('api.unsplash.com/search/photos');
expect((init.headers as Record<string, string>).Authorization).toBe('Client-ID my-access-key');
expect((init.headers as Record<string, string>)['Accept-Version']).toBe('v1');
});
});
describe('unsplashService.getUnsplashKey', () => {
it('UNSPLASH-012: prefers the UNSPLASH_ACCESS_KEY env var over any stored key', () => {
process.env.UNSPLASH_ACCESS_KEY = 'env-key';
mockDbGet.mockReturnValue({ unsplash_api_key: 'user-key' });
expect(getUnsplashKey(1)).toBe('env-key');
expect(mockDbGet).not.toHaveBeenCalled();
});
it('UNSPLASH-013: returns the user key when set and no env var', () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockDbGet.mockReturnValueOnce({ unsplash_api_key: 'user-key' });
expect(getUnsplashKey(1)).toBe('user-key');
});
it('UNSPLASH-014: falls back to the admin key when the user has none', () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockDbGet.mockReturnValueOnce({ unsplash_api_key: null });
mockDbGet.mockReturnValueOnce({ unsplash_api_key: 'admin-key' });
expect(getUnsplashKey(1)).toBe('admin-key');
});
it('UNSPLASH-015: returns null when neither env, user, nor admin has a key', () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockDbGet.mockReturnValue(undefined);
expect(getUnsplashKey(1)).toBeNull();
});
});
describe('unsplashService.saveUnsplashCover', () => {
const dir = path.join(os.tmpdir(), 'trek-unsplash-cover-test');
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } });
it('UNSPLASH-005: rejects a non-Unsplash host before any fetch', async () => {
await expect(saveUnsplashCover('https://evil.example.com/x.jpg', dir)).rejects.toThrow('Not an Unsplash image URL');
expect(safeFetch).not.toHaveBeenCalled();
});
it('UNSPLASH-006: downloads an Unsplash image and writes it locally', async () => {
safeFetch.mockResolvedValue(fakeRes({ ok: true, type: 'image/jpeg', bytes: 1234 }));
const filename = await saveUnsplashCover('https://images.unsplash.com/photo-1?w=1080', dir);
expect(filename).toMatch(/\.jpg$/);
expect(fs.existsSync(path.join(dir, filename))).toBe(true);
});
it('UNSPLASH-007: rejects an unsupported content type', async () => {
safeFetch.mockResolvedValue(fakeRes({ ok: true, type: 'text/html' }));
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1', dir)).rejects.toThrow(/Unsupported cover image type/);
});
it('UNSPLASH-008: rejects an oversized image', async () => {
safeFetch.mockResolvedValue(fakeRes({ ok: true, type: 'image/png', bytes: 16 * 1024 * 1024 }));
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1', dir)).rejects.toThrow('Cover image too large');
});
it('UNSPLASH-009: throws when the download fails', async () => {
safeFetch.mockResolvedValue(fakeRes({ ok: false, status: 404 }));
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1', dir)).rejects.toThrow(/HTTP 404/);
});
});