Files
TREK/server/tests/unit/nest/thumbnail.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

105 lines
4.3 KiB
TypeScript

/**
* ThumbnailService — the downscaled JPEG for a locally uploaded journey photo.
*
* Untested before the fold, because it lived outside the measured tree. The
* cases below pin the three things that decide whether a gallery shows a
* picture or a broken tile: the addon gate, the "source is gone" bail-out, and
* the mtime check that avoids regenerating an up-to-date thumbnail.
*/
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Jimp } from 'jimp';
import { ThumbnailService } from '../../../src/nest/memories/thumbnail.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
const svc = new ThumbnailService({ isAddonEnabled } as unknown as AddonsService);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-thumbs-'));
/** A real 1200x900 JPEG — Jimp has to be able to decode it for the happy path. */
async function writeSourceImage(rel: string): Promise<void> {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
const img = new Jimp({ width: 1200, height: 900, color: 0xff0000ff });
await img.write(abs as `${string}.jpg`);
}
beforeEach(() => {
vi.clearAllMocks();
isAddonEnabled.mockReturnValue(true);
});
afterAll(() => {
fs.rmSync(root, { recursive: true, force: true });
});
describe('ensureLocalThumbnail', () => {
it('THUMB-001: returns null when the journey addon is off, without touching the disk', async () => {
isAddonEnabled.mockReturnValue(false);
expect(await svc.ensureLocalThumbnail(root, 'anything.jpg')).toBeNull();
});
it('THUMB-002: returns null when the source file does not exist', async () => {
expect(await svc.ensureLocalThumbnail(root, 'missing/nope.jpg')).toBeNull();
});
it('THUMB-003: downscales an oversized image and reports the resulting size', async () => {
await writeSourceImage('journey/big.jpg');
const result = await svc.ensureLocalThumbnail(root, 'journey/big.jpg');
expect(result).not.toBeNull();
expect(result!.thumbnailRelPath).toMatch(/^journey\/thumbs\/[0-9a-f]{16}\.jpg$/);
// 1200x900 fits into an 800 box as 800x600.
expect(Math.max(result!.width, result!.height)).toBeLessThanOrEqual(800);
expect(fs.existsSync(path.join(root, result!.thumbnailRelPath))).toBe(true);
});
it('THUMB-004: the path is deterministic, so concurrent requests cannot race on two names', async () => {
await writeSourceImage('journey/stable.jpg');
const first = await svc.ensureLocalThumbnail(root, 'journey/stable.jpg');
const second = await svc.ensureLocalThumbnail(root, 'journey/stable.jpg');
expect(second!.thumbnailRelPath).toBe(first!.thumbnailRelPath);
});
it('THUMB-005: reuses an existing thumbnail that is newer than the source', async () => {
await writeSourceImage('journey/cached.jpg');
const first = await svc.ensureLocalThumbnail(root, 'journey/cached.jpg');
const thumbAbs = path.join(root, first!.thumbnailRelPath);
const mtimeBefore = fs.statSync(thumbAbs).mtimeMs;
const second = await svc.ensureLocalThumbnail(root, 'journey/cached.jpg');
expect(second).toEqual(first);
expect(fs.statSync(thumbAbs).mtimeMs).toBe(mtimeBefore);
});
it('THUMB-006: regenerates when the source is newer than the thumbnail', async () => {
await writeSourceImage('journey/changed.jpg');
const first = await svc.ensureLocalThumbnail(root, 'journey/changed.jpg');
const thumbAbs = path.join(root, first!.thumbnailRelPath);
// Age the thumbnail past the source.
const old = new Date(Date.now() - 60_000);
fs.utimesSync(thumbAbs, old, old);
const second = await svc.ensureLocalThumbnail(root, 'journey/changed.jpg');
expect(second!.thumbnailRelPath).toBe(first!.thumbnailRelPath);
expect(fs.statSync(thumbAbs).mtimeMs).toBeGreaterThan(old.getTime());
});
it('THUMB-007: returns null for a file Jimp cannot decode instead of throwing', async () => {
const abs = path.join(root, 'journey/corrupt.jpg');
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, 'not an image at all');
expect(await svc.ensureLocalThumbnail(root, 'journey/corrupt.jpg')).toBeNull();
});
});