Files
immich-folio/lib/__tests__/urls.test.ts
2026-10-02 18:14:23 +02:00

386 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest';
// Mock tokens so we get predictable URL output
vi.mock('@/lib/tokens', () => ({
encodeAssetId: (id: string) => `ENCODED_${id}`,
}));
// Mock thumbhash helpers
vi.mock('@/lib/thumbhash', () => ({
thumbHashToBlurDataUrl: () => 'data:image/png;base64,mockblur',
thumbHashToDominantHex: () => '#aabbcc',
}));
import {
imageUrl,
exifUrl,
assetPlaceholder,
assetExifSummary,
assetAspectRatio,
assetCaption,
editMarker,
} from '@/lib/urls';
describe('imageUrl', () => {
it('generates a proxy URL with encoded token and default size', () => {
const url = imageUrl('some-uuid');
expect(url).toBe('/api/image/ENCODED_some-uuid?size=preview');
});
it('uses the specified size parameter', () => {
expect(imageUrl('id', 'thumbnail')).toBe('/api/image/ENCODED_id?size=thumbnail');
expect(imageUrl('id', 'original')).toBe('/api/image/ENCODED_id?size=original');
});
});
describe('imageUrl for photos edited in Immich (#831)', () => {
const UPDATED = '2026-09-04T19:22:09.220Z';
it('adds the edit marker only for an edited photo', () => {
const marker = Math.floor(Date.parse(UPDATED) / 1000).toString(36);
expect(imageUrl({ id: 'id', isEdited: true, updatedAt: UPDATED })).toBe(
`/api/image/ENCODED_id?size=preview&e=${marker}`,
);
expect(imageUrl({ id: 'id', isEdited: true, updatedAt: UPDATED }, 'thumbnail')).toBe(
`/api/image/ENCODED_id?size=thumbnail&e=${marker}`,
);
});
it('leaves every other URL exactly as before', () => {
expect(imageUrl({ id: 'id', isEdited: false, updatedAt: UPDATED })).toBe(
'/api/image/ENCODED_id?size=preview',
);
expect(imageUrl({ id: 'id', updatedAt: UPDATED })).toBe('/api/image/ENCODED_id?size=preview');
expect(imageUrl({ id: 'id' })).toBe(imageUrl('id'));
});
it('changes the marker when the photo is edited again', () => {
const first = editMarker({ isEdited: true, updatedAt: UPDATED });
const again = editMarker({ isEdited: true, updatedAt: '2026-10-01T08:00:00.000Z' });
expect(first).toMatch(/^[0-9a-z]{1,16}$/);
expect(again).toMatch(/^[0-9a-z]{1,16}$/);
expect(again).not.toBe(first);
});
it('reveals the update time to the second only (review of #832)', () => {
const marker = editMarker({ isEdited: true, updatedAt: UPDATED });
expect(marker).toBe((Date.parse('2026-09-04T19:22:09Z') / 1000).toString(36));
expect(editMarker({ isEdited: true, updatedAt: '2026-09-04T19:22:09.999Z' })).toBe(marker);
expect(editMarker({ isEdited: true, updatedAt: '2026-09-04T19:22:10.000Z' })).not.toBe(marker);
});
it('still marks an edited photo whose update time is unknown', () => {
expect(editMarker({ isEdited: true })).toBe('1');
expect(editMarker({ isEdited: true, updatedAt: 'not a date' })).toBe('1');
expect(editMarker({ isEdited: false, updatedAt: UPDATED })).toBeNull();
expect(editMarker({})).toBeNull();
});
});
describe('exifUrl', () => {
it('generates an EXIF API URL with encoded token', () => {
expect(exifUrl('my-asset')).toBe('/api/exif/ENCODED_my-asset');
});
});
describe('assetPlaceholder', () => {
it('returns blur data and dominant color for an asset with thumbhash', () => {
const result = assetPlaceholder({ thumbhash: 'abc123' });
expect(result).toEqual({
blurDataURL: 'data:image/png;base64,mockblur',
dominantColor: '#aabbcc',
});
});
it('returns null when thumbhash is null', () => {
expect(assetPlaceholder({ thumbhash: null })).toBeNull();
});
it('returns null when thumbhash is empty string', () => {
expect(assetPlaceholder({ thumbhash: '' })).toBeNull();
});
});
describe('assetExifSummary', () => {
it('returns camera, lens, and focal length from exifInfo', () => {
const result = assetExifSummary({
exifInfo: {
make: 'Leica',
model: 'M11-P',
lensModel: 'Summilux-M 50mm',
focalLength: 50,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: null,
exifImageHeight: null,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
});
expect(result).toEqual({
camera: 'M11-P',
lens: 'Summilux-M 50mm',
focalLength: '50mm',
});
});
it('returns undefined when no exifInfo exists', () => {
expect(assetExifSummary({ exifInfo: undefined })).toBeUndefined();
});
it('returns undefined when all relevant fields are null', () => {
expect(
assetExifSummary({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: null,
exifImageHeight: null,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
}),
).toBeUndefined();
});
});
describe('assetAspectRatio', () => {
it('computes width / height ratio', () => {
const result = assetAspectRatio({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: 3000,
exifImageHeight: 2000,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
});
expect(result).toBe(1.5);
});
it('returns undefined when dimensions are missing', () => {
expect(assetAspectRatio({ exifInfo: undefined })).toBeUndefined();
});
/**
* #831, measured on Immich 3.2: EXIF keeps describing the stored file after
* an edit in Immich, while `width`/`height` follow the edit.
*/
describe('a photo edited in Immich', () => {
const exif = (w: number, h: number, orientation: string | null = null) => ({
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: w,
exifImageHeight: h,
orientation,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
});
it('takes a quarter turn from the edited size', () => {
// A scan stored 1076×723, rotated 270° in Immich: shown 723×1076.
expect(
assetAspectRatio({ isEdited: true, width: 723, height: 1076, exifInfo: exif(1076, 723) }),
).toBeCloseTo(723 / 1076);
});
it('takes a crop from the edited size', () => {
// Stored 960×1280, cropped to 872×1132.
expect(
assetAspectRatio({ isEdited: true, width: 872, height: 1132, exifInfo: exif(960, 1280) }),
).toBeCloseTo(872 / 1132);
// Stored 8064×6048 with orientation 6 (portrait), cropped to landscape.
expect(
assetAspectRatio({
isEdited: true,
width: 6048,
height: 4838,
exifInfo: exif(8064, 6048, '6'),
}),
).toBeCloseTo(6048 / 4838);
});
it('keeps EXIF for a photo that is not edited', () => {
expect(
assetAspectRatio({ isEdited: false, width: 723, height: 1076, exifInfo: exif(3000, 2000) }),
).toBe(1.5);
});
it('does not fall back to EXIF, which describes the unedited file (review of #832)', () => {
expect(assetAspectRatio({ isEdited: true, exifInfo: exif(3000, 2000) })).toBeUndefined();
expect(
assetAspectRatio({ isEdited: true, width: null, height: 0, exifInfo: exif(3000, 2000) }),
).toBeUndefined();
});
});
it('returns undefined when height is 0', () => {
expect(
assetAspectRatio({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: 100,
exifImageHeight: 0,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
}),
).toBeUndefined();
});
/**
* A portrait frame off a Sony A7 V: the sensor is read out in landscape and
* the quarter turn lives in the orientation flag, so the stored dimensions
* are the displayed ones swapped.
*/
const rotated = (orientation?: string | null) => ({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: 7008,
exifImageHeight: 4672,
orientation,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
});
it.each([
['6', 'rotated 90 CW'],
['8', 'rotated 90 CCW'],
['5', 'mirrored and rotated 90 CCW'],
['7', 'mirrored and rotated 90 CW'],
])('swaps width and height for orientation %s (%s)', (orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(4672 / 7008);
});
it.each([
['1', 'upright'],
['2', 'mirrored horizontally'],
['3', 'rotated 180'],
['4', 'mirrored vertically'],
])('leaves the ratio alone for orientation %s (%s)', (orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
});
it.each([
['null', null],
['absent', undefined],
])('treats a %s orientation as upright', (_label, orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
});
});
describe('assetCaption', () => {
/** An asset carrying nothing but the description under test. */
const withDescription = (description: string | null) => ({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: null,
exifImageHeight: null,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description,
},
});
it('returns the description when captions are shown', () => {
expect(assetCaption(withDescription('A gull over the harbour'), true)).toBe(
'A gull over the harbour',
);
});
it('trims surrounding whitespace', () => {
expect(assetCaption(withDescription(' Low tide\n'), true)).toBe('Low tide');
});
it.each([
['null', null],
['empty', ''],
['whitespace only', ' \n\t '],
])('returns undefined for a %s description, leaving alt=""', (_label, value) => {
expect(assetCaption(withDescription(value), true)).toBeUndefined();
});
it('returns undefined when the asset has no exifInfo at all', () => {
expect(assetCaption({ exifInfo: undefined }, true)).toBeUndefined();
});
/**
* The point of #506: the description is the one field that can hold private
* notes. Alt text publishes it as surely as a visible caption does, so the
* caption switch has to gate it too.
*/
it('withholds the description when captions are switched off', () => {
expect(assetCaption(withDescription('Client hates this crop'), false)).toBeUndefined();
});
});