release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Suspense, isValidElement, type ReactElement } from 'react';
import fs from 'node:fs';
import path from 'node:path';
/**
* Next sends the status with the first byte, and the first byte goes out as
* soon as a Suspense fallback renders. The catch-all used to sit inside
* loading.tsx boundaries (app/ and app/[...path]/), so its skeleton streamed
* as a 200 before the page could call notFound(): `/nope`, an offline subpage,
* a draft content page and `/<subpage>/<unknown>` were all soft 404s — the
* not-found page with a 200.
*
* The page now decides whether the path exists before it renders a boundary,
* and only then streams the skeleton around the part that fetches photos.
*/
const NOT_FOUND = new Error('NEXT_NOT_FOUND');
vi.mock('next/navigation', () => ({
notFound: () => {
throw NOT_FOUND;
},
}));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
const admin = vi.hoisted(() => ({ signedIn: false }));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => admin.signedIn }));
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('en') };
});
const locked = vi.hoisted(() => ({ keys: new Set<string>() }));
vi.mock('@/lib/auth', () => ({
isProtected: (key: string) => locked.keys.has(key),
isAuthenticated: () => false,
withoutLockedAlbums: <T>(albums: T[]) => albums,
}));
const pages = vi.hoisted(() => ({
access: new Map<string, 'open' | 'draft' | 'locked'>(),
}));
vi.mock('../contentPage', () => ({
contentPageAccess: vi.fn(async (slug: string) => pages.access.get(slug) ?? null),
contentPageMetadata: vi.fn(async () => null),
renderContentPage: vi.fn(async () => null),
}));
const ICELAND = {
id: 'album-1',
albumName: 'Iceland',
slug: 'iceland',
assets: [],
albumThumbnailAssetId: null,
};
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn(),
getSubpageAlbums: vi.fn(),
findAlbumBySlug: vi.fn(),
getAlbumBySlug: vi.fn(),
getStandaloneAlbums: vi.fn(async () => []),
getSubpages: vi.fn(async () => []),
getAssetInfo: vi.fn(async () => null),
}));
vi.mock('@/lib/immich', () => ({ immich }));
import PathPage from '../page';
import { PathSkeleton } from '../PathSkeleton';
const call = (segments: string[]) => ({
params: Promise.resolve({ path: segments }),
searchParams: Promise.resolve({}),
});
beforeEach(() => {
vi.clearAllMocks();
admin.signedIn = false;
locked.keys.clear();
pages.access = new Map([
['about-us', 'open'],
['pricing', 'draft'],
['members', 'locked'],
]);
// `dubai` is `enabled: false`: isSubpageSlug() and every lookup skip it.
immich.isSubpageSlug.mockImplementation((slug: string) =>
['travel', 'vault', 'empty'].includes(slug),
);
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
if (slug === 'empty') return { subpage: { slug, name: 'Empty', albumIds: [] }, albums: [] };
if (slug === 'travel' || slug === 'vault') {
return { subpage: { slug, name: slug, albumIds: ['album-1'] }, albums: [ICELAND] };
}
return null;
});
immich.findAlbumBySlug.mockImplementation(async (slug: string, subpage?: string) =>
slug === 'iceland' && (subpage === undefined || subpage === 'travel' || subpage === 'vault')
? ICELAND
: null,
);
immich.getAlbumBySlug.mockResolvedValue(ICELAND);
});
describe('catch-all not-found status', () => {
it.each([
['an unknown slug', ['nope']],
['a long made-up slug', ['this-is-a-long-made-up-slug-that-names-nothing-at-all-0123456789']],
['a disabled subpage', ['dubai']],
['a draft content page', ['pricing']],
['a subpage without published albums', ['empty']],
['an unknown album under a real subpage', ['travel', 'nope']],
['an album under a disabled subpage', ['dubai', 'iceland']],
['a three-segment path', ['x', 'y', 'z']],
])('calls notFound() for %s before anything can stream', async (_, segments) => {
await expect(PathPage(call(segments))).rejects.toBe(NOT_FOUND);
// No photo was fetched: the decision came from the album list alone.
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
});
it.each([
['a subpage', ['travel']],
['an album under a subpage', ['travel', 'iceland']],
['a standalone album', ['iceland']],
['a content page', ['about-us']],
['a locked content page (its gate renders)', ['members']],
])('streams %s behind the skeleton, deciding first', async (_, segments) => {
const element = (await PathPage(call(segments))) as ReactElement<{ fallback: ReactElement }>;
expect(isValidElement(element)).toBe(true);
expect(element.type).toBe(Suspense);
expect(element.props.fallback.type).toBe(PathSkeleton);
// The asset fetch — the slow part — happens inside the boundary.
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
});
it('does not tell a visitor without the password which albums a locked subpage holds', async () => {
locked.keys.add('vault');
const known = await PathPage(call(['vault', 'iceland']));
const unknown = await PathPage(call(['vault', 'no-such-album']));
expect((known as ReactElement).type).toBe(Suspense);
expect((unknown as ReactElement).type).toBe(Suspense);
expect(immich.findAlbumBySlug).not.toHaveBeenCalled();
});
it('lets a locked subpage show its gate even before its albums are known', async () => {
locked.keys.add('empty');
const element = await PathPage(call(['empty']));
expect((element as ReactElement).type).toBe(Suspense);
});
it('leaves a signed-in admin the diagnostic banner instead of the 404', async () => {
admin.signedIn = true;
const element = await PathPage(call(['nope']));
expect((element as ReactElement).type).toBe(Suspense);
// ...but never for a path deeper than the catch-all serves.
await expect(PathPage(call(['x', 'y', 'z']))).rejects.toBe(NOT_FOUND);
});
it('surfaces an Immich outage as an error, never as a missing album', async () => {
const outage = new Error('Immich unavailable');
immich.findAlbumBySlug.mockRejectedValue(outage);
await expect(PathPage(call(['iceland']))).rejects.toBe(outage);
await expect(PathPage(call(['travel', 'iceland']))).rejects.toBe(outage);
});
it('has no loading.tsx above it, which would stream the 200 first', () => {
// A loading file wraps every route below its folder in a Suspense
// boundary, and nothing the page does inside one can change the status.
const appDir = path.join(process.cwd(), 'app');
for (const dir of [appDir, path.join(appDir, '[...path]')]) {
const loading = fs.readdirSync(dir).filter((name) => /^loading\.(t|j)sx?$/.test(name));
expect(loading, dir).toEqual([]);
}
});
});

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ReactElement } from 'react';
/**
* The catch-all answers one segment (subpage, content page, standalone album)
@@ -24,6 +25,7 @@ const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn((slug: string) => slug === 'travel'),
getSubpageAlbums: vi.fn(),
getAlbumBySlug: vi.fn(),
findAlbumBySlug: vi.fn(),
getStandaloneAlbums: vi.fn(async () => []),
getSubpages: vi.fn(async () => []),
getAssetInfo: vi.fn(async () => null),
@@ -49,6 +51,7 @@ const call = (path: string[]) => ({
beforeEach(() => {
vi.clearAllMocks();
immich.getAlbumBySlug.mockResolvedValue(ALBUM);
immich.findAlbumBySlug.mockResolvedValue(ALBUM);
immich.getSubpageAlbums.mockResolvedValue({
subpage: { slug: 'travel', name: 'Travel', albumIds: ['album-1', 'album-2'] },
albums: [ALBUM, { ...ALBUM, id: 'album-2', slug: 'norway', albumName: 'Norway' }],
@@ -61,6 +64,7 @@ describe('catch-all path depth', () => {
async (path) => {
await expect(PathPage(call(path))).rejects.toBe(NOT_FOUND);
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
expect(immich.findAlbumBySlug).not.toHaveBeenCalled();
expect(immich.getSubpageAlbums).not.toHaveBeenCalled();
},
);
@@ -72,7 +76,13 @@ describe('catch-all path depth', () => {
});
it('still renders one and two segments', async () => {
await expect(PathPage(call(['iceland']))).resolves.toBeTruthy();
await expect(PathPage(call(['travel', 'iceland']))).resolves.toBeTruthy();
for (const path of [['iceland'], ['travel', 'iceland']]) {
// The page decides, then hands the rendering to the content inside its
// Suspense boundary; run that too.
const shell = (await PathPage(call(path))) as ReactElement<{ children: ReactElement }>;
const content = shell.props.children as ReactElement<object>;
const render = content.type as (props: object) => Promise<unknown>;
await expect(render(content.props)).resolves.toBeTruthy();
}
});
});

View File

@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* A `?photo=` that names no photo in the album opened nothing and stayed in
* the address bar, to be copied and shared on (P-16). It is removed now; a
* valid deep link still opens its photo.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
beforeEach(() => {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('?photo= on load', () => {
it('opens the photo a valid token names', () => {
window.history.replaceState(null, '', '/wedding?photo=a2');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(window.location.search).toBe('?photo=a2');
});
it('drops a token that names no photo, keeping other parameters', () => {
window.history.replaceState(null, '', '/wedding?preview=true&photo=bogus');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.queryByRole('dialog')).toBeNull();
expect(window.location.pathname + window.location.search).toBe('/wedding?preview=true');
});
it('drops a position-style ?photo=5, which was never a format', () => {
window.history.replaceState(null, '', '/wedding?photo=5');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.queryByRole('dialog')).toBeNull();
expect(window.location.search).toBe('');
});
it('still honours a legacy #photo-N hash next to a bad token', () => {
window.history.replaceState(null, '', '/wedding?photo=bogus#photo-3');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(new URLSearchParams(window.location.search).get('photo')).toBe('a3');
});
});

View File

@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* Kunsthalle's wall label was the EXIF overlay with a CSS-counter number in
* front of it, so a photo without camera or lens got no label and no number.
* Each photo is now a <figure> with a figcaption that always carries its
* number; globals.css hides it outside the presets that show it.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({
children,
as: Wrapper = 'div',
}: {
children: React.ReactNode;
as?: 'div' | 'figure';
}) => <Wrapper>{children}</Wrapper>,
}));
const photo = (id: string, extra: Partial<PhotoItem> = {}): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
...extra,
});
const ASSETS = [
photo('a1', { camera: 'Leica M11', lens: 'Summicron 35', focalLength: '35mm' }),
photo('a2'),
photo('a3', { caption: 'Cloister at dusk' }),
photo('a4', { focalLength: '50mm' }),
];
let store: Map<string, string>;
beforeEach(() => {
store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
window.history.replaceState(null, '', '/kloster');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const labels = (container: HTMLElement) =>
[...container.querySelectorAll('figure > figcaption.photo-grid__label')] as HTMLElement[];
describe('photo wall label', () => {
it('numbers every photo, with or without EXIF', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const numbers = labels(container).map(
(l) => l.querySelector('.photo-grid__label-no')?.textContent,
);
expect(numbers).toEqual(['01', '02', '03', '04']);
});
it('sits next to the tile, not inside its clipped box', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
for (const label of labels(container)) {
expect(label.closest('.photo-grid__item')).toBeNull();
expect(label.previousElementSibling?.classList.contains('photo-grid__item')).toBe(true);
}
});
it('carries the caption only where one is published, and names the figure with it', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const titles = labels(container).map(
(l) => l.querySelector('.photo-grid__label-title')?.textContent ?? null,
);
expect(titles).toEqual([null, null, 'Cloister at dusk', null]);
// The same text the photo already publishes as its alt text.
expect(container.querySelector('img[alt="Cloister at dusk"]')).not.toBeNull();
// What assistive tech reads off the label (the figure's name, by HTML-AAM)
// is the caption alone.
const spoken = (l: HTMLElement) =>
[...l.children]
.filter((c) => c.getAttribute('aria-hidden') !== 'true')
.map((c) => c.textContent)
.join('');
expect(labels(container).map(spoken)).toEqual(['', '', 'Cloister at dusk', '']);
});
it('prints the EXIF line only for a camera or lens, as the tile does', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const exif = labels(container).map(
(l) => l.querySelector('.photo-grid__label-exif')?.textContent ?? null,
);
expect(exif).toEqual(['Leica M11 · Summicron 35 · 35mm', null, null, null]);
});
it('keeps the number and EXIF, which repeat the tile and lightbox, out of the figure name', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const [first] = labels(container);
expect(first.querySelector('.photo-grid__label-no')?.getAttribute('aria-hidden')).toBe('true');
expect(first.querySelector('.photo-grid__label-exif')?.getAttribute('aria-hidden')).toBe(
'true',
);
});
it('keeps a photo’s album number when the favourites filter hides the others', async () => {
store.set('folio_fav_kloster', JSON.stringify(['a3']));
const { container } = render(<PhotoGrid assets={ASSETS} proofing albumName="Kloster" />);
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
const numbers = labels(container).map(
(l) => l.querySelector('.photo-grid__label-no')?.textContent,
);
expect(numbers).toEqual(['03']);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest';
import type { ImmichAsset } from '@/lib/immich';
/**
* #831: a photo edited in Immich (crop, rotate) reaches the grid, the
* lightbox and the zoom as edited — an image URL with the edit marker, the
* edited proportions, and the edited full-size rendition behind the zoom.
*/
vi.mock('@/lib/config', () => ({
getConfig: () => ({ authSecret: 'test-auth-secret-32-chars-long-min' }),
getConfigOrNull: () => null,
}));
const { toPhotoItems } = await import('../photoItems');
const exif = (w: number, h: number, orientation: string | null = null) =>
({ exifImageWidth: w, exifImageHeight: h, orientation }) as ImmichAsset['exifInfo'];
const asset = (over: Partial<ImmichAsset>): ImmichAsset => ({
id: '11111111-1111-1111-1111-111111111111',
type: 'IMAGE',
originalFileName: 'scan.jpg',
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt: '2020-07-24T11:13:27.000Z',
isTrashed: false,
...over,
});
const ALBUM = '22222222-2222-2222-2222-222222222222';
describe('toPhotoItems for photos edited in Immich (#831)', () => {
// Measured on Immich 3.2: a scan stored 1076×723, rotated 270° in Immich.
const rotated = asset({
isEdited: true,
updatedAt: '2026-09-04T19:22:09.220Z',
width: 723,
height: 1076,
exifInfo: exif(1076, 723),
});
it('marks the edited photo’s URLs and gives it the edited proportions', () => {
const [item] = toPhotoItems([rotated], false, false);
expect(item.thumbUrl).toMatch(/[?&]e=[0-9a-z]+(&|$)/);
expect(item.previewUrl).toBe(item.thumbUrl);
expect(item.aspectRatio).toBeCloseTo(723 / 1076);
});
it('leaves a photo that is not edited exactly as it was', () => {
const [item] = toPhotoItems(
[asset({ width: 1076, height: 723, exifInfo: exif(1076, 723) })],
false,
false,
);
expect(item.thumbUrl).toMatch(/\?size=preview$/);
expect(item.aspectRatio).toBeCloseTo(1076 / 723);
});
it('offers zoom at the edited size', () => {
const [item] = toPhotoItems([rotated], false, false, undefined, ALBUM);
expect(item.zoomUrl).toMatch(/^\/api\/zoom\//);
expect([item.zoomWidth, item.zoomHeight]).toEqual([723, 1076]);
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* Two gaps in a subpage's `<head>`:
*
* - A `hidden: true` subpage is reachable by link only — off the nav, the
* home page and the sitemap — yet it and its albums inherited the layout's
* `index, follow`, so a crawler that found the link indexed it anyway.
* - A subpage with more than one album had no description of its own and fell
* back to the site-wide one; an album page describes itself by its photo
* count.
*/
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('en') };
});
vi.mock('@/lib/ogImage', () => ({ ogImageUrl: () => '/api/og?sig=x' }));
vi.mock('../contentPage', () => ({
contentPageAccess: vi.fn(async () => null),
contentPageMetadata: vi.fn(async () => null),
renderContentPage: vi.fn(async () => null),
}));
vi.mock('@/lib/auth', () => ({
isProtected: () => false,
isAuthenticated: () => false,
withoutLockedAlbums: <T>(albums: T[]) => albums,
}));
const config = vi.hoisted(() => ({
subpages: [] as { slug: string; hidden?: boolean }[],
seo: { noFollow: false },
}));
vi.mock('@/lib/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/config')>();
return { ...actual, getConfig: () => config };
});
const album = (id: string, name: string) => ({
id,
albumName: name,
slug: name.toLowerCase(),
assets: [{ id: `${id}-a`, type: 'IMAGE' }],
albumThumbnailAssetId: null,
});
const ICELAND = album('album-1', 'Iceland');
const NORWAY = album('album-2', 'Norway');
const subpages: Record<string, { subtitle?: string; albums: ReturnType<typeof album>[] }> = {
travel: { albums: [ICELAND, NORWAY] },
trips: { subtitle: 'Where the road went', albums: [ICELAND, NORWAY] },
private: { albums: [ICELAND, NORWAY] },
handover: { albums: [ICELAND] },
};
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn(),
getSubpageAlbums: vi.fn(),
getAlbumBySlug: vi.fn(),
}));
vi.mock('@/lib/immich', () => ({ immich }));
import { generateMetadata } from '../page';
const meta = (path: string[]) =>
generateMetadata({ params: Promise.resolve({ path }), searchParams: Promise.resolve({}) });
beforeEach(() => {
config.subpages = [
{ slug: 'travel' },
{ slug: 'trips' },
{ slug: 'private', hidden: true },
{ slug: 'handover', hidden: true },
];
config.seo.noFollow = false;
immich.isSubpageSlug.mockImplementation((slug: string) => slug in subpages);
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
const sp = subpages[slug];
return sp ? { subpage: { slug, name: slug, subtitle: sp.subtitle }, albums: sp.albums } : null;
});
immich.getAlbumBySlug.mockImplementation(
async (slug: string) => [ICELAND, NORWAY].find((a) => a.slug === slug) ?? null,
);
});
describe('hidden subpages stay out of search results', () => {
it.each([
['a hidden multi-album subpage', ['private']],
['a hidden single-album subpage', ['handover']],
['an album below a hidden subpage', ['private', 'iceland']],
])('marks %s noindex', async (_, path) => {
const m = await meta(path);
expect(m.robots).toEqual({ index: false, follow: true });
});
it('keeps the site-wide nofollow on a hidden subpage', async () => {
config.seo.noFollow = true;
expect((await meta(['private'])).robots).toEqual({ index: false, follow: false });
});
it('leaves a visible subpage, its albums and standalone albums to the layout', async () => {
expect((await meta(['travel'])).robots).toBeUndefined();
expect((await meta(['travel', 'iceland'])).robots).toBeUndefined();
expect((await meta(['iceland'])).robots).toBeUndefined();
});
});
describe('multi-album subpage description', () => {
it('describes the subpage by its album count when it has no subtitle', async () => {
const m = await meta(['travel']);
expect(m.description).toBe('travel — 2 albums');
expect(m.openGraph?.description).toBe('travel — 2 albums');
});
it('prefers the subtitle when there is one', async () => {
const m = await meta(['trips']);
expect(m.description).toBe('Where the road went');
});
});