Files
TREK/server/tests/unit/nest/photo-resolver.service.test.ts
T
Maurice c0d90d57b2 refactor(server): fold the memories domain into providers
The seven files in services/memories/ become injectable services. Every SQL
statement, error string, status code and header moves verbatim; what changes is
who owns what.

photoResolverService was two files in one, so it stayed two: the trek_photos
rows are a repository under photos/, the provider dispatch is
PhotoResolverService under memories/. That is the photos/ vs memories/ boundary —
storage there, dispatch here — and it is also why the module graph is a straight
line: TrekPhotosModule is a leaf both halves import, so neither needs a
forwardRef.

pipeAsset and the ServiceResult envelope stay free functions over the Express
response. pipeAsset is deliberately not a StreamableFile: it forwards the
upstream 206 and Content-Range so a <video> can seek (#823), swaps Cache-Control
to no-store on an error status, and answers differently once headers are sent.

The trek-photo cache keeps its in-flight map at module scope. That map is the
stampede guard, and the scheduler builds its own instance through a lazy require
outside the container — a per-instance map would give them separate guards and
ten tiles would fetch the same asset ten times, silently.

journeyService's dynamic import() of synologyService is gone: it existed only to
duck the import cycle, which no longer exists.

MemoriesModule imports RealtimeModule explicitly even though it is @Global, so a
single-domain e2e TestingModule can still resolve the broadcast.

The providers arrived at 60% branch coverage or less, because services/memories/
sits outside the gate and the integration suites drive the domain over HTTP
without reaching the failure paths. New suites cover what had no case at all:
the access checks that decide who may fetch someone else's photo, the stampede
guard, the thumbnail mtime check, the poster-less-video 404, and Immich end to
end with safeFetch stubbed — including the hidden-asset filter older servers
depend on (#1474) and the http -> https canonical detection that must NOT
rewrite a changed host.
2026-08-08 22:27:56 +02:00

231 lines
9.2 KiB
TypeScript

/**
* PhotoResolverService — the provider dispatch half of the old
* photoResolverService.
*
* It routes a stored trek_photo to bytes: local file, generated thumbnail, or
* the provider that owns the asset. None of this was measured before the fold
* (services/memories/ sits outside the coverage gate), so the branch that
* decides a poster-less video must 404 rather than stream the whole file as a
* "thumbnail" had no case at all.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
return {
testDb: db,
dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null },
};
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'x'.repeat(40),
ENCRYPTION_KEY: 'a'.repeat(64),
updateJwtSecret: () => {},
}));
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: (v: string) => v,
encrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: (v: string) => v,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
import type { ThumbnailService } from '../../../src/nest/memories/thumbnail.service';
import type { TrekPhotoCacheService } from '../../../src/nest/memories/trek-photo-cache.service';
const immich = {
streamImmichAsset: vi.fn(),
fetchImmichThumbnailBytes: vi.fn(),
getAssetInfo: vi.fn(),
};
const synology = {
streamSynologyAsset: vi.fn(),
fetchSynologyThumbnailBytes: vi.fn(),
getSynologyAssetInfo: vi.fn(),
};
const thumbnails = { ensureLocalThumbnail: vi.fn() };
// The cache has its own suite; here it is a switch for hit/miss/in-flight.
const cache = {
cacheKey: vi.fn(() => 'k'),
serveFresh: vi.fn(() => false),
getInFlight: vi.fn(() => undefined),
setInFlight: vi.fn(),
put: vi.fn(),
};
const dbs = new DatabaseService(testDb);
const repo = new TrekPhotosRepository(dbs);
const svc = new PhotoResolverService(
repo,
immich as unknown as ImmichService,
synology as unknown as SynologyService,
thumbnails as unknown as ThumbnailService,
cache as unknown as TrekPhotoCacheService,
);
function makeRes() {
return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), sendFile: vi.fn(), end: vi.fn() };
}
/** Insert a trek_photos row directly so each case controls the exact shape. */
function insertPhoto(cols: Record<string, unknown>): number {
const keys = Object.keys(cols);
const res = testDb.prepare(
`INSERT INTO trek_photos (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`
).run(...keys.map(k => cols[k] as never));
return Number(res.lastInsertRowid);
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
vi.clearAllMocks();
// owner_id carries a FK to users; the cases pick fixed ids, so seed them.
testDb.prepare('DELETE FROM trek_photos').run();
for (const id of [2, 3, 4, 5, 9]) {
testDb.prepare("INSERT OR IGNORE INTO users (id, username, email, password_hash) VALUES (?, ?, ?, 'x')")
.run(id, `u${id}`, `u${id}@example.test`);
}
cache.serveFresh.mockReturnValue(false);
cache.getInFlight.mockReturnValue(undefined);
cache.cacheKey.mockReturnValue('k');
});
afterAll(() => testDb.close());
describe('streamPhoto — dispatch', () => {
it('RESOLVE-001: 404s a photo id that does not exist', async () => {
const res = makeRes();
await svc.streamPhoto(res as never, 1, 999999, 'original');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not found' });
});
it('RESOLVE-002: a local row whose file is gone answers "File not found", not a provider call', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/missing.jpg' });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'original');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'File not found' });
expect(immich.streamImmichAsset).not.toHaveBeenCalled();
expect(synology.streamSynologyAsset).not.toHaveBeenCalled();
});
it('RESOLVE-003: a poster-less video 404s instead of streaming the whole file as a thumbnail', async () => {
// #823: falling through here would push a full video down a thumbnail request.
const id = insertPhoto({ provider: 'local', file_path: 'nope/clip.mp4', media_type: 'video' });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'thumbnail');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'No poster available' });
expect(thumbnails.ensureLocalThumbnail).not.toHaveBeenCalled();
});
it('RESOLVE-004: a local image with no thumbnail asks for one to be generated', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' });
thumbnails.ensureLocalThumbnail.mockResolvedValue(null);
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'thumbnail');
expect(thumbnails.ensureLocalThumbnail).toHaveBeenCalledOnce();
});
it('RESOLVE-005: a generated thumbnail is recorded on the row', async () => {
const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' });
thumbnails.ensureLocalThumbnail.mockResolvedValue({ thumbnailRelPath: 'journey/thumbs/abc.jpg', width: 800, height: 600 });
const res = makeRes();
await svc.streamPhoto(res as never, 1, id, 'thumbnail');
const row = testDb.prepare('SELECT thumbnail_path, width, height FROM trek_photos WHERE id = ?').get(id) as { thumbnail_path: string; width: number; height: number };
expect(row).toEqual({ thumbnail_path: 'journey/thumbs/abc.jpg', width: 800, height: 600 });
});
it('RESOLVE-006: an immich original goes straight to the provider with the range header', async () => {
const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
const res = makeRes();
await svc.streamPhoto(res as never, 3, id, 'original', 'bytes=0-99');
expect(immich.streamImmichAsset).toHaveBeenCalledWith(res, 3, 'a1', 'original', 5, expect.objectContaining({ range: 'bytes=0-99' }));
});
it('RESOLVE-007: an immich thumbnail served from cache never reaches the provider', async () => {
const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
cache.serveFresh.mockReturnValue(true);
const res = makeRes();
await svc.streamPhoto(res as never, 3, id, 'thumbnail');
expect(immich.fetchImmichThumbnailBytes).not.toHaveBeenCalled();
expect(immich.streamImmichAsset).not.toHaveBeenCalled();
});
it('RESOLVE-008: a synology original forwards the decrypted passphrase', async () => {
const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9, passphrase: 'secret' });
const res = makeRes();
await svc.streamPhoto(res as never, 4, id, 'original');
expect(synology.streamSynologyAsset).toHaveBeenCalledWith(res, 4, 9, 's1', 'original', undefined, 'secret');
});
it('RESOLVE-009: a synology row without a passphrase forwards undefined, not an empty string', async () => {
const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's2', owner_id: 9 });
const res = makeRes();
await svc.streamPhoto(res as never, 4, id, 'original');
expect(synology.streamSynologyAsset).toHaveBeenCalledWith(res, 4, 9, 's2', 'original', undefined, undefined);
});
});
describe('getPhotoInfo — dispatch', () => {
it('RESOLVE-010: rejects an unknown photo id', async () => {
const result = await svc.getPhotoInfo(1, 999999);
expect(result.success).toBe(false);
});
it('RESOLVE-011: asks immich for an immich asset', async () => {
const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 });
immich.getAssetInfo.mockResolvedValue({ success: true, data: { id: 'a1' } });
await svc.getPhotoInfo(3, id);
expect(immich.getAssetInfo).toHaveBeenCalled();
expect(synology.getSynologyAssetInfo).not.toHaveBeenCalled();
});
it('RESOLVE-012: asks synology for a synology asset', async () => {
const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9 });
synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: 's1' } });
await svc.getPhotoInfo(4, id);
expect(synology.getSynologyAssetInfo).toHaveBeenCalled();
expect(immich.getAssetInfo).not.toHaveBeenCalled();
});
it('RESOLVE-013: an unknown provider is an error, not a crash', async () => {
const id = insertPhoto({ provider: 'picasa', asset_id: 'p1', owner_id: 2 });
const result = await svc.getPhotoInfo(1, id);
expect(result.success).toBe(false);
});
});