mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
c0d90d57b2
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.
105 lines
4.3 KiB
TypeScript
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();
|
|
});
|
|
});
|