release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { canSelectMore, matchesQuery, toggleSelection } from '../admin/assetSelection';
describe('toggleSelection (#602)', () => {
it('appends in click order and removes on a second toggle', () => {
let s = toggleSelection([], 'a');
s = toggleSelection(s, 'b');
s = toggleSelection(s, 'c');
expect(s).toEqual(['a', 'b', 'c']);
expect(toggleSelection(s, 'b')).toEqual(['a', 'c']);
});
it('refuses assets the caller already uses', () => {
expect(toggleSelection(['a'], 'x', { disabled: new Set(['x']) })).toEqual(['a']);
});
it('stops at max but still lets you deselect', () => {
const full = ['a', 'b'];
expect(toggleSelection(full, 'c', { max: 2 })).toEqual(['a', 'b']);
expect(toggleSelection(full, 'a', { max: 2 })).toEqual(['b']);
});
it('replaces the pick when max is 1', () => {
expect(toggleSelection(['a'], 'b', { max: 1 })).toEqual(['b']);
});
it('does not mutate its input', () => {
const s = ['a'];
toggleSelection(s, 'b');
expect(s).toEqual(['a']);
});
});
describe('canSelectMore', () => {
it('is unlimited without max, bounded with it, and always open for a single slot', () => {
expect(canSelectMore(['a', 'b', 'c'])).toBe(true);
expect(canSelectMore(['a'], 2)).toBe(true);
expect(canSelectMore(['a', 'b'], 2)).toBe(false);
expect(canSelectMore(['a'], 1)).toBe(true);
});
});
describe('matchesQuery', () => {
const asset = { originalFileName: 'IMG_4021.HEIC' };
it('matches a case-insensitive filename fragment', () => {
expect(matchesQuery(asset, 'img_40')).toBe(true);
expect(matchesQuery(asset, ' 4021 ')).toBe(true);
expect(matchesQuery(asset, 'DSC')).toBe(false);
});
it('matches everything for an empty query', () => {
expect(matchesQuery(asset, ' ')).toBe(true);
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi } from 'vitest';
/**
* Content page passwords (#722): their own cookie, `lb_auth_page_<slug>`,
* through the same authenticate()/isAuthenticated() path as every other gate,
* and a token that no other gate of the same slug and password accepts.
*/
vi.mock('@/lib/config', () => ({
getConfig: () => ({
authSecret: 'test-auth-secret-32-chars-long-min',
standaloneAlbums: [],
albumPasswords: {},
subpages: [{ name: 'Pricing', slug: 'pricing', albumIds: [], password: 'shared' }],
}),
}));
vi.mock('@/lib/admin/pages-service', () => ({
readPageSync: (slug: string) =>
slug === 'pricing' || slug === 'open'
? {
slug,
rawMarkdown: '',
parsed: {
frontmatter: slug === 'pricing' ? { password: 'shared' } : {},
blocks: [],
referencedAssetIds: [],
},
}
: null,
}));
vi.spyOn(console, 'warn').mockImplementation(() => {});
import { authenticate, isAuthenticated, isProtected } from '@/lib/auth';
function cookieFrom(setCookie: string): [string, string] {
const [pair] = setCookie.split(';');
const eq = pair.indexOf('=');
return [pair.slice(0, eq), pair.slice(eq + 1)];
}
describe('content page passwords', () => {
it('knows which pages are protected', () => {
expect(isProtected('pricing', 'page')).toBe(true);
expect(isProtected('open', 'page')).toBe(false);
expect(isProtected('missing', 'page')).toBe(false);
});
it('sets lb_auth_page_<slug> and accepts it back', async () => {
const setCookie = await authenticate('pricing', 'shared', 'page');
expect(setCookie).not.toBeNull();
const [name, value] = cookieFrom(setCookie!);
expect(name).toBe('lb_auth_page_pricing');
expect(isAuthenticated('pricing', (n) => (n === name ? value : undefined), 'page')).toBe(true);
});
it('refuses a wrong password', async () => {
expect(await authenticate('pricing', 'nope', 'page')).toBeNull();
});
it('does not accept a subpage token of the same slug and password', async () => {
const [, subpageValue] = cookieFrom((await authenticate('pricing', 'shared', 'subpage'))!);
expect(
isAuthenticated(
'pricing',
(n) => (n === 'lb_auth_page_pricing' ? subpageValue : undefined),
'page',
),
).toBe(false);
});
});

View File

@@ -25,6 +25,13 @@ vi.mock('@/lib/config', () => ({
albumIds: ['00000000-0000-0000-0000-000000000002'],
// no password
},
{
// Taken offline with `enabled: false`, and asks for nothing.
name: 'Offline',
slug: 'offline',
albumIds: ['00000000-0000-0000-0000-000000000006'],
enabled: false,
},
{
name: 'Hashed',
slug: 'hashed',
@@ -34,6 +41,13 @@ vi.mock('@/lib/config', () => ({
password:
'scrypt:0123456789abcdef0123456789abcdef:4850eef18a09612ef11fdcd943bf64ce98d0eca2b0f677f9c0c51d1f648b8a101cf7ea36b6f94cfc05236b2fda6fffabd0929dc7e14c07626522fba68bf26de5',
},
{
// A leftover bcrypt hash: refused outright, whatever the attempt.
name: 'Legacy',
slug: 'legacy',
albumIds: ['00000000-0000-0000-0000-000000000007'],
password: '$2b$10$abcdefghijklmnopqrstuuPjJ7x1b0c9vX8rZ0Yx0mZ1n2o3p4q5r',
},
],
}),
}));
@@ -129,6 +143,41 @@ describe('password hashing does not stall the process', () => {
});
});
/**
* A key with an scrypt hash took ~50ms to answer, every other key ~2ms — the
* early return skipped hashing. Identical reply bodies did not help: the clock
* alone told a prober which pages, albums and journal entries are locked,
* drafts included. Every attempt now runs exactly one scrypt. Counting the
* calls keeps the test independent of how fast the machine is.
*/
describe('authenticate — one scrypt per attempt, whatever the key', () => {
const attempts: [string, () => Promise<string | null>][] = [
['scrypt hash, right password', () => authenticate('hashed', 'scrypted-pass')],
['scrypt hash, wrong password', () => authenticate('hashed', 'wrong')],
['plaintext, right password', () => authenticate('private', 'secret123')],
['plaintext, wrong password', () => authenticate('private', 'wrong')],
['key without a password', () => authenticate('public', 'anything')],
['unknown key', () => authenticate('no-such-page', 'anything')],
['unknown journal entry', () => authenticate('no-such-entry', 'x', 'journal')],
['unknown album', () => authenticate('00000000-0000-0000-0000-00000000dead', 'x', 'album')],
['legacy bcrypt hash', () => authenticate('legacy', 'anything')],
];
it.each(attempts)('%s', async (_label, attempt) => {
const scrypt = vi.spyOn(crypto, 'scrypt');
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
await attempt();
expect(scrypt).toHaveBeenCalledTimes(1);
} finally {
scrypt.mockRestore();
error.mockRestore();
warn.mockRestore();
}
});
});
describe('isAuthenticated', () => {
it('returns true when a valid cookie is present', async () => {
// First authenticate to get the expected token
@@ -249,6 +298,11 @@ describe('isAlbumReachable', () => {
expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true);
});
// `enabled: false` takes the subpage offline; its albums have no route left.
it('refuses an album whose only subpage is offline', () => {
expect(isAlbumReachable('00000000-0000-0000-0000-000000000006', none)).toBe(false);
});
it('still refuses an album that carries its own password', async () => {
const subpageOnly = await cookieFor('private', 'secret123');
expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false);

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// Mock env.ts so importing config.ts doesn't trigger Zod validation
vi.mock('@/lib/env', () => ({
@@ -17,47 +20,109 @@ vi.mock('@/lib/secret', () => ({
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
}));
// Replace the filesystem-backed YAML layer. The real loadYaml() already
// revalidates by mtime, so "the file changed on disk" is modelled by the
// mock returning different content.
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
// The real YAML layer, spied on: how often it is asked is how often the
// config is derived.
vi.mock('@/lib/config/parser', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/config/parser')>();
return { ...actual, loadYaml: vi.fn(actual.loadYaml) };
});
import { getConfig, invalidateConfigCache } from '@/lib/config';
import { loadYaml } from '@/lib/config/parser';
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
function mockYamlFiles(settings: Record<string, unknown>) {
vi.mocked(loadYaml).mockImplementation((filename: string) => {
if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] };
if (filename === 'settings.yaml') return settings;
return null;
});
let dir: string;
let mtime = Date.now() / 1000;
/** Write a content file with a distinct mtime, the way a save from another worker lands. */
function write(name: string, body: string) {
const file = path.join(dir, 'content', name);
fs.writeFileSync(file, body);
mtime += 10;
fs.utimesSync(file, mtime, mtime);
}
describe('getConfig() in production', () => {
beforeEach(() => {
vi.stubEnv('NODE_ENV', 'production');
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-config-cache-'));
fs.mkdirSync(path.join(dir, 'content'));
vi.spyOn(process, 'cwd').mockReturnValue(dir);
write('gallery.yaml', `albums:\n - "${ALBUM_ID}"\n`);
write('settings.yaml', 'title: Old Title\n');
invalidateConfigCache();
vi.mocked(loadYaml).mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
fs.rmSync(dir, { recursive: true, force: true });
});
it('picks up settings.yaml changes without invalidateConfigCache()', () => {
// An admin save may run in a different worker/process than page
// rendering, so this worker never sees invalidateConfigCache().
// getConfig() must delegate freshness to the mtime-checked YAML layer
// instead of short-circuiting on a stale in-memory copy.
mockYamlFiles({ title: 'Old Title' });
// getConfig() must notice the file changed instead of short-circuiting
// on a stale in-memory copy.
expect(getConfig().siteTitle).toBe('Old Title');
mockYamlFiles({ title: 'New Title' });
write('settings.yaml', 'title: New Title\n');
expect(getConfig().siteTitle).toBe('New Title');
});
it('picks up gallery.yaml changes without invalidateConfigCache()', () => {
expect(getConfig().albums).toEqual([ALBUM_ID]);
const other = '22222222-2222-2222-2222-222222222222';
write('gallery.yaml', `albums:\n - "${ALBUM_ID}"\n - "${other}"\n`);
expect(getConfig().albums).toEqual([ALBUM_ID, other]);
});
it('notices about.md appearing', () => {
expect(getConfig().aboutEnabled).toBe(false);
write('about.md', '---\nname: Someone\n---\n');
expect(getConfig().aboutEnabled).toBe(true);
});
it('notices install.json credentials appearing', () => {
// env supplies the URL; the file is still watched.
write('install.json', JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'file-key' }));
getConfig();
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(2);
write('install.json', JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'other' }));
getConfig();
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(4);
});
// Every image request and every page render call getConfig() many times
// over. Deriving the whole config each time cost ~150µs a call; the files
// did not change in between.
it('derives the config once while its files are unchanged', () => {
for (let i = 0; i < 100; i++) getConfig();
// gallery.yaml and settings.yaml, once — not 200 reads.
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(2);
});
it('derives again after invalidateConfigCache()', () => {
getConfig();
invalidateConfigCache();
getConfig();
expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(4);
});
it('hands every caller its own copy', () => {
const first = getConfig();
first.albums.push('mutated');
first.siteTitle = 'mutated';
const second = getConfig();
expect(second.albums).toEqual([ALBUM_ID]);
expect(second.siteTitle).toBe('Old Title');
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// No SITE_TITLE, so the title has to come from the locale.
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: '',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
RATE_LIMIT_RPM: 120,
},
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
}));
vi.mock('@/lib/secret', () => ({
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
}));
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
import { getConfig, invalidateConfigCache } from '@/lib/config';
import { loadYaml } from '@/lib/config/parser';
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
function mockYamlFiles(settings: Record<string, unknown>) {
vi.mocked(loadYaml).mockImplementation((filename: string) => {
if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] };
if (filename === 'settings.yaml') return settings;
return null;
});
}
// With neither settings.yaml `title` nor SITE_TITLE set, the site used to be
// called "Gallery" in every language (#696).
describe('default site title', () => {
beforeEach(() => invalidateConfigCache());
it('uses the configured locale', () => {
mockYamlFiles({ lang: 'de' });
expect(getConfig().siteTitle).toBe('Galerie');
expect(getConfig().seo.title).toBe('Galerie');
});
it('falls back to English for an unsupported locale', () => {
mockYamlFiles({ lang: 'ja' });
expect(getConfig().siteTitle).toBe('Gallery');
});
it('keeps an explicit title', () => {
mockYamlFiles({ lang: 'de', title: 'Mein Folio' });
expect(getConfig().siteTitle).toBe('Mein Folio');
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
/**
* configInputs() stats a list of paths it builds at runtime. Turbopack cannot
* resolve a path that arrives through a variable, so it traces the whole
* project into `.next/standalone` — app/, docs/, e2e/, the real content files —
* and the Docker image copies all of it (#753 did this, ~70 MB). The ignore
* comment is the documented opt-out; the stat only reads an mtime, and the
* files themselves are loaded (and traced) by loadYaml() and readInstallFile().
*
* The build prints a warning when this regresses, but nothing fails, so the
* check lives here.
*/
const source = fs.readFileSync(path.join(__dirname, '..', 'config', 'index.ts'), 'utf8');
/** The body of configInputs(), up to the next top-level declaration. */
const body = source.match(/function configInputs\(\)[\s\S]*?\n}\n/)?.[0] ?? '';
describe('config memo key and output file tracing', () => {
it('finds configInputs()', () => {
expect(body).toContain('statSync');
});
it('marks every filesystem call in configInputs() as ignored by Turbopack', () => {
const calls = body.match(/fs\.\w+\([^)]*/g) ?? [];
expect(calls.length).toBeGreaterThan(0);
const unmarked = calls.filter(
(call) => !/^fs\.\w+\(\s*\/\*\s*turbopackIgnore: true\s*\*\//.test(call),
);
expect(unmarked).toEqual([]);
});
});

View File

@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'fs/promises';
import {
ABSENT_VERSION,
VersionConflictError,
assertVersion,
baseVersionFrom,
fileVersion,
serializeContentWrite,
versionOf,
} from '../admin/contentVersion';
/**
* Concurrent-edit detection (#601): a save carrying the version its editor
* loaded is refused when the file changed since; a save without one is
* written as before.
*
* The services resolve content/ from process.cwd() at import time, so each
* test points cwd at a fresh temp directory and re-imports them.
*/
let root: string;
let contentDir: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-version-'));
contentDir = path.join(root, 'content');
await mkdir(path.join(contentDir, 'journal'), { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
/** By name: the services are re-imported per test, so their error class is a different copy. */
const conflict = expect.objectContaining({ name: 'VersionConflictError' });
const put = (ifMatch?: string) =>
new Request('http://localhost/x', {
method: 'PUT',
headers: ifMatch === undefined ? {} : { 'If-Match': ifMatch },
});
describe('contentVersion helpers', () => {
it('versions content by its bytes, and a missing file as absent', async () => {
expect(versionOf('a')).toBe(versionOf(Buffer.from('a')));
expect(versionOf('a')).not.toBe(versionOf('b'));
expect(versionOf(null)).toBe(ABSENT_VERSION);
await expect(fileVersion(path.join(root, 'nope'))).resolves.toBe(ABSENT_VERSION);
await writeFile(path.join(root, 'f'), 'a');
await expect(fileVersion(path.join(root, 'f'))).resolves.toBe(versionOf('a'));
});
it('reads If-Match in bare, quoted and weak form; * and absent mean no check', () => {
expect(baseVersionFrom(put('abc'))).toBe('abc');
expect(baseVersionFrom(put('"abc"'))).toBe('abc');
expect(baseVersionFrom(put('W/"abc"'))).toBe('abc');
expect(baseVersionFrom(put('*'))).toBeUndefined();
expect(baseVersionFrom(put())).toBeUndefined();
});
it('assertVersion passes on a match or no base, and throws with the current version', async () => {
const file = path.join(root, 'f');
await writeFile(file, 'now');
await expect(assertVersion(file, versionOf('now'))).resolves.toBeUndefined();
await expect(assertVersion(file, undefined)).resolves.toBeUndefined();
const err = await assertVersion(file, versionOf('before')).catch((e) => e);
expect(err).toBeInstanceOf(VersionConflictError);
expect(err.currentVersion).toBe(versionOf('now'));
});
it('runs queued writes one at a time, and a failure does not stall the queue', async () => {
const order: string[] = [];
const slow = serializeContentWrite(async () => {
order.push('a:start');
await new Promise((r) => setTimeout(r, 20));
order.push('a:end');
});
const failing = serializeContentWrite(async () => {
order.push('b');
throw new Error('boom');
});
const after = serializeContentWrite(async () => order.push('c'));
await Promise.allSettled([slow, failing, after]);
await expect(failing).rejects.toThrow('boom');
expect(order).toEqual(['a:start', 'a:end', 'b', 'c']);
});
});
describe('yaml-service', () => {
it('writes settings.yaml when the base version matches, and refuses when it does not', async () => {
const service = await import('../admin/yaml-service');
const settingsPath = path.join(contentDir, 'settings.yaml');
await writeFile(settingsPath, 'title: One\n');
const loaded = await service.readSettingsYamlVersioned();
expect(loaded.data).toEqual({ title: 'One' });
// Another tab saves first.
const second = await service.writeSettingsYaml({ title: 'Two' }, loaded.version);
expect(second).toBe(versionOf(await readFile(settingsPath)));
// This tab still holds the first version.
await expect(service.writeSettingsYaml({ title: 'Three' }, loaded.version)).rejects.toEqual(
expect.objectContaining({ name: 'VersionConflictError', currentVersion: second }),
);
expect(await service.readSettingsYaml()).toEqual({ title: 'Two' });
});
it('still writes without a base version (older clients)', async () => {
const service = await import('../admin/yaml-service');
await writeFile(path.join(contentDir, 'gallery.yaml'), 'hero: []\n');
await service.writeGalleryYaml({ hero: ['x'] } as never);
expect(await service.readGalleryYaml()).toEqual({ hero: ['x'] });
});
it('treats a file created since an absent load as a conflict', async () => {
const service = await import('../admin/yaml-service');
const loaded = await service.readGalleryYamlVersioned();
expect(loaded.version).toBe(ABSENT_VERSION);
await writeFile(path.join(contentDir, 'gallery.yaml'), 'hero: []\n');
await expect(service.writeGalleryYaml({ hero: [] } as never, loaded.version)).rejects.toEqual(
conflict,
);
});
});
const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
describe('journal-service saveJournalEntry', () => {
it('saves on a matching version and refuses a stale one', async () => {
const service = await import('../admin/journal-service');
const file = path.join(contentDir, 'journal', 'trip.md');
await writeFile(file, md('One'));
const base = versionOf(md('One'));
await expect(service.saveJournalEntry('trip', md('Two'), { baseVersion: base })).resolves.toBe(
versionOf(md('Two')),
);
await expect(
service.saveJournalEntry('trip', md('Three'), { baseVersion: base }),
).rejects.toEqual(conflict);
await expect(readFile(file, 'utf8')).resolves.toBe(md('Two'));
// No base: written as before.
await service.saveJournalEntry('trip', md('Four'));
await expect(readFile(file, 'utf8')).resolves.toBe(md('Four'));
});
it('checks a rename against the source entry and leaves both files alone on conflict', async () => {
const service = await import('../admin/journal-service');
const dir = path.join(contentDir, 'journal');
await writeFile(path.join(dir, 'old.md'), md('Changed elsewhere'));
const err = await service
.saveJournalEntry('new', md('Mine'), {
baseVersion: versionOf(md('Loaded')),
fromSlug: 'old',
})
.catch((e) => e);
expect(err).toEqual(conflict);
expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['old.md']);
await service.saveJournalEntry('new', md('Mine'), {
baseVersion: versionOf(md('Changed elsewhere')),
fromSlug: 'old',
});
expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['new.md']);
});
});
describe('pages-service savePage', () => {
it('saves on a matching version, refuses a stale one, and renames', async () => {
const service = await import('../admin/pages-service');
const dir = path.join(contentDir, 'pages');
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'faq.md'), md('FAQ'));
await expect(
service.savePage('faq', md('Stale'), { baseVersion: versionOf(md('Other')) }),
).rejects.toEqual(conflict);
await expect(readFile(path.join(dir, 'faq.md'), 'utf8')).resolves.toBe(md('FAQ'));
await service.savePage('help', md('Help'), {
baseVersion: versionOf(md('FAQ')),
fromSlug: 'faq',
});
expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['help.md']);
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import fs from 'fs';
import { writeFile as namedWriteFile, mkdtemp, readFile, rm } from 'fs/promises';
import os from 'os';
import path from 'path';
import { guardedContentDir, takeContentWriteViolations } from '../../vitest.content-guard';
/**
* The unit suite used to write a real journal entry into the checkout's
* content/ — the live site's directory in a deployment — and leave a
* .deleted.bak behind on every run. vitest.content-guard.ts refuses such
* writes; these tests pin that it catches each way code writes a file, and
* that it leaves reads and temp directories alone.
*/
const repoContent = path.resolve(__dirname, '..', '..', 'content');
const target = path.join(repoContent, `.content-guard-test-${process.pid}`);
function expectBlocked(write: () => unknown) {
expect(write).toThrow(/real content\/ directory/);
expect(fs.existsSync(target)).toBe(false);
expect(takeContentWriteViolations()).toHaveLength(1);
}
async function expectRejected(write: () => Promise<unknown>) {
await expect(write()).rejects.toThrow(/real content\/ directory/);
expect(fs.existsSync(target)).toBe(false);
expect(takeContentWriteViolations()).toHaveLength(1);
}
describe('content write guard', () => {
let tmp: string;
beforeEach(async () => {
tmp = await mkdtemp(path.join(os.tmpdir(), 'folio-guard-'));
});
afterEach(async () => {
vi.restoreAllMocks();
takeContentWriteViolations();
await rm(tmp, { recursive: true, force: true });
});
it('protects the checkout content/ directory', () => {
expect(guardedContentDir).toBe(repoContent);
});
it('rejects fs.promises writes, the named fs/promises import included', async () => {
await expectRejected(() => fs.promises.writeFile(target, 'x'));
await expectRejected(() => namedWriteFile(target, 'x'));
await expectRejected(() => fs.promises.mkdir(target, { recursive: true }));
await expectRejected(() => fs.promises.open(target, 'a+'));
});
it('blocks sync, callback and stream writes', () => {
expectBlocked(() => fs.writeFileSync(target, 'x'));
expectBlocked(() => fs.writeFile(target, 'x', () => {}));
expectBlocked(() => fs.mkdirSync(target, { recursive: true }));
expectBlocked(() => fs.appendFileSync(target, 'x'));
expectBlocked(() => fs.createWriteStream(target));
});
it('blocks opening for writing and moving, copying or removing a file', () => {
const outside = path.join(tmp, 'file');
fs.writeFileSync(outside, 'x');
expectBlocked(() => fs.openSync(target, 'w'));
expectBlocked(() => fs.renameSync(outside, target));
expectBlocked(() => fs.copyFileSync(outside, target));
expectBlocked(() => fs.unlinkSync(target));
expectBlocked(() => fs.rmSync(target, { force: true }));
});
it('still blocks when a test points process.cwd() elsewhere', () => {
vi.spyOn(process, 'cwd').mockReturnValue(tmp);
expectBlocked(() => fs.writeFileSync(target, 'x'));
});
it('records a blocked write even when the caller swallows the error', async () => {
await fs.promises.writeFile(target, 'x').catch(() => {});
expect(takeContentWriteViolations()).toHaveLength(1);
});
it('allows reads from content/ and writes anywhere else', async () => {
const example = path.join(repoContent, 'gallery.yaml.example');
await expect(readFile(example, 'utf8')).resolves.toContain('albums');
fs.closeSync(fs.openSync(example, 'r'));
const elsewhere = path.join(tmp, 'content', 'journal', 'entry.md');
fs.mkdirSync(path.dirname(elsewhere), { recursive: true });
await namedWriteFile(elsewhere, 'ok');
await expect(readFile(elsewhere, 'utf8')).resolves.toBe('ok');
expect(takeContentWriteViolations()).toEqual([]);
});
});

View File

@@ -13,6 +13,8 @@ import {
checkContact,
checkLegal,
checkPrivacy,
checkContentPages,
checkSettingValues,
worstLevel,
} from '../admin/doctor';
@@ -444,3 +446,58 @@ describe('checkPrivacy', () => {
);
});
});
describe('checkContentPages (#722)', () => {
it('stays silent without pages or references', () => {
expect(checkContentPages({ menuRefs: [], pages: [], collisions: [] })).toBeNull();
});
it('is ok when every reference has a file and no slug is taken', () => {
const f = checkContentPages({
menuRefs: ['pricing'],
pages: ['pricing', 'faq'],
collisions: [],
});
expect(f?.level).toBe('ok');
expect(f?.id).toBe('content-pages');
});
it('warns about a menu reference without a file, naming it', () => {
const f = checkContentPages({
menuRefs: ['pricing', 'gone'],
pages: ['pricing'],
collisions: [],
});
expect(f?.level).toBe('warn');
expect(f?.detail).toContain('"gone"');
});
it('reports a slug collision as an error', () => {
const f = checkContentPages({
menuRefs: [],
pages: ['travel'],
collisions: [{ slug: 'travel', reason: '/travel is already a subpage.' }],
});
expect(f?.level).toBe('error');
expect(f?.detail).toContain('/travel is already a subpage.');
});
});
describe('checkSettingValues (QA A-14)', () => {
it('is ok when every checked value is used as written', () => {
expect(checkSettingValues([]).level).toBe('ok');
});
it('warns and names the fields the site replaces', () => {
const f = checkSettingValues([{ field: 'theme.accent' }, { field: 'grid.columns' }]);
expect(f.level).toBe('warn');
expect(f.title).toBe('2 settings are ignored');
expect(f.detail).toContain('accent colour, grid columns');
});
it('points at the settings section of the first field', () => {
expect(checkSettingValues([{ field: 'url' }]).settingsSection).toBe('seo');
expect(checkSettingValues([{ field: 'grid.gap' }]).settingsSection).toBe('grid');
expect(checkSettingValues([{ field: 'theme.accent' }]).settingsSection).toBe('theme');
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { ENV_LOCKS, keepLockedValues, resolveEnvLocks } from '../admin/envLocks';
describe('resolveEnvLocks (#605)', () => {
it('locks nothing when no overriding variable is set', () => {
expect(resolveEnvLocks({})).toEqual({});
expect(resolveEnvLocks({ SITE_PASSWORD: ' ', CONTACT_NOTIFY_URL: undefined })).toEqual({});
});
it('maps each set variable to its settings path, by name only', () => {
const locks = resolveEnvLocks({
SITE_PASSWORD: 'hunter2',
CONTACT_NOTIFY_URL: 'https://ntfy.sh/secret-topic',
});
expect(locks).toEqual({
sitePassword: 'SITE_PASSWORD',
'contact.notifyUrl': 'CONTACT_NOTIFY_URL',
});
expect(JSON.stringify(locks)).not.toContain('hunter2');
expect(JSON.stringify(locks)).not.toContain('secret-topic');
});
it('does not lock title, subtitle or url: settings.yaml wins over those variables', () => {
const locks = resolveEnvLocks({ SITE_TITLE: 'T', SITE_SUBTITLE: 'S', SITE_URL: 'https://x' });
expect(locks).toEqual({});
expect(Object.keys(ENV_LOCKS)).not.toContain('title');
});
});
describe('keepLockedValues (#605)', () => {
const locks = { sitePassword: 'SITE_PASSWORD', 'contact.notifyUrl': 'CONTACT_NOTIFY_URL' };
it('puts the stored value back over an edited locked field', () => {
const out = keepLockedValues(
{ title: 'New', sitePassword: 'changed', contact: { enabled: true, notifyUrl: 'https://b' } },
{ sitePassword: 'scrypt:a:b', contact: { notifyUrl: 'https://a' } },
locks,
);
expect(out).toEqual({
title: 'New',
sitePassword: 'scrypt:a:b',
contact: { enabled: true, notifyUrl: 'https://a' },
});
});
it('drops a locked value the file never had', () => {
const out = keepLockedValues(
{ sitePassword: 'new', contact: { notifyUrl: 'https://b' } },
{},
locks,
);
expect(out).toEqual({ contact: {} });
});
it('leaves unlocked fields alone and does not mutate the input', () => {
const incoming = { sitePassword: 'new' };
expect(keepLockedValues(incoming, { sitePassword: 'old' }, {})).toEqual({
sitePassword: 'new',
});
keepLockedValues(incoming, { sitePassword: 'old' }, locks);
expect(incoming.sitePassword).toBe('new');
});
});

View File

@@ -62,7 +62,7 @@ describe('resolveEssayFile', () => {
});
it('locks a password-protected entry without a cookie', () => {
expect(resolveEssayFile('locked', anonymous)).toEqual({ status: 'locked', title: 'Locked' });
expect(resolveEssayFile('locked', anonymous)).toEqual({ status: 'locked' });
});
it('keeps the entry locked for the admin, like /journal/<slug> does', () => {

View File

@@ -219,3 +219,160 @@ describe('plural helpers', () => {
},
);
});
/**
* Count strings at the CLDR boundaries. French puts 0 in the singular ("0 photo"),
* the other five use the singular for exactly 1 — so a shared `n === 1` helper
* was wrong for French, and a hard-coded plural noun ("(1 items)") was wrong for
* everyone at 1. Italian "foto"/"album" and the Dutch/German participles are
* invariable, which the table spells out rather than skips.
*/
type Counted = (d: Dictionary, n: number) => string;
const COUNTED = {
photos: (d, n) => d.common.photos(n),
albums: (d, n) => d.common.albums(n),
collections: (d, n) => d.common.collections(n),
selected: (d, n) => d.proofing.selected(n),
mailSubject: (d, n) => d.proofing.mailSubject(n),
downloadsLeft: (d, n) => d.proofSession.downloadsLeft(n),
} satisfies Record<string, Counted>;
type CountedKey = keyof typeof COUNTED;
const PLURAL_TABLE: [string, Dictionary, Record<CountedKey, [string, string, string]>][] = [
[
'en',
en,
{
photos: ['0 photos', '1 photo', '2 photos'],
albums: ['0 albums', '1 album', '2 albums'],
collections: ['0 collections', '1 collection', '2 collections'],
selected: ['❤️ 0 Selected', '❤️ 1 Selected', '❤️ 2 Selected'],
mailSubject: [
'Photo Selection (0 items)',
'Photo Selection (1 item)',
'Photo Selection (2 items)',
],
downloadsLeft: ['0 downloads left', '1 download left', '2 downloads left'],
},
],
[
'de',
de,
{
photos: ['0 Fotos', '1 Foto', '2 Fotos'],
albums: ['0 Alben', '1 Album', '2 Alben'],
collections: ['0 Sammlungen', '1 Sammlung', '2 Sammlungen'],
selected: ['❤️ 0 ausgewählt', '❤️ 1 ausgewählt', '❤️ 2 ausgewählt'],
mailSubject: ['Fotoauswahl (0 Fotos)', 'Fotoauswahl (1 Foto)', 'Fotoauswahl (2 Fotos)'],
downloadsLeft: ['Noch 0 Downloads', 'Noch 1 Download', 'Noch 2 Downloads'],
},
],
[
'fr',
fr,
{
photos: ['0 photo', '1 photo', '2 photos'],
albums: ['0 album', '1 album', '2 albums'],
collections: ['0 collection', '1 collection', '2 collections'],
selected: ['❤️ 0 sélectionnée', '❤️ 1 sélectionnée', '❤️ 2 sélectionnées'],
mailSubject: [
'Sélection de photos (0)',
'Sélection de photos (1)',
'Sélection de photos (2)',
],
downloadsLeft: [
'0 téléchargement restant',
'1 téléchargement restant',
'2 téléchargements restants',
],
},
],
[
'es',
es,
{
photos: ['0 fotos', '1 foto', '2 fotos'],
albums: ['0 álbumes', '1 álbum', '2 álbumes'],
collections: ['0 colecciones', '1 colección', '2 colecciones'],
selected: ['❤️ 0 seleccionadas', '❤️ 1 seleccionada', '❤️ 2 seleccionadas'],
mailSubject: ['Selección de fotos (0)', 'Selección de fotos (1)', 'Selección de fotos (2)'],
downloadsLeft: ['0 descargas restantes', '1 descarga restante', '2 descargas restantes'],
},
],
[
'it',
itIT,
{
photos: ['0 foto', '1 foto', '2 foto'],
albums: ['0 album', '1 album', '2 album'],
collections: ['0 collezioni', '1 collezione', '2 collezioni'],
selected: ['❤️ 0 selezionate', '❤️ 1 selezionata', '❤️ 2 selezionate'],
mailSubject: ['Selezione di foto (0)', 'Selezione di foto (1)', 'Selezione di foto (2)'],
downloadsLeft: ['0 download rimasti', '1 download rimasto', '2 download rimasti'],
},
],
[
'nl',
nl,
{
photos: ['0 foto’s', '1 foto', '2 foto’s'],
albums: ['0 albums', '1 album', '2 albums'],
collections: ['0 collecties', '1 collectie', '2 collecties'],
selected: ['❤️ 0 geselecteerd', '❤️ 1 geselecteerd', '❤️ 2 geselecteerd'],
mailSubject: ['Fotoselectie (0)', 'Fotoselectie (1)', 'Fotoselectie (2)'],
downloadsLeft: ['Nog 0 downloads', 'Nog 1 download', 'Nog 2 downloads'],
},
],
];
describe('plural forms at 0, 1 and 2', () => {
it('cover every supported locale', () => {
expect(PLURAL_TABLE.map(([code]) => code).sort()).toEqual([...SUPPORTED_LOCALES].sort());
});
describe.each(PLURAL_TABLE)('%s', (_code, dict, expected) => {
it.each(Object.keys(COUNTED) as CountedKey[])('%s', (key) => {
expect([0, 1, 2].map((n) => COUNTED[key](dict, n))).toEqual(expected[key]);
});
});
it.each([
['en', en, ['0 days.', '1 day.', '2 days.']],
['de', de, ['0 Tagen gelöscht.', '1 Tag gelöscht.', '2 Tagen gelöscht.']],
['fr', fr, ['0 jour.', '1 jour.', '2 jours.']],
['es', es, ['0 días.', '1 día.', '2 días.']],
['it', itIT, ['0 giorni.', '1 giorno.', '2 giorni.']],
['nl', nl, ['0 dagen verwijderd.', '1 dag verwijderd.', '2 dagen verwijderd.']],
] as const)('contact retention days in %s', (_code, dict, endings) => {
const got = [0, 1, 2].map((n) => dict.contact.privacy(n));
got.forEach((text, i) => expect(text.endsWith(` ${endings[i]}`), text).toBe(true));
});
});
describe('German terminology', () => {
// The carousel said "Diashow", the lightbox's shortcut panel and live
// region "Diaschau" for the same feature.
it('calls the slideshow "Diashow" everywhere', () => {
const strings = [
de.home.pauseSlideshow,
de.home.playSlideshow,
de.lightbox.shortcutSlideshow,
de.lightbox.shortcutSlideshowRunning(5),
de.lightbox.slideshowStopped,
de.lightbox.slideshowRunning(5),
];
for (const s of strings) expect(s).toContain('Diashow');
});
it('leaves no "Diaschau" in any string', () => {
const walk = (value: unknown): string[] =>
typeof value === 'string'
? [value]
: typeof value === 'function'
? [String((value as (n: number) => string)(2))]
: value && typeof value === 'object'
? Object.values(value).flatMap(walk)
: [];
expect(walk(de).filter((s) => s.includes('Diaschau'))).toEqual([]);
});
});

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { getImgProps } from 'next/dist/shared/lib/get-img-props';
import { imageConfigDefault } from 'next/dist/shared/lib/image-config';
import immichLoader from '../immichLoader';
import { canonicalImageUrl, resolveImageSize } from '../imageSize';
/**
* The proxy answers every width inside an Immich tier with the same bytes, but
* each distinct URL is its own `immutable` cache entry. next/image used to
* write a different `?w=` into every srcset candidate and the lightbox asked
* for the bare URL, so a grid tile and the same photo in the lightbox — or a
* tile before and after a rotation — downloaded the identical preview twice.
*/
const PREVIEW = '/api/image/v2:tok?size=preview';
const THUMB = '/api/image/v2:tok?size=thumbnail';
const ALL_WIDTHS = [...imageConfigDefault.deviceSizes, ...imageConfigDefault.imageSizes];
function srcSetUrls(src: string, sizes: string): string[] {
const { props } = getImgProps(
{ src, alt: '', fill: true, sizes },
{
defaultLoader: immichLoader,
imgConf: { ...imageConfigDefault, loader: 'custom', loaderFile: './lib/immichLoader.ts' },
showAltText: false,
blurComplete: false,
},
);
return (props.srcSet ?? '').split(', ').map((candidate) => candidate.split(' ')[0]);
}
const tierOf = (url: string) => {
const params = new URLSearchParams(url.slice(url.indexOf('?') + 1));
return resolveImageSize(params.get('size'), params.get('w'));
};
describe('one image URL per asset and tier', () => {
it.each([
['PhotoGrid', '(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw'],
['SubpageGridView', '(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw'],
['album hero', '100vw'],
['home hero', '(max-width: 640px) 100vw, 50vw'],
['essay', '(max-width: 1100px) 100vw, 1100px'],
])('every %s srcset candidate is the URL the lightbox opens', (_name, sizes) => {
const urls = srcSetUrls(PREVIEW, sizes);
expect(urls.length).toBeGreaterThan(3);
expect(new Set(urls)).toEqual(new Set([canonicalImageUrl(PREVIEW)]));
});
it('collapses a thumbnail-ceiling image to one URL as well', () => {
expect(new Set(srcSetUrls(THUMB, '25vw'))).toEqual(new Set([canonicalImageUrl(THUMB)]));
});
it('asks the proxy for the same rendition the raw width did', () => {
for (const size of ['thumbnail', 'preview', 'original', null]) {
const src = size ? `/api/image/v2:tok?size=${size}` : '/api/image/v2:tok';
for (const w of [...ALL_WIDTHS, 1, 250, 251, 1440, 1441, 99999]) {
expect(tierOf(canonicalImageUrl(src, w))).toBe(resolveImageSize(size, String(w)));
}
}
});
it('keeps the cache buster and writes a width next/image accepts', () => {
expect(canonicalImageUrl('/api/image/v2:tok?size=preview&v=7', 640)).toBe(
'/api/image/v2:tok?size=preview&v=7&w=1440',
);
expect(canonicalImageUrl('https://cdn.example.com/api/image/v2:tok?size=thumbnail', 96)).toBe(
'https://cdn.example.com/api/image/v2:tok?size=thumbnail&w=250',
);
expect(immichLoader({ src: PREVIEW, width: 400 })).not.toBe(PREVIEW);
});
});

View File

@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* Public pages follow admin saves (A-2) and the page builder's album order
* (A-6).
*
* An admin save lands in another module instance (Next bundles each route
* separately), so its immich.invalidateAll() never reached the cache the
* pages read. The album list was cached with the allowlist and the
* gallery.yaml titles and descriptions already applied, and the public site
* kept serving the old ones for up to CACHE_TTL. These tests use the real
* getConfig() on a temp content directory: the only signal a save leaves is
* the new mtime of gallery.yaml.
*/
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://immich.test',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
STALE_MAX_AGE: 86_400,
IMMICH_TIMEOUT_MS: 15_000,
RATE_LIMIT_RPM: 120,
},
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
}));
vi.mock('@/lib/secret', () => ({
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
}));
import { invalidateConfigCache } from '@/lib/config';
import { cache } from '@/lib/cache';
import { immich } from '@/lib/immich';
const A = '11111111-1111-4111-8111-111111111111';
const B = '22222222-2222-4222-8222-222222222222';
const C = '33333333-3333-4333-8333-333333333333';
const NEW = '44444444-4444-4444-8444-444444444444';
let dir: string;
let mtime = Date.now() / 1000;
let immichAlbums: { id: string; albumName: string; description: string; assetCount: number }[];
const fetchMock = vi.fn();
/** Write a content file with a distinct mtime, the way a save from another instance lands. */
function write(name: string, body: string) {
const file = path.join(dir, 'content', name);
fs.writeFileSync(file, body);
mtime += 10;
fs.utimesSync(file, mtime, mtime);
}
const json = (body: unknown) => ({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => body,
});
const listRequests = () =>
fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/albums?shared=true')).length;
beforeEach(() => {
vi.stubEnv('NODE_ENV', 'production');
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-immich-follows-'));
fs.mkdirSync(path.join(dir, 'content'));
vi.spyOn(process, 'cwd').mockReturnValue(dir);
write('settings.yaml', 'title: Test\n');
invalidateConfigCache();
cache.clear();
immichAlbums = [
// Immich answers in its own order (last updated first, say).
{ id: C, albumName: 'Gamma', description: 'from Immich', assetCount: 3 },
{ id: A, albumName: 'Alpha', description: '', assetCount: 1 },
{ id: B, albumName: 'Beta', description: '', assetCount: 2 },
];
fetchMock.mockReset();
fetchMock.mockImplementation(async (url: string) => {
if (String(url).endsWith('/albums?shared=true')) return json(immichAlbums);
return { ok: false, status: 404, headers: { get: () => 'application/json' } };
});
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
fs.rmSync(dir, { recursive: true, force: true });
});
describe('public album data follows a gallery.yaml save (A-2)', () => {
it('shows a new title and description on the next call, without asking Immich again', async () => {
write('gallery.yaml', `albums:\n - "${A}"\n - "${B}"\n`);
const before = await immich.getAlbums();
expect(before.map((a) => a.albumName)).toEqual(['Alpha', 'Beta']);
expect(listRequests()).toBe(1);
// The admin saves in another module instance: no invalidation reaches us.
write(
'gallery.yaml',
`albums:\n - "${A}":\n title: "Alpha, renamed"\n description: "Now described"\n - "${B}"\n`,
);
const after = await immich.getAlbums();
const alpha = after.find((a) => a.id === A)!;
expect(alpha.albumName).toBe('Alpha, renamed');
expect(alpha.description).toBe('Now described');
expect(alpha.slug).toBe('alpha-renamed');
expect(listRequests()).toBe(1);
});
it('drops an unpublished album and adds a newly published one at once', async () => {
write('gallery.yaml', `albums:\n - "${A}"\n - "${B}"\n`);
expect((await immich.getAlbums()).map((a) => a.id)).toEqual([A, B]);
write('gallery.yaml', `albums:\n - "${B}"\n - "${C}"\n`);
expect((await immich.getAlbums()).map((a) => a.id).sort()).toEqual([B, C].sort());
expect(listRequests()).toBe(1);
});
it('refetches the list once for an album created in Immich after it was cached', async () => {
write('gallery.yaml', `albums:\n - "${A}"\n`);
await immich.getAlbums();
immichAlbums = [
...immichAlbums,
{ id: NEW, albumName: 'Fresh', description: '', assetCount: 1 },
];
write('gallery.yaml', `albums:\n - "${A}"\n - "${NEW}"\n`);
expect((await immich.getAlbums()).map((a) => a.id)).toContain(NEW);
expect(listRequests()).toBe(2);
// Cached again, and an allowlisted album Immich does not know (deleted)
// costs no request per call.
write(
'gallery.yaml',
`albums:\n - "${A}"\n - "${NEW}"\n - "${'5'.repeat(8)}-5555-4555-8555-555555555555"\n`,
);
await immich.getAlbums();
await immich.getAlbums();
await immich.getAlbums();
expect(listRequests()).toBe(3);
});
it('applies overrides to a single album from the cache as well', async () => {
fetchMock.mockImplementation(async (url: string) => {
if (String(url).includes('/search/metadata')) {
return json({ assets: { items: [], nextPage: null } });
}
if (String(url).endsWith(`/albums/${A}`)) {
return json({ id: A, albumName: 'Alpha', description: '', assetCount: 0, order: 'desc' });
}
return json(immichAlbums);
});
write('gallery.yaml', `albums:\n - "${A}"\n`);
expect((await immich.getAlbum(A))?.albumName).toBe('Alpha');
const albumRequests = () =>
fetchMock.mock.calls.filter(([url]) => String(url).endsWith(`/albums/${A}`)).length;
expect(albumRequests()).toBe(1);
write('gallery.yaml', `albums:\n - "${A}": Alpha Prime\n`);
const album = await immich.getAlbum(A);
expect(album?.albumName).toBe('Alpha Prime');
expect(album?.slug).toBe('alpha-prime');
expect(albumRequests()).toBe(1);
});
});
describe('subpage albums follow the configured order (A-6)', () => {
it('lists albums in gallery.yaml order, not Immich order', async () => {
write(
'gallery.yaml',
`subpages:\n - name: Trips\n albums:\n - "${B}"\n - "${A}"\n - "${C}"\n`,
);
const result = await immich.getSubpageAlbums('trips');
expect(result?.albums.map((a) => a.id)).toEqual([B, A, C]);
// Rearranged in the page builder: the next call follows.
write(
'gallery.yaml',
`subpages:\n - name: Trips\n albums:\n - "${C}"\n - "${B}"\n - "${A}"\n`,
);
const reordered = await immich.getSubpageAlbums('trips');
expect(reordered?.albums.map((a) => a.id)).toEqual([C, B, A]);
expect(listRequests()).toBe(1);
});
it('keeps section order across sections', async () => {
write(
'gallery.yaml',
[
'subpages:',
' - name: Japan',
' sections:',
' - title: South',
' albums:',
` - "${B}"`,
' - title: North',
' albums:',
` - "${C}"`,
` - "${A}"`,
'',
].join('\n'),
);
const result = await immich.getSubpageAlbums('japan');
expect(result?.albums.map((a) => a.id)).toEqual([B, C, A]);
});
});
describe('the cache is one per process, not one per module instance', () => {
it('is cleared for every instance by a clear in any of them', async () => {
vi.resetModules();
const other = await import('@/lib/cache');
expect(other.cache).not.toBe(cache);
cache.set('probe', 1, 60_000);
expect(other.cache.get('probe')).toBe(1);
expect(other.cache.size).toBe(cache.size);
other.cache.clear();
expect(cache.get('probe')).toBeNull();
});
});

View File

@@ -80,9 +80,11 @@ describe('ImmichClient — install state', () => {
* The setup config carries an empty allowlist, so the filtered list is [] no
* matter what Immich returned. Caching that made the gallery look empty until
* the server restarted, because the wizard's invalidateAll() runs in another
* bundle's module instance and never reached this cache.
* bundle's module instance and never reached this cache. The cache now holds
* Immich's list as sent and the allowlist is applied per call, so the album
* shows up from the cached list without a second request.
*/
it('does not cache the empty album list while setup is unfinished', async () => {
it('does not keep the empty album list of the setup config after setup', async () => {
const album = {
id: 'album-1',
albumName: 'Japan',
@@ -106,10 +108,9 @@ describe('ImmichClient — install state', () => {
state.needsGallery = false;
state.albums = ['album-1'];
mockFetch.mockResolvedValueOnce(respond());
const after = await immich.getAlbums();
expect(after.map((a) => a.id)).toEqual(['album-1']);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('makes no request at all when credentials are absent', async () => {

View File

@@ -16,7 +16,8 @@ vi.mock('../config', async () => {
const actual = await vi.importActual<typeof config>('../config');
return {
...actual,
getConfig: () => ({
// A spy, so the tests below can count how often the client reads it.
getConfig: vi.fn(() => ({
immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' },
authSecret: 'test-auth-secret-32-chars-long-min',
albums: ['album-1', 'album-2'],
@@ -34,7 +35,7 @@ vi.mock('../config', async () => {
cacheTtl: 60_000,
staleMaxAge: 86_400_000,
immichTimeoutMs: 15000,
}),
})),
};
});
@@ -567,6 +568,59 @@ describe('ImmichClient', () => {
});
});
/**
* getConfig() re-checks the content files and clones the whole config on
* every call, and the client used to call it once per property it read —
* four times per streamed image, twice per album on a list refresh.
*/
describe('reads the config once per operation', () => {
const configReads = () => vi.mocked(config.getConfig).mock.calls.length;
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
it('streamAsset(): once per image', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
body: new ReadableStream(),
headers: { get: () => 'image/jpeg' },
});
await immich.streamAsset('asset-1', 'preview');
expect(configReads()).toBe(1);
});
it('getAlbums(): twice on a refresh, however many albums there are', async () => {
const album = (id: string) => ({ id, albumName: id, assetCount: 1, assets: [] });
mockFetch.mockResolvedValueOnce(json([album('album-1'), album('album-2')]));
expect(await immich.getAlbums()).toHaveLength(2);
// One for the request, one for filtering, naming and caching the list.
expect(configReads()).toBe(2);
});
it('getAlbum(): twice on a cache hit', async () => {
mockFetch.mockImplementation(async (url: string) =>
json(
url.includes('/search/metadata')
? { assets: { items: [], nextPage: null } }
: { id: 'album-1', albumName: 'A', assetCount: 0, assets: [], order: 'desc' },
),
);
await immich.getAlbum('album-1');
vi.mocked(config.getConfig).mockClear();
await immich.getAlbum('album-1');
// The allowlist check, then the configured sort.
expect(configReads()).toBe(2);
});
});
describe('getAlbum()', () => {
it('returns null for unallowed album IDs', async () => {
const album = await immich.getAlbum('album-3');
@@ -821,6 +875,85 @@ describe('ImmichClient', () => {
});
});
});
/**
* Album blocks in journal entries, content pages and essay files are
* rendered from getAlbumAssetsRaw(), and so is the published-asset set. It
* used to go to Immich on every call — two requests per album per page view,
* and again on every set rebuild — with no cache and no coalescing.
*/
describe('getAlbumAssetsRaw() request count', () => {
function mockRawAlbumApi(albumId: string) {
mockFetch.mockImplementation(async (url: string) => {
const body = url.includes('/search/metadata')
? {
assets: {
items: [
{ id: 'old', isTrashed: false, fileCreatedAt: '2024-01-01T00:00:00Z' },
{ id: 'gone', isTrashed: true, fileCreatedAt: '2024-01-02T00:00:00Z' },
{ id: 'new', isTrashed: false, fileCreatedAt: '2024-01-03T00:00:00Z' },
],
nextPage: null,
},
}
: { id: albumId, albumName: 'Raw', assetCount: 3, assets: [], order: 'desc' };
return { ok: true, headers: { get: () => 'application/json' }, json: async () => body };
});
}
it('asks Immich once for many concurrent callers and serves repeats from the cache', async () => {
mockRawAlbumApi('unlisted');
const lists = await Promise.all(
Array.from({ length: 10 }, () => immich.getAlbumAssetsRaw('unlisted')),
);
// One album request plus one metadata page — not 2 × 10.
expect(mockFetch).toHaveBeenCalledTimes(2);
await immich.getAlbumAssetsRaw('unlisted');
expect(mockFetch).toHaveBeenCalledTimes(2);
// Unchanged result: trashed dropped, capture-time order of a 'desc' album.
for (const list of lists) expect(list.map((a) => a.id)).toEqual(['new', 'old']);
});
it('shares the load with getAlbum() for an allowlisted album', async () => {
mockRawAlbumApi('album-1');
await immich.getAlbum('album-1');
const raw = await immich.getAlbumAssetsRaw('album-1');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(raw.map((a) => a.id)).toEqual(['new', 'old']);
});
it('hands each caller its own array, so no one reorders the cached entry', async () => {
mockRawAlbumApi('unlisted');
const first = await immich.getAlbumAssetsRaw('unlisted');
first.reverse();
expect((await immich.getAlbumAssetsRaw('unlisted')).map((a) => a.id)).toEqual(['new', 'old']);
});
it('goes back to Immich when the admin editors ask for a fresh copy', async () => {
mockRawAlbumApi('unlisted');
await immich.getAlbumAssetsRaw('unlisted');
await immich.getAlbumAssetsRaw('unlisted', true);
expect(mockFetch).toHaveBeenCalledTimes(4);
});
it('still does not open getAlbum() to an album outside the allowlist', async () => {
mockRawAlbumApi('unlisted');
vi.spyOn(console, 'warn').mockImplementation(() => {});
await immich.getAlbumAssetsRaw('unlisted');
expect(await immich.getAlbum('unlisted')).toBeNull();
});
});
});
describe('stale fallback when Immich is unavailable', () => {

View File

@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
/**
* listJournalEntries sorted by date only, so two entries from the same day kept
* whatever order readdir returned — the /journal index and the entry page's
* prev/next links then depended on the filesystem. The slug breaks the tie.
*/
let root: string;
let journalDir: string;
let service: typeof import('../admin/journal-service');
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'folio-journal-order-'));
journalDir = path.join(root, 'content', 'journal');
await fs.mkdir(journalDir, { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.resetModules();
service = await import('../admin/journal-service');
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(root, { recursive: true, force: true });
});
const entry = (title: string, date?: string) =>
`---\ntitle: "${title}"\n${date ? `date: "${date}"\n` : ''}---\n\nBody\n`;
/** Make readdir hand the files back in a fixed order. */
function readdirOrder(order: 'asc' | 'desc') {
const real = fs.readdir.bind(fs) as (dir: string) => Promise<string[]>;
vi.spyOn(fs, 'readdir').mockImplementation((async (dir: string) => {
const names = [...(await real(dir))].sort();
return order === 'asc' ? names : names.reverse();
}) as unknown as typeof fs.readdir);
}
describe('listJournalEntries order', () => {
it.each(['asc', 'desc'] as const)(
'orders same-day entries by slug whatever readdir returns (%s)',
async (order) => {
await fs.writeFile(path.join(journalDir, 'alpha.md'), entry('Zebra', '2026-03-15'));
await fs.writeFile(path.join(journalDir, 'beta.md'), entry('Aardvark', '2026-03-15'));
await fs.writeFile(path.join(journalDir, 'newer.md'), entry('Newer', '2026-04-01'));
readdirOrder(order);
const slugs = (await service.listJournalEntries()).map((e) => e.slug);
expect(slugs).toEqual(['newer', 'alpha', 'beta']);
},
);
it.each(['asc', 'desc'] as const)(
'orders undated entries with the same title by slug (%s)',
async (order) => {
await fs.writeFile(path.join(journalDir, 'one.md'), entry('Untitled'));
await fs.writeFile(path.join(journalDir, 'two.md'), entry('Untitled'));
readdirOrder(order);
const slugs = (await service.listJournalEntries()).map((e) => e.slug);
expect(slugs).toEqual(['one', 'two']);
},
);
});

View File

@@ -0,0 +1,268 @@
import { describe, it, expect } from 'vitest';
import {
calculateReadingTime,
parseJournalMarkdown,
renderInlineMarkdown,
serializeJournalMarkdown,
} from '../journal';
import type { JournalBlock } from '../journal';
const ID = '3f1c9a52-8d4e-4b7a-9c1d-0e2f3a4b5c6d';
const ID2 = '7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d';
const ID3 = 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e';
const serialize = (blocks: JournalBlock[]) =>
serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] });
const roundTrip = (source: string) => serializeJournalMarkdown(parseJournalMarkdown(source));
/** No block that renders as text may carry an asset id. */
function expectNoIdInText(blocks: JournalBlock[], ids: string[]) {
for (const block of blocks) {
const text =
block.type === 'paragraph'
? block.html
: block.type === 'heading'
? block.text
: block.type === 'quote'
? block.text
: 'caption' in block
? (block.caption ?? '')
: '';
for (const id of ids) expect(text).not.toContain(id);
}
}
/** Captions that broke, or could break, the `![…](…)` framing. */
const TRICKY_CAPTIONS = [
'Gipfel (560 m)',
'(560 m)',
'Ende :)',
'Anfang :(',
'((doppelt))',
')',
'(',
'a) b (c',
'Siehe [Karte](https://example.com/map_(1)) oben',
'[Link](https://immich.app)',
'**fett (mit Klammer)** und *kursiv*',
'eckige [Klammern] ohne Link',
'] mitten [',
'Pfad C:\\Fotos\\',
'Backslash vor Klammer \\) und \\(',
'Emoji 🏔️ (Gipfel) 📷',
'Umlaute: Größe, Übersicht, Äpfel (Öl)',
'Anna\'s "Zitat" & <Tag>',
'![verschachtelt](nicht wirklich)',
];
describe('photo captions with parentheses (A-1)', () => {
it('keeps a wide photo with `(560 m)` in its caption a photo block', () => {
const source = `![${ID}:wide](Gipfel (560 m))`;
const parsed = parseJournalMarkdown(source);
expect(parsed.blocks).toEqual([
{ type: 'photo', assetId: ID, layout: 'wide', caption: 'Gipfel (560 m)' },
]);
expect(roundTrip(source)).toBe(source);
});
it('keeps pair and grid captions with parentheses intact', () => {
const pair = `![${ID}, ${ID2}](Links (Nord), rechts (Süd))`;
expect(parseJournalMarkdown(pair).blocks[0]).toEqual({
type: 'photo-pair',
assetIds: [ID, ID2],
caption: 'Links (Nord), rechts (Süd)',
});
expect(roundTrip(pair)).toBe(pair);
const grid = `![${ID}, ${ID2}, ${ID3}](Drei (3))`;
expect(parseJournalMarkdown(grid).blocks[0]).toEqual({
type: 'photo-grid',
assetIds: [ID, ID2, ID3],
caption: 'Drei (3)',
});
expect(roundTrip(grid)).toBe(grid);
});
it('renders a link inside a caption', () => {
const parsed = parseJournalMarkdown(`![${ID}](Mehr auf [Immich](https://immich.app))`);
expect(parsed.blocks[0]).toMatchObject({
type: 'photo',
caption:
'Mehr auf <a href="https://immich.app" target="_blank" rel="noopener noreferrer">Immich</a>',
});
});
it('keeps balanced parentheses inside a link URL', () => {
const source = `![${ID}:wide](Gipfel (560 m), siehe [Karte](https://example.com/map_(1)))`;
const parsed = parseJournalMarkdown(source);
expect(parsed.blocks[0]).toMatchObject({
type: 'photo',
caption:
'Gipfel (560 m), siehe <a href="https://example.com/map_(1)" target="_blank" rel="noopener noreferrer">Karte</a>',
});
expect(roundTrip(source)).toBe(source);
// Outside a link, a parenthesis after the URL is still text.
expect(renderInlineMarkdown('[a](https://x.y) (b)')).toBe(
'<a href="https://x.y" target="_blank" rel="noopener noreferrer">a</a> (b)',
);
});
for (const caption of TRICKY_CAPTIONS) {
it(`round-trips the caption ${JSON.stringify(caption)} for every photo block type`, () => {
const html = renderInlineMarkdown(caption);
const blocks: JournalBlock[] = [
{ type: 'photo', assetId: ID, layout: 'fullbleed', caption: html },
{ type: 'photo', assetId: ID, layout: 'contained', caption: html },
{ type: 'photo-pair', assetIds: [ID, ID2], caption: html },
{ type: 'photo-grid', assetIds: [ID, ID2, ID3], caption: html },
];
for (const block of blocks) {
const once = serialize([block]);
const parsed = parseJournalMarkdown(once);
// The framing is exact: the caption comes back as the markdown that
// was written, rendered once, and the block keeps its type and ids.
expect(parsed.blocks).toHaveLength(1);
const [back] = parsed.blocks;
expect(back.type).toBe(block.type);
expect(back).toMatchObject(
block.type === 'photo'
? { assetId: ID, layout: block.layout }
: { assetIds: 'assetIds' in block ? block.assetIds : [] },
);
const writtenCaption = once.slice(once.indexOf('](') + 2, -1);
expect('caption' in back ? back.caption : undefined).toBe(
renderInlineMarkdown(writtenCaption.trim()),
);
// And a second save writes the same file.
expect(serialize(parsed.blocks)).toBe(once);
expectNoIdInText(
parsed.blocks.filter((b) => b.type === 'paragraph'),
[ID, ID2, ID3],
);
}
});
}
it('parses any single-line caption exactly (seeded fuzz)', () => {
const alphabet = ['(', ')', '[', ']', '\\', '*', '_', ' ', 'a', 'Ü', '🏔️', '!', ':', ','];
let seed = 0x5eed;
const next = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed;
};
for (let n = 0; n < 500; n++) {
const length = next() % 24;
let caption = '';
for (let i = 0; i < length; i++) caption += alphabet[next() % alphabet.length];
const source = `![${ID}:wide](${caption})`;
const parsed = parseJournalMarkdown(source);
expect(parsed.blocks).toHaveLength(1);
expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID, layout: 'wide' });
const expected = caption.trim() ? renderInlineMarkdown(caption.trim()) : undefined;
expect((parsed.blocks[0] as { caption?: string }).caption).toBe(expected);
// Idempotence of a save. Stray `*`/`_` runs are left out: the inline
// emphasis renderer normalises those on its own (`__*` → `***`), which is
// a property of renderInlineMarkdown, not of the photo-line framing.
if (!/[*_]/.test(caption)) {
const once = serializeJournalMarkdown(parsed);
expect(roundTrip(once)).toBe(once);
}
}
});
});
describe('photo lines the old pattern accepted parse exactly as before', () => {
// The pattern before A-1, kept here as the reference for backward compatibility.
const legacy = (line: string) => {
const m = line.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
return m ? { target: m[1].trim(), caption: m[2].trim() } : null;
};
const lines = [
`![${ID}]()`,
`![${ID}](Caption)`,
`![${ID}:wide](A **bold** caption)`,
`![${ID}:fullbleed](Offene Klammer (ohne Ende)`,
`![${ID}, ${ID2}](Anna's photos)`,
`![${ID}, ${ID2}, ${ID3}](Drei [eckige] Klammern)`,
`![](Placeholder)`,
`![:wide]()`,
`![${ID}](C:\\)`,
`![${ID}](a\\b \\( c)`,
`![${ID}](Emoji 🏔️ Umlaut ä)`,
`![${ID}]( Leerraum )`,
];
for (const line of lines) {
it(JSON.stringify(line), () => {
const old = legacy(line);
expect(old).not.toBeNull();
const blocks = parseJournalMarkdown(line).blocks;
expect(blocks).toHaveLength(1);
const expectedCaption = old!.caption ? renderInlineMarkdown(old!.caption) : undefined;
expect((blocks[0] as { caption?: string }).caption).toBe(expectedCaption);
const ids =
blocks[0].type === 'photo'
? [blocks[0].assetId]
: 'assetIds' in blocks[0]
? blocks[0].assetIds
: [];
expect(ids.join(', ')).toBe(old!.target.replace(/:(wide|fullbleed)$/, ''));
// Unchanged content saves to the same line.
expect(roundTrip(line)).toBe(roundTrip(roundTrip(line)));
});
}
});
describe('a malformed photo line never prints asset ids (A-1)', () => {
it('splits two photo lines written without a blank line between them', () => {
const parsed = parseJournalMarkdown(`![${ID}](Erstes)\n![${ID2}:wide](Zweites (2))`);
expect(parsed.blocks).toEqual([
{ type: 'photo', assetId: ID, layout: 'contained', caption: 'Erstes' },
{ type: 'photo', assetId: ID2, layout: 'wide', caption: 'Zweites (2)' },
]);
});
it('splits a heading or sentence directly above a photo', () => {
const parsed = parseJournalMarkdown(`## Tag 1\n![${ID}](Gipfel)`);
expect(parsed.blocks).toEqual([
{ type: 'heading', level: 2, text: 'Tag 1' },
{ type: 'photo', assetId: ID, layout: 'contained', caption: 'Gipfel' },
]);
const para = parseJournalMarkdown(`Wir stiegen auf.\n ![${ID}, ${ID2}](Oben)`);
expect(para.blocks.map((b) => b.type)).toEqual(['paragraph', 'photo-pair']);
expectNoIdInText(para.blocks, [ID, ID2]);
});
it('keeps text after the closing parenthesis in the caption, not a paragraph', () => {
const parsed = parseJournalMarkdown(`![${ID}](Gipfel) und weiter`);
expect(parsed.blocks).toHaveLength(1);
expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID });
expectNoIdInText(parsed.blocks, [ID]);
// Stable from the first save on.
const once = roundTrip(`![${ID}](Gipfel) und weiter`);
expect(roundTrip(once)).toBe(once);
});
it('treats a reference without a caption group as a photo', () => {
for (const source of [`![${ID}]`, `![${ID}:wide] Text`, `![${ID}`]) {
const parsed = parseJournalMarkdown(source);
expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID });
expectNoIdInText(parsed.blocks, [ID]);
}
});
it('leaves directive chunks alone', () => {
const parsed = parseJournalMarkdown(`::album abc\ncaption: ![x](y)`);
expect(parsed.blocks).toHaveLength(1);
expect(parsed.blocks[0]).toMatchObject({ type: 'album', albumId: 'abc' });
});
});
describe('reading time ignores photo lines', () => {
it('drops the whole line, parentheses in the caption included', () => {
const { words } = calculateReadingTime(`Eins zwei.\n\n![${ID}](Gipfel (560 m) oben)`);
expect(words).toBe(2);
});
});

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, rm } from 'fs/promises';
import {
parseJournalMarkdown,
serializeJournalMarkdown,
@@ -7,22 +10,8 @@ import {
isValidSlug,
sanitizeSlug,
} from '../journal';
import {
writeJournalEntry,
readJournalEntry,
deleteJournalEntry,
listJournalEntries,
} from '../admin/journal-service';
describe('Journal Service & Parser', () => {
const testSlug = 'test-journey-nordkap';
afterEach(async () => {
try {
await deleteJournalEntry(testSlug);
} catch {}
});
describe('slug validation and sanitization', () => {
it('validates safe alphanumeric and hyphenated slugs', () => {
expect(isValidSlug('my-story-2026')).toBe(true);
@@ -149,7 +138,28 @@ A paragraph of text.
});
describe('filesystem CRUD operations', () => {
// The service resolves content/journal from process.cwd() at import time.
// Without a temp cwd this wrote a real entry into the checkout's content/
// — the live site's, in a deployment — and left a .deleted.bak behind.
const testSlug = 'test-journey-nordkap';
let root: string;
let service: typeof import('../admin/journal-service');
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-crud-'));
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.resetModules();
service = await import('../admin/journal-service');
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
it('writes, reads, lists and deletes journal files safely', async () => {
const { writeJournalEntry, readJournalEntry, deleteJournalEntry, listJournalEntries } =
service;
const testContent = `---
title: "Vitest Journey"
date: "2026-08-14"

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { formatJournalDate } from '../journalDate';
/**
* The /journal index formatted `new Date(frontmatter.date)` in the server's
* timezone: `2026-03-15` is UTC midnight, so every server west of Greenwich
* printed Mar 14. Hand-typed dates became "Invalid Date", and the entry page
* printed the raw string next to the index's localized one.
*
* Run under a negative UTC offset so a regression to local-time formatting
* shows up on any machine.
*/
let savedTz: string | undefined;
beforeAll(() => {
savedTz = process.env.TZ;
process.env.TZ = 'America/Los_Angeles';
});
afterAll(() => {
if (savedTz === undefined) delete process.env.TZ;
else process.env.TZ = savedTz;
});
describe('formatJournalDate', () => {
it('keeps the calendar day of a date-only value west of UTC', () => {
// Sanity check that the offset is in effect: this is the old code path.
expect(new Date('2026-03-15').getDate()).toBe(14);
expect(formatJournalDate('2026-03-15', 'en-US')).toBe('Mar 15, 2026');
expect(formatJournalDate('2026-01-01', 'de-DE')).toBe('1. Jan. 2026');
});
it('takes the written day of a value with a time', () => {
expect(formatJournalDate('2026-03-15T23:30:00+01:00', 'en-US')).toBe('Mar 15, 2026');
expect(formatJournalDate('2026-03-15 08:00', 'en-US')).toBe('Mar 15, 2026');
});
it('shows a year-month value without inventing a day', () => {
expect(formatJournalDate('2026-03', 'en-US')).toBe('March 2026');
});
it.each(['15.03.2026', 'March 2026', 'Spring 2026', '2026-02-30', '2026-13', '2026-03-150'])(
'shows %s as written instead of Invalid Date or a guessed day',
(raw) => {
expect(formatJournalDate(raw, 'en-US')).toBe(raw);
},
);
it('returns null for a missing or blank date', () => {
expect(formatJournalDate(undefined, 'en-US')).toBeNull();
expect(formatJournalDate(' ', 'en-US')).toBeNull();
});
});

View File

@@ -13,8 +13,15 @@ vi.mock('@/lib/immich', () => ({
},
}));
const config = vi.hoisted(() => ({
value: {
standaloneAlbums: ['album-1'] as string[],
subpages: [] as Array<{ slug: string; albumIds: string[]; enabled: boolean; hidden?: boolean }>,
cacheTtl: 60_000,
},
}));
vi.mock('@/lib/config', () => ({
getConfig: () => ({ subpages: [], cacheTtl: 60_000 }),
getConfig: () => config.value,
}));
vi.mock('@/lib/cache', () => ({
@@ -22,6 +29,7 @@ vi.mock('@/lib/cache', () => ({
}));
import { getMapData } from '@/lib/mapService';
import { listedAlbumIds } from '@/lib/config/schema';
const ALBUM = { id: 'album-1', albumName: 'Trip', slug: 'trip' };
@@ -30,6 +38,8 @@ function asset(id: string, exif: Record<string, unknown>) {
}
beforeEach(() => {
config.value.standaloneAlbums = ['album-1'];
config.value.subpages = [];
getAlbums.mockReset();
getAlbum.mockReset();
getAlbums.mockResolvedValue([ALBUM]);
@@ -83,3 +93,101 @@ describe('getMapData coordinate filtering (#635)', () => {
expect(locations).toHaveLength(0);
});
});
/**
* `enabled: false` takes a subpage offline. Its albums stay on the allowlist
* (the configuration is kept), so the map has to leave them out itself: a
* marker names the album, links it, counts it and shows one of its photos.
*/
describe('getMapData and offline subpages', () => {
const berlin = { latitude: 52.5, longitude: 13.4, city: 'Berlin', country: 'Germany' };
beforeEach(() => {
getAlbum.mockResolvedValue({ id: ALBUM.id, assets: [asset('a', berlin)] });
});
it('leaves out an album whose only subpage is offline', async () => {
config.value.standaloneAlbums = [];
config.value.subpages = [{ slug: 'old-series', albumIds: [ALBUM.id], enabled: false }];
expect(await getMapData()).toEqual([]);
});
it('keeps it when an enabled subpage lists it too, and links it there', async () => {
config.value.standaloneAlbums = [];
config.value.subpages = [
{ slug: 'old-series', albumIds: [ALBUM.id], enabled: false },
{ slug: 'travel', albumIds: [ALBUM.id], enabled: true },
];
const locations = await getMapData();
expect(locations).toHaveLength(1);
expect(locations[0].albums[0].subpageSlug).toBe('travel');
});
});
/**
* `hidden: true` keeps a subpage reachable by direct link only. The map is a
* listing: a marker names the album, links it under the subpage's address and
* shows one of its photos, so an album only a hidden subpage carries must not
* appear on it. Its photos stay reachable through the link (publishedAssets
* still asks onlineAlbumIds, which is not narrowed).
*/
describe('getMapData and hidden subpages', () => {
const berlin = { latitude: 52.5, longitude: 13.4, city: 'Berlin', country: 'Germany' };
beforeEach(() => {
getAlbum.mockResolvedValue({ id: ALBUM.id, assets: [asset('a', berlin)] });
});
it('leaves out an album whose only subpage is hidden', async () => {
config.value.standaloneAlbums = [];
config.value.subpages = [
{ slug: 'private', albumIds: [ALBUM.id], enabled: true, hidden: true },
];
expect(await getMapData()).toEqual([]);
expect(getAlbum).not.toHaveBeenCalled();
});
it('keeps it when a listed subpage carries it too, and links it there', async () => {
config.value.standaloneAlbums = [];
config.value.subpages = [
{ slug: 'private', albumIds: [ALBUM.id], enabled: true, hidden: true },
{ slug: 'travel', albumIds: [ALBUM.id], enabled: true },
];
const locations = await getMapData();
expect(locations).toHaveLength(1);
expect(locations[0].albums[0].subpageSlug).toBe('travel');
});
it('keeps a standalone album that a hidden subpage also carries, linked standalone', async () => {
config.value.standaloneAlbums = [ALBUM.id];
config.value.subpages = [
{ slug: 'private', albumIds: [ALBUM.id], enabled: true, hidden: true },
];
const locations = await getMapData();
expect(locations).toHaveLength(1);
expect(locations[0].albums[0].subpageSlug).toBeUndefined();
});
});
describe('listedAlbumIds', () => {
it('drops albums of offline and hidden subpages, keeps standalone and listed ones', () => {
const ids = listedAlbumIds({
standaloneAlbums: ['s'],
subpages: [
{ albumIds: ['v', 'shared'], enabled: true },
{ albumIds: ['off'], enabled: false },
{ albumIds: ['h', 'shared'], enabled: true, hidden: true },
],
});
expect([...ids].sort()).toEqual(['s', 'shared', 'v']);
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises';
/**
* content/pages/ storage (#722): the journal's rules — valid slugs only, a
* backup before every overwrite, ten saves kept, a `.deleted.bak` on delete.
*/
let root: string;
let pagesDir: string;
let backupDir: string;
let service: typeof import('../admin/pages-service');
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-pages-'));
pagesDir = path.join(root, 'content', 'pages');
backupDir = path.join(pagesDir, '.backups');
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
service = await import('../admin/pages-service');
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const page = (title: string, extra = '') =>
`---\ntitle: "${title}"\n${extra}---\n\nAbout ${title}\n`;
describe('pages-service', () => {
it('writes, reads and lists a page', async () => {
await service.writePage('pricing', page('Pricing', 'draft: true\n'));
const read = await service.readPage('pricing');
expect(read?.parsed.frontmatter).toMatchObject({ title: 'Pricing', draft: true });
expect(service.readPageSync('pricing')?.rawMarkdown).toBe(read?.rawMarkdown);
const list = await service.listPages();
expect(list).toEqual([
{ slug: 'pricing', frontmatter: expect.objectContaining({ title: 'Pricing' }) },
]);
expect(service.listPageSlugsSync()).toEqual(['pricing']);
});
it('keeps the SEO description in the frontmatter', async () => {
await service.writePage('faq', page('FAQ', 'description: "Questions, answered"\n'));
expect((await service.readPage('faq'))?.parsed.frontmatter.description).toBe(
'Questions, answered',
);
});
it('refuses a slug that is not one, and never touches a path outside', async () => {
await expect(service.writePage('../escape', 'x')).rejects.toThrow(/Invalid page slug/);
await expect(service.writePage('a/b', 'x')).rejects.toThrow(/Invalid page slug/);
expect(await service.readPage('../gallery')).toBeNull();
expect(service.resolvePageFilePath('..')).toBeNull();
});
it('answers null for a page that does not exist', async () => {
expect(await service.readPage('nothing')).toBeNull();
expect(await service.listPages()).toEqual([]);
});
it('backs up the previous version before an overwrite, keeping ten', async () => {
await mkdir(pagesDir, { recursive: true });
await writeFile(path.join(pagesDir, 'pricing.md'), page('v0'));
for (let i = 1; i <= 12; i++) {
await service.writePage('pricing', page(`v${i}`));
// Distinct timestamps in the backup names.
await new Promise((r) => setTimeout(r, 2));
}
const backups = await readdir(backupDir);
expect(backups).toHaveLength(10);
expect(await readFile(path.join(pagesDir, 'pricing.md'), 'utf8')).toBe(page('v12'));
});
it('keeps a .deleted.bak copy on delete, and restores from it', async () => {
await service.writePage('workshops', page('Workshops'));
await expect(service.deletePage('workshops')).resolves.toBe(true);
await expect(service.deletePage('workshops')).resolves.toBe(false);
expect(await service.readPage('workshops')).toBeNull();
const [backup] = await service.listPageBackups();
expect(backup).toMatchObject({ slug: 'workshops', kind: 'deleted' });
await expect(service.restorePageBackup(backup.filename)).resolves.toBe('workshops');
expect((await service.readPage('workshops'))?.parsed.frontmatter.title).toBe('Workshops');
});
it('refuses to restore from a name it did not write', async () => {
await expect(service.restorePageBackup('../gallery.yaml')).rejects.toThrow(/unrecognised/);
await expect(service.restorePageBackup('pricing.md')).rejects.toThrow(/unrecognised/);
});
});

155
lib/__tests__/pages.test.ts Normal file
View File

@@ -0,0 +1,155 @@
import { describe, it, expect } from 'vitest';
import {
RESERVED_PAGE_SLUGS,
describeCollision,
menuPageSlugs,
pageSlugCollision,
resolveMenu,
type NavEntry,
type PageSummary,
} from '../pages';
import { deriveGallery, type GalleryYaml } from '../config';
import { rewritePageRefs } from '../admin/pageRefs';
const A1 = '11111111-1111-4111-8111-111111111111';
const A2 = '22222222-2222-4222-8222-222222222222';
const none = { subpages: [], albums: [], journal: [] };
describe('pageSlugCollision (#722)', () => {
it('accepts a free slug', () => {
expect(pageSlugCollision('pricing', none)).toBeNull();
});
it.each(RESERVED_PAGE_SLUGS)('refuses the built-in route /%s', (slug) => {
expect(pageSlugCollision(slug, none)).toEqual({ kind: 'reserved' });
});
it('refuses a slug taken by a subpage, an album or a journal entry', () => {
const taken = { subpages: ['travel'], albums: ['iceland'], journal: ['trip'] };
expect(pageSlugCollision('travel', taken)).toEqual({ kind: 'subpage' });
expect(pageSlugCollision('iceland', taken)).toEqual({ kind: 'album' });
expect(pageSlugCollision('trip', taken)).toEqual({ kind: 'journal' });
});
it('compares without regard to case', () => {
expect(pageSlugCollision('Travel', { ...none, subpages: ['travel'] })).toEqual({
kind: 'subpage',
});
expect(pageSlugCollision('ADMIN', none)).toEqual({ kind: 'reserved' });
});
it('refuses what is not a slug at all', () => {
expect(pageSlugCollision('a/b', none)).toEqual({ kind: 'invalid' });
expect(pageSlugCollision('', none)).toEqual({ kind: 'invalid' });
});
it('describes each collision in a sentence naming the slug', () => {
for (const kind of ['invalid', 'reserved', 'subpage', 'album', 'journal'] as const) {
expect(describeCollision('x', { kind })).toContain('x');
}
});
});
describe('deriveGallery: `- page:` references (#722)', () => {
const gallery = (subpages: NonNullable<GalleryYaml['subpages']>): GalleryYaml => ({ subpages });
it('keeps pages and subpages in one menu order', () => {
const { nav, subpages } = deriveGallery(
gallery([
{ page: 'pricing' },
{ name: 'Travel', albums: [A1] },
{ page: 'faq' },
{ name: 'Weddings', albums: [A2] },
]),
);
expect(subpages.map((s) => s.slug)).toEqual(['travel', 'weddings']);
expect(nav).toEqual([
{ type: 'page', slug: 'pricing' },
{ type: 'subpage', slug: 'travel' },
{ type: 'page', slug: 'faq' },
{ type: 'subpage', slug: 'weddings' },
]);
expect(menuPageSlugs(nav)).toEqual(['pricing', 'faq']);
});
it('allows a gallery whose menu holds only pages', () => {
expect(deriveGallery(gallery([{ page: 'pricing' }])).nav).toEqual([
{ type: 'page', slug: 'pricing' },
]);
});
it('rejects an invalid page slug and a page listed twice', () => {
expect(() => deriveGallery(gallery([{ page: '../x' }]))).toThrow(/not a valid page slug/);
expect(() => deriveGallery(gallery([{ page: 'a' }, { page: 'a' }]))).toThrow(/more than once/);
});
it('builds the menu from the map form of subpages too', () => {
const { nav } = deriveGallery({ subpages: { Travel: [A1] } });
expect(nav).toEqual([{ type: 'subpage', slug: 'travel' }]);
});
});
describe('resolveMenu (#722)', () => {
const nav: NavEntry[] = [
{ type: 'subpage', slug: 'travel' },
{ type: 'page', slug: 'pricing' },
{ type: 'subpage', slug: 'hidden' },
{ type: 'page', slug: 'draft' },
{ type: 'page', slug: 'missing' },
{ type: 'page', slug: 'locked' },
];
const pages: PageSummary[] = [
{ slug: 'pricing', frontmatter: { title: 'Pricing' } },
{ slug: 'draft', frontmatter: { title: 'Soon', draft: true } },
{ slug: 'locked', frontmatter: { password: 'scrypt:a:b' } },
];
it('interleaves visible subpages and published pages in gallery.yaml order', () => {
const menu = resolveMenu(nav, [{ slug: 'travel', name: 'Travel' }], pages);
expect(menu.map((m) => [m.href, m.label])).toEqual([
['/travel', 'Travel'],
['/pricing', 'Pricing'],
// A protected page stays listed; its title falls back to the slug.
['/locked', 'locked'],
]);
});
// `name` is the gallery.yaml key the slug is built from ("south-korea");
// `title` is what the page itself shows ("South Korea"). The menu showed
// the key.
it('labels a subpage with its title, falling back to its name', () => {
const menu = resolveMenu(
[
{ type: 'subpage', slug: 'south-korea' },
{ type: 'subpage', slug: 'travel' },
],
[
{ slug: 'south-korea', name: 'south-korea', title: 'South Korea' },
{ slug: 'travel', name: 'Travel' },
],
[],
);
expect(menu.map((m) => m.label)).toEqual(['South Korea', 'Travel']);
});
});
describe('rewritePageRefs (#722)', () => {
const list = [{ name: 'Travel' }, { page: 'pricing' }, { page: 'faq' }];
it('renames a reference in place', () => {
expect(rewritePageRefs(list, 'pricing', 'rates')).toEqual([
{ name: 'Travel' },
{ page: 'rates' },
{ page: 'faq' },
]);
});
it('removes a reference', () => {
expect(rewritePageRefs(list, 'faq', null)).toEqual([{ name: 'Travel' }, { page: 'pricing' }]);
});
it('returns the same list when nothing matches', () => {
expect(rewritePageRefs(list, 'nothing', null)).toBe(list);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, rename, rm, unlink, writeFile } from 'fs/promises';
/**
* The journal and page listings run on every public render (the header nav
* lists both) and used to parse every markdown file each time. They now keep
* the parse per file while the file is unchanged — which must never cost an
* admin save its immediate visibility.
*/
let root: string;
let journalDir: string;
let pagesDir: string;
let parseSpy: ReturnType<typeof vi.fn<(raw: string) => void>>;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-parsecache-'));
journalDir = path.join(root, 'content', 'journal');
pagesDir = path.join(root, 'content', 'pages');
await mkdir(journalDir, { recursive: true });
await mkdir(pagesDir, { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
parseSpy = vi.fn<(raw: string) => void>();
vi.doMock('../journal', async (importOriginal) => {
const actual = await importOriginal<typeof import('../journal')>();
return {
...actual,
parseJournalMarkdown: (raw: string) => {
parseSpy(raw);
return actual.parseJournalMarkdown(raw);
},
};
});
});
afterEach(async () => {
vi.doUnmock('../journal');
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const doc = (title: string) =>
`---\ntitle: "${title}"\ndate: "2026-01-01"\n---\n\nBody of ${title}\n`;
describe('listJournalEntries', () => {
it('parses an unchanged file once across listings', async () => {
await writeFile(path.join(journalDir, 'a.md'), doc('Alpha'));
await writeFile(path.join(journalDir, 'b.md'), doc('Bravo'));
const service = await import('../admin/journal-service');
const first = await service.listJournalEntries();
const second = await service.listJournalEntries();
expect(second).toEqual(first);
expect(second.map((e) => e.frontmatter.title).sort()).toEqual(['Alpha', 'Bravo']);
expect(parseSpy).toHaveBeenCalledTimes(2);
});
it('shows an admin save on the very next listing, even at the same size', async () => {
await writeFile(path.join(journalDir, 'a.md'), doc('Alpha'));
const service = await import('../admin/journal-service');
expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Alpha');
// Same byte length, so only the inode (atomicWrite renames) tells them apart.
await service.writeJournalEntry('a', doc('Omega'));
expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Omega');
});
it('sees an edit made in place and a file renamed over the entry', async () => {
const file = path.join(journalDir, 'a.md');
await writeFile(file, doc('Alpha'));
const service = await import('../admin/journal-service');
await service.listJournalEntries();
await writeFile(file, doc('Alpha, revised'));
expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Alpha, revised');
await writeFile(path.join(root, 'tmp.md'), doc('Gamma'));
await rename(path.join(root, 'tmp.md'), file);
expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Gamma');
});
it('drops a deleted entry and hands out copies callers cannot poison', async () => {
await writeFile(path.join(journalDir, 'a.md'), doc('Alpha'));
await writeFile(path.join(journalDir, 'b.md'), doc('Bravo'));
const service = await import('../admin/journal-service');
const listed = await service.listJournalEntries();
listed[0].frontmatter.title = 'mutated';
listed[0].excerpt = 'mutated';
expect((await service.listJournalEntries()).map((e) => e.frontmatter.title).sort()).toEqual([
'Alpha',
'Bravo',
]);
await unlink(path.join(journalDir, 'b.md'));
expect((await service.listJournalEntries()).map((e) => e.slug)).toEqual(['a']);
});
});
describe('listPages', () => {
it('parses an unchanged page once and sees a save at once', async () => {
await writeFile(path.join(pagesDir, 'pricing.md'), doc('Pricing'));
const service = await import('../admin/pages-service');
await service.listPages();
const again = await service.listPages();
expect(again).toEqual([
{
slug: 'pricing',
frontmatter: {
title: 'Pricing',
description: undefined,
password: undefined,
draft: undefined,
},
},
]);
expect(parseSpy).toHaveBeenCalledTimes(1);
await service.writePage('pricing', doc('Tariffs'));
expect((await service.listPages())[0].frontmatter.title).toBe('Tariffs');
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { baseName, formatExport } from '../proofExport';
const picks = [
{ position: 3, fileName: 'IMG_0412.JPG', takenAt: '2026-05-01T10:00:00' },
{ position: 7, fileName: 'IMG_0412.CR3' },
{ position: 9, fileName: 'wedding "first", look.jpg' },
];
describe('baseName', () => {
it('drops only the last extension', () => {
expect(baseName('IMG_0412.JPG')).toBe('IMG_0412');
expect(baseName('a.b.tif')).toBe('a.b');
expect(baseName('.hidden')).toBe('.hidden');
expect(baseName('noext')).toBe('noext');
});
});
describe('formatExport', () => {
it('lists de-duplicated base names for a Lightroom filter', () => {
expect(formatExport(picks, 'lightroom')).toBe('IMG_0412, wedding "first", look');
});
it('lists full names one per line', () => {
expect(formatExport(picks, 'txt')).toBe(
'IMG_0412.JPG\nIMG_0412.CR3\nwedding "first", look.jpg\n',
);
expect(formatExport([], 'txt')).toBe('');
});
it('writes RFC 4180 CSV', () => {
expect(formatExport(picks, 'csv').split('\r\n')).toEqual([
'position,file_name,taken_at',
'3,IMG_0412.JPG,2026-05-01T10:00:00',
'7,IMG_0412.CR3,',
'9,"wedding ""first"", look.jpg",',
'',
]);
});
it('never lets a file name become a spreadsheet formula', () => {
const csv = formatExport([{ position: 1, fileName: '=HYPERLINK("x")' }], 'csv');
expect(csv).toContain(`"'=HYPERLINK(""x"")"`);
});
it('also neutralises names starting with a tab or carriage return', () => {
for (const name of ['\t=1+1', '\r=1+1']) {
const row = formatExport([{ position: 1, fileName: name }], 'csv').split('\r\n')[1];
expect(row).toMatch(/^1,"?'/);
}
});
});

View File

@@ -0,0 +1,198 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
claimDownload,
createSession,
deleteSession,
downloadsRemaining,
findSessionByToken,
getSessionById,
isExpired,
isTokenShaped,
listSessions,
newToken,
ProofError,
saveSelection,
sessionInputSchema,
sessionPatchSchema,
sessionState,
submitSelection,
updateSession,
type ProofSession,
} from '../proofSessions';
const ALBUM = '11111111-1111-1111-1111-111111111111';
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-proof-'));
vi.spyOn(process, 'cwd').mockReturnValue(dir);
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(dir, { recursive: true, force: true });
});
const storePath = () => path.join(dir, 'content', 'proofing.json');
function input(overrides: Record<string, unknown> = {}) {
return sessionInputSchema.parse({ clientName: 'Anna', albumId: ALBUM, ...overrides });
}
describe('tokens', () => {
it('are 192-bit URL-safe strings and never repeat', () => {
const a = newToken();
expect(isTokenShaped(a)).toBe(true);
expect(a).not.toBe(newToken());
});
it('rejects anything else before touching the store', async () => {
expect(isTokenShaped('../../etc/passwd')).toBe(false);
expect(isTokenShaped('short')).toBe(false);
expect(await findSessionByToken('short')).toBeNull();
});
});
describe('expiry', () => {
it('is inclusive of the last day', () => {
const session = { expiresOn: '2026-05-12' };
expect(isExpired(session, new Date('2026-05-12T22:00:00'))).toBe(false);
expect(isExpired(session, new Date('2026-05-13T00:00:01'))).toBe(true);
});
it('never expires without a date', () => {
expect(isExpired({}, new Date('2999-01-01'))).toBe(false);
});
});
describe('input validation', () => {
it('requires a client name and an album UUID', () => {
expect(sessionInputSchema.safeParse({ clientName: ' ', albumId: ALBUM }).success).toBe(false);
expect(sessionInputSchema.safeParse({ clientName: 'A', albumId: 'nope' }).success).toBe(false);
});
it('rejects malformed dates and limits', () => {
expect(sessionInputSchema.safeParse({ ...input(), expiresOn: '12.05.2026' }).success).toBe(
false,
);
expect(sessionInputSchema.safeParse({ ...input(), downloadLimit: 0 }).success).toBe(false);
});
it('does not let a patch move a link to another album', () => {
const parsed = sessionPatchSchema.parse({ albumId: ALBUM, clientName: 'B' });
expect(parsed).not.toHaveProperty('albumId');
});
});
describe('store', () => {
it('creates, finds and lists a session, written 0600', async () => {
const session = await createSession(input({ download: 'selection', downloadLimit: 2 }));
expect(session.selection).toEqual([]);
expect(session.downloadsUsed).toBe(0);
expect(await findSessionByToken(session.token)).toMatchObject({ id: session.id });
expect(await getSessionById(session.id)).toMatchObject({ clientName: 'Anna' });
expect(await listSessions()).toHaveLength(1);
if (process.platform !== 'win32') {
expect(fs.statSync(storePath()).mode & 0o777).toBe(0o600);
}
});
it('saves and de-duplicates a selection', async () => {
const { token } = await createSession(input());
const saved = await saveSelection(token, ['a', 'b', 'a']);
expect(saved.selection).toEqual(['a', 'b']);
expect(saved.updatedAt).toBeTruthy();
});
it('serialises concurrent saves instead of losing one', async () => {
const one = await createSession(input({ clientName: 'One' }));
const two = await createSession(input({ clientName: 'Two' }));
await Promise.all([saveSelection(one.token, ['x']), saveSelection(two.token, ['y'])]);
expect((await getSessionById(one.id))?.selection).toEqual(['x']);
expect((await getSessionById(two.id))?.selection).toEqual(['y']);
});
it('locks the selection on submit, and only the first submit counts', async () => {
const { token, id } = await createSession(input());
await saveSelection(token, ['a']);
const first = await submitSelection(token);
const second = await submitSelection(token);
expect(first.firstSubmit).toBe(true);
expect(second.firstSubmit).toBe(false);
await expect(saveSelection(token, ['b'])).rejects.toMatchObject({ code: 'submitted' });
expect(sessionState((await getSessionById(id)) as ProofSession)).toBe('submitted');
// The photographer can reopen it.
await updateSession(id, { reopen: true });
expect((await saveSelection(token, ['b'])).selection).toEqual(['b']);
});
it('refuses every client action on an expired link', async () => {
const { token } = await createSession(input({ expiresOn: '2000-01-01' }));
await expect(saveSelection(token, ['a'])).rejects.toMatchObject({ code: 'expired' });
await expect(submitSelection(token)).rejects.toMatchObject({ code: 'expired' });
});
it('counts downloads against the limit', async () => {
const created = await createSession(input({ download: 'album', downloadLimit: 2 }));
await claimDownload(created.token);
const after = await claimDownload(created.token);
expect(downloadsRemaining(after)).toBe(0);
await expect(claimDownload(created.token)).rejects.toMatchObject({ code: 'limit' });
await updateSession(created.id, { resetDownloads: true });
expect(downloadsRemaining(await claimDownload(created.token))).toBe(1);
});
it('refuses to submit an empty selection, decided under the write lock', async () => {
const { token, id } = await createSession(input());
await saveSelection(token, ['a']);
// A save of [] landing between the route's snapshot and the submit.
await saveSelection(token, []);
await expect(submitSelection(token)).rejects.toMatchObject({ code: 'empty' });
expect((await getSessionById(id))?.submittedAt).toBeUndefined();
});
it('re-checks the download scope under the lock', async () => {
const { token, id } = await createSession(input({ download: 'album' }));
// The photographer narrows the link while a request is on its way.
await updateSession(id, { download: 'selection' });
await expect(claimDownload(token, 'album')).rejects.toMatchObject({ code: 'not-found' });
await expect(claimDownload(token, 'selection')).resolves.toBeTruthy();
});
it('refuses downloads on a link that offers none', async () => {
const { token } = await createSession(input());
await expect(claimDownload(token)).rejects.toBeInstanceOf(ProofError);
});
it('clears optional fields with null in a patch', async () => {
const { id } = await createSession(
input({ expiresOn: '2030-01-01', download: 'album', downloadLimit: 3 }),
);
const patched = await updateSession(id, { expiresOn: null, downloadLimit: null });
expect(patched.expiresOn).toBeUndefined();
expect(patched.downloadLimit).toBeUndefined();
expect(downloadsRemaining(patched)).toBeNull();
});
it('deletes a link, which stops it working', async () => {
const { id, token } = await createSession(input());
await deleteSession(id);
expect(await findSessionByToken(token)).toBeNull();
await expect(deleteSession(id)).rejects.toMatchObject({ code: 'not-found' });
});
it('does not overwrite a store it cannot parse', async () => {
fs.mkdirSync(path.dirname(storePath()), { recursive: true });
fs.writeFileSync(storePath(), '{ not json');
await expect(createSession(input())).rejects.toThrow();
expect(fs.readFileSync(storePath(), 'utf8')).toBe('{ not json');
// …and the queue is not stuck behind the failure.
fs.writeFileSync(storePath(), '{"sessions":[]}');
await expect(createSession(input())).resolves.toBeTruthy();
});
});

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { buildWebhookPayload, notifySubmitted, webhookUrl } from '../proofWebhook';
import type { ProofSession } from '../proofSessions';
const session: ProofSession = {
id: 'abc123',
token: 'x'.repeat(32),
clientName: 'Anna & Ben',
albumId: '11111111-1111-1111-1111-111111111111',
createdAt: '2026-01-01T00:00:00Z',
download: 'none',
downloadsUsed: 0,
selection: ['a', 'b'],
submittedAt: '2026-02-01T00:00:00Z',
};
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('webhookUrl', () => {
it('accepts http(s) only', () => {
expect(webhookUrl('https://ntfy.example/x')).toBe('https://ntfy.example/x');
expect(webhookUrl('ftp://x')).toBeNull();
expect(webhookUrl('not a url')).toBeNull();
expect(webhookUrl('')).toBeNull();
});
});
describe('buildWebhookPayload', () => {
it('speaks Discord, Slack and Gotify at once, and never carries the client token', () => {
const payload = buildWebhookPayload(session, 'Wedding', 'https://folio.example');
expect(payload.content).toContain('Anna & Ben');
expect(payload.text).toContain('2 photos');
expect(payload.title).toBeTruthy();
expect(payload.message).toContain('"Wedding"');
expect(payload.proofing.adminUrl).toBe('https://folio.example/admin/proofing?session=abc123');
expect(JSON.stringify(payload)).not.toContain(session.token);
});
it('works without a site URL', () => {
expect(buildWebhookPayload(session, 'Wedding', null).proofing.adminUrl).toBeNull();
});
});
describe('notifySubmitted', () => {
it('does nothing without a URL', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await notifySubmitted(session, 'Wedding', null);
expect(fetchMock).not.toHaveBeenCalled();
});
it('posts JSON to the configured URL', async () => {
vi.stubEnv('PROOFING_WEBHOOK_URL', 'https://hooks.example/abc');
const fetchMock = vi.fn(async () => new Response('ok'));
vi.stubGlobal('fetch', fetchMock);
await notifySubmitted(session, 'Wedding', null);
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe('https://hooks.example/abc');
expect(JSON.parse(init.body as string).event).toBe('proofing.submitted');
});
it('swallows a failing webhook', async () => {
vi.stubEnv('PROOFING_WEBHOOK_URL', 'https://hooks.example/abc');
vi.stubGlobal(
'fetch',
vi.fn(async () => Promise.reject(new Error('down'))),
);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await expect(notifySubmitted(session, 'Wedding', null)).resolves.toBeUndefined();
expect(warn).toHaveBeenCalled();
});
});

View File

@@ -14,7 +14,7 @@ vi.mock('@/lib/env', () => ({
},
}));
import { resolveProofing } from '@/lib/config';
import { resolveProofing, resolveProofingEmail } from '@/lib/config';
/**
* Client proofing hands a gallery to a client and takes their picks back, so
@@ -48,3 +48,33 @@ describe('resolveProofing', () => {
expect(resolveProofing({ proofing: false }, true)).not.toBe(resolveProofing(undefined, true));
});
});
/**
* The proofing dialog's "email to photographer" needs a recipient (#736): the
* footer contact email by default, overridable with `proofing.email`. Neither
* set means no recipient, and the dialog hides the button rather than opening
* a compose with an empty To:.
*/
describe('resolveProofingEmail', () => {
it('prefers the proofing address over the footer contact email', () => {
expect(resolveProofingEmail('proofs@example.com', 'hello@example.com')).toBe(
'proofs@example.com',
);
});
it('falls back to the footer contact email', () => {
expect(resolveProofingEmail(undefined, 'hello@example.com')).toBe('hello@example.com');
expect(resolveProofingEmail('', 'hello@example.com')).toBe('hello@example.com');
expect(resolveProofingEmail(' ', 'hello@example.com')).toBe('hello@example.com');
});
it('is undefined when neither is set, so the button is hidden', () => {
expect(resolveProofingEmail(undefined, undefined)).toBeUndefined();
expect(resolveProofingEmail('', '')).toBeUndefined();
expect(resolveProofingEmail(' ', ' ')).toBeUndefined();
});
it('trims surrounding whitespace', () => {
expect(resolveProofingEmail(' proofs@example.com ', undefined)).toBe('proofs@example.com');
});
});

View File

@@ -24,7 +24,7 @@ vi.mock('@/lib/config', () => ({
import { isSiteUnlocked } from '@/lib/auth';
import { cdnOrigin } from '@/lib/cdn';
import { tryToParsePath } from 'next/dist/lib/try-to-parse-path';
import { proxy, config, isKnownMissing } from '@/proxy';
import { proxy, config, isKnownMissing, TOP_LEVEL_ROUTES } from '@/proxy';
import { getConfigOrNull } from '@/lib/config';
import fs from 'node:fs';
import os from 'node:os';
@@ -307,6 +307,11 @@ describe('isKnownMissing', () => {
expect(isKnownMissing('/journal/..%2Fsettings', contentDir)).toBe(true);
});
it('treats a malformed escape in a journal slug as missing instead of throwing', () => {
expect(isKnownMissing('/journal/%E0', contentDir)).toBe(true);
expect(proxy(new NextRequest('https://example.com/journal/%E0')).status).toBe(404);
});
it('leaves album and subpage slugs to the page, which needs Immich to decide', () => {
expect(isKnownMissing('/japan', contentDir)).toBe(false);
expect(isKnownMissing('/japan/tokyo', contentDir)).toBe(false);
@@ -317,6 +322,60 @@ describe('isKnownMissing', () => {
expect(isKnownMissing('/contact', contentDir)).toBe(false);
});
it('knows paths deeper than the catch-all serves', () => {
expect(isKnownMissing('/x/y/z', contentDir)).toBe(true);
expect(isKnownMissing('/japan/tokyo/extra/', contentDir)).toBe(true);
// Deep paths below a fixed route are that route's to answer.
expect(isKnownMissing('/admin/settings/general', contentDir)).toBe(false);
expect(isKnownMissing('/admin/journal/kyoto', contentDir)).toBe(false);
expect(isKnownMissing('/proof/abc/def', contentDir)).toBe(false);
// Next's internals and dotfiles are not the catch-all's either.
expect(isKnownMissing('/_next/data/build/x.json', contentDir)).toBe(false);
expect(isKnownMissing('/.well-known/appspecific/x.json', contentDir)).toBe(false);
});
it('lists every top-level route directory under app/', () => {
// A route directory missing from the list would have its deep paths
// answered 404 while the page renders normally.
const appDir = path.join(process.cwd(), 'app');
const hasRoute = (dir: string): boolean =>
fs
.readdirSync(dir, { withFileTypes: true })
.some((entry) =>
entry.isDirectory()
? hasRoute(path.join(dir, entry.name))
: /^(page|route)\.(tsx?|jsx?)$/.test(entry.name),
);
const routeDirs = fs
.readdirSync(appDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== '[...path]')
.filter((entry) => hasRoute(path.join(appDir, entry.name)))
.map((entry) => entry.name);
expect(routeDirs.length).toBeGreaterThan(5);
for (const name of routeDirs) expect(TOP_LEVEL_ROUTES.has(name), name).toBe(true);
});
/*
* #704 answered with NextResponse.rewrite(request.nextUrl, { status: 404 }).
* NextURL reports 127.0.0.1 as "localhost", so behind `next start -H
* 127.0.0.1` the destination's origin no longer matched the server's and
* Next proxied it as an external URL to localhost — ::1, where nothing
* listened: a 30 s hang and a 500 for every known-missing path.
*/
it.each(['/journal/nope', '/contact', '/x/y/z'])(
'answers %s with a 404 on the same request, not a rewrite to another origin',
(pathname) => {
const res = proxy(new NextRequest(`http://127.0.0.1:3591${pathname}`));
expect(res.status).toBe(404);
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
expect(res.headers.get('x-middleware-next')).toBe('1');
// The page still gets its nonce, and the policy still matches it.
const nonce = res.headers.get('x-middleware-request-x-nonce');
expect(nonce).toBeTruthy();
expect(res.headers.get('Content-Security-Policy')).toContain(`'nonce-${nonce}'`);
},
);
it('makes proxy() answer 404 with the policy still set', () => {
const res = proxy(new NextRequest('https://example.com/contact'));
expect(res.status).toBe(404);

View File

@@ -123,8 +123,21 @@ describe('publicPaths', () => {
site({
journalEnabled: true,
journal: [
{ slug: 'published', draft: false },
{ slug: 'wip', draft: true },
{ slug: 'published', draft: false, isProtected: false },
{ slug: 'wip', draft: true, isProtected: false },
],
}),
);
expect(paths).toEqual(['/', '/journal', '/journal/published']);
});
it('omits a password-protected journal entry, as the journal index does', () => {
const paths = publicPaths(
site({
journalEnabled: true,
journal: [
{ slug: 'published', draft: false, isProtected: false },
{ slug: 'private', draft: false, isProtected: true },
],
}),
);
@@ -133,7 +146,10 @@ describe('publicPaths', () => {
it('omits journal entries entirely when the journal is off', () => {
const paths = publicPaths(
site({ journalEnabled: false, journal: [{ slug: 'published', draft: false }] }),
site({
journalEnabled: false,
journal: [{ slug: 'published', draft: false, isProtected: false }],
}),
);
expect(paths).toEqual(['/']);
});
@@ -150,12 +166,38 @@ describe('publicPaths', () => {
expect(paths).toEqual([...new Set(paths)]);
});
it('lists a published content page, whether or not it is in the menu (#722)', () => {
const paths = publicPaths(
site({ pages: [{ slug: 'pricing', draft: false, isProtected: false }] }),
);
expect(paths).toEqual(['/', '/pricing']);
});
it('leaves out a draft content page and a password-protected one', () => {
const paths = publicPaths(
site({
pages: [
{ slug: 'draft', draft: true, isProtected: false },
{ slug: 'secret', draft: false, isProtected: true },
],
}),
);
expect(paths).toEqual(['/']);
});
it('lists no content page on a locked site', () => {
const paths = publicPaths(
site({ siteLocked: true, pages: [{ slug: 'pricing', draft: false, isProtected: false }] }),
);
expect(paths).toEqual([]);
});
it('emits only absolute-looking, single-slash paths', () => {
const paths = publicPaths(
site({
aboutEnabled: true,
journalEnabled: true,
journal: [{ slug: 'story', draft: false }],
journal: [{ slug: 'story', draft: false, isProtected: false }],
subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])],
standaloneAlbums: [{ id: 'b', slug: 'portraits' }],
}),

View File

@@ -0,0 +1,300 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* GHSA-gfh4-6275-9gqv: the media routes serve only assets the site shows. Each
* source that puts an asset on a public page must land in the set, or that
* page loses its photos; nothing else may.
*/
const state = vi.hoisted(() => ({
config: {
albums: ['album-pub', 'album-offline'],
standaloneAlbums: ['album-pub'],
heroImages: ['hero-1'],
albumHeroImages: {
'album-pub': 'album-hero',
'album-offline': 'offline-hero',
} as Record<string, string>,
subpages: [
{ albumIds: [], enabled: true, essayText: '![album:album-essay](x)' },
// Taken offline with `enabled: false`: its albums stay on the allowlist.
{
albumIds: ['album-offline'],
enabled: false,
essayText: '![album:album-offline-essay](x)',
},
] as Array<{ albumIds: string[]; enabled: boolean; essayText?: string }>,
cacheTtl: 300,
},
albums: {
'album-pub': { albumThumbnailAssetId: 'thumb', assets: [{ id: 'a1' }, { id: 'a2' }] },
'album-offline': { albumThumbnailAssetId: 'offline-thumb', assets: [{ id: 'offline-1' }] },
} as Record<string, { albumThumbnailAssetId: string | null; assets: { id: string }[] }>,
raw: {
'album-journal': [{ id: 'j-album-1' }],
'album-essay': [{ id: 'essay-1' }],
'album-offline-essay': [{ id: 'offline-essay-1' }],
'album-page': [{ id: 'page-album-1' }],
} as Record<string, { id: string }[]>,
proofAlbums: {
'album-proof': { albumThumbnailAssetId: null, assets: [{ id: 'proof-1' }] },
'album-expired': { albumThumbnailAssetId: null, assets: [{ id: 'expired-1' }] },
} as Record<string, { albumThumbnailAssetId: string | null; assets: { id: string }[] }>,
builds: 0,
mtime: 1,
/** While set, a proofing album lookup reads its answer, then waits for it. */
gate: null as Promise<void> | null,
onGate: null as (() => void) | null,
}));
vi.mock('../config', () => ({ getConfig: () => state.config }));
vi.mock('../immich', () => ({
immich: {
getAlbum: async (id: string) => {
state.builds += id === 'album-pub' ? 1 : 0;
return state.albums[id] ?? null;
},
getProofingAlbum: async (id: string) => {
const album = state.proofAlbums[id];
const answer = album ? { ...album, assets: [...album.assets] } : null;
if (state.gate) {
state.onGate?.();
await state.gate;
}
return answer;
},
getAlbumAssetsRaw: async (id: string) => state.raw[id] ?? [],
},
}));
vi.mock('../admin/journal-service', () => ({
listJournalEntries: async () => [{ slug: 'trip' }, { slug: 'draft' }],
readJournalEntry: async (slug: string) => ({
slug,
rawMarkdown: '',
parsed:
slug === 'trip'
? {
frontmatter: { coverAssetId: 'j-cover' },
blocks: [
{ type: 'photo', assetId: 'j-photo', layout: 'wide' },
{ type: 'album', albumId: 'album-journal', layout: 'grid' },
],
}
: { frontmatter: {}, blocks: [{ type: 'photo', assetId: 'draft-photo', layout: 'wide' }] },
}),
}));
vi.mock('../admin/pages-service', () => ({
listPages: async () => [{ slug: 'pricing', frontmatter: {} }],
readPage: async (slug: string) => ({
slug,
rawMarkdown: '',
parsed: {
frontmatter: { draft: true },
blocks: [
{ type: 'photo', assetId: 'page-photo', layout: 'wide' },
{ type: 'album', albumId: 'album-page', layout: 'grid' },
{
type: 'map',
line: true,
items: [{ kind: 'photo', assetId: 'page-map-photo' }],
},
],
},
}),
}));
vi.mock('../journal', async (orig) => {
const actual = await orig<typeof import('../journal')>();
return {
...actual,
parseJournalMarkdown: (md: string) => {
const albumId = /album:([\w-]+)/.exec(md)?.[1];
return albumId
? { frontmatter: {}, blocks: [{ type: 'album', albumId, layout: 'grid' }] }
: actual.parseJournalMarkdown(md);
},
};
});
vi.mock('../proofSessions', () => ({
listSessions: async () => [
{ albumId: 'album-proof', expiresOn: undefined },
{ albumId: 'album-expired', expiresOn: '2000-01-01' },
],
isExpired: (s: { expiresOn?: string }) => s.expiresOn === '2000-01-01',
}));
vi.mock('fs/promises', () => ({
default: {
readFile: async () => '---\nportrait: portrait-1\n---\nBio',
stat: async () => ({ mtimeMs: state.mtime }),
},
}));
import { isPublishedAsset, resetPublishedAssetsForTest } from '../publishedAssets';
beforeEach(() => {
resetPublishedAssetsForTest();
state.builds = 0;
});
describe('isPublishedAsset', () => {
it.each([
['an album photo', 'a1'],
['an album thumbnail', 'thumb'],
['a homepage hero', 'hero-1'],
['an album hero', 'album-hero'],
['the about portrait', 'portrait-1'],
['a journal cover', 'j-cover'],
['a journal photo block', 'j-photo'],
["a journal album block's photo", 'j-album-1'],
['a draft entry photo (its page gate hides it)', 'draft-photo'],
["an inline essay's album block photo", 'essay-1'],
['a content page photo block (draft included)', 'page-photo'],
["a content page album block's photo", 'page-album-1'],
['a photo of an open proofing link', 'proof-1'],
])('serves %s', async (_label, id) => {
expect(await isPublishedAsset(id)).toBe(true);
});
it('refuses a photo of an expired proofing link', async () => {
expect(await isPublishedAsset('expired-1')).toBe(false);
});
it('refuses a photo only a content page map block names (maps do not render on pages)', async () => {
expect(await isPublishedAsset('page-map-photo')).toBe(false);
});
it.each([
['a photo of its album', 'offline-1'],
['its album thumbnail', 'offline-thumb'],
['its album hero', 'offline-hero'],
["an album block's photo in its inline essay", 'offline-essay-1'],
])('refuses %s once a subpage is offline', async (_label, id) => {
expect(await isPublishedAsset(id)).toBe(false);
});
it('refuses any other asset in the library', async () => {
expect(await isPublishedAsset('never-published')).toBe(false);
});
it('builds once and rebuilds on a miss at most every ten seconds', async () => {
await isPublishedAsset('a1');
await isPublishedAsset('a2');
expect(state.builds).toBe(1);
await isPublishedAsset('unknown-1'); // forced rebuild
await isPublishedAsset('unknown-2'); // throttled
expect(state.builds).toBe(2);
});
it('rebuilds at once when a content file changes, e.g. a revoked link', async () => {
expect(await isPublishedAsset('proof-1')).toBe(true);
delete state.proofAlbums['album-proof'].assets[0];
state.proofAlbums['album-proof'].assets = [];
state.mtime += 1; // proofing.json rewritten by the admin
try {
expect(await isPublishedAsset('proof-1')).toBe(false);
} finally {
state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }];
}
});
/**
* The files used to be checked at most every 2 s. A request in that window
* after a save was answered from the set before it — with `immutable`, so
* the browser kept the photo.
*/
it('sees a save made milliseconds after the previous check', async () => {
expect(await isPublishedAsset('proof-1')).toBe(true);
expect(await isPublishedAsset('a1')).toBe(true); // a check a moment ago
state.proofAlbums['album-proof'].assets = [];
state.mtime += 1;
try {
expect(await isPublishedAsset('proof-1')).toBe(false);
} finally {
state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }];
}
});
/**
* A request that finds a build already running joins it. When that build
* read the content before the save, its set must not answer for a request
* that saw the save.
*/
it('does not answer from a build that read the content before the save', async () => {
expect(await isPublishedAsset('proof-1')).toBe(true);
let release!: () => void;
state.gate = new Promise<void>((r) => (release = r));
const reached = new Promise<void>((r) => (state.onGate = r));
state.mtime += 1; // an unrelated save starts a rebuild…
const first = isPublishedAsset('a1');
await reached; // …which has read the proofing album and is waiting on Immich
state.proofAlbums['album-proof'].assets = [];
state.mtime += 1; // the link is revoked
const second = isPublishedAsset('proof-1');
state.gate = null;
state.onGate = null;
release();
try {
expect(await first).toBe(true);
expect(await second).toBe(false);
} finally {
state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }];
}
});
/**
* The first view of a newly published album: a burst of image requests for
* photos the set has never seen. Only the first one rebuilt; the rest hit
* the rebuild throttle and were refused while that rebuild was still
* running — 11 of 13 photos broken until a reload.
*/
describe('a burst of requests for a newly published album', () => {
const NEW = Array.from({ length: 13 }, (_, i) => ({ id: `fresh-${i}` }));
beforeEach(async () => {
await isPublishedAsset('a1'); // built, and checked a moment ago
});
afterEach(() => {
state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }];
});
it('serves every photo when the save changed a content file', async () => {
state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }, ...NEW];
state.mtime += 1; // gallery.yaml saved by the admin
const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id)));
expect(answers).toEqual(NEW.map(() => true));
});
it('serves every photo when only Immich changed (misses share one rebuild)', async () => {
state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }, ...NEW];
const before = state.builds;
const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id)));
expect(answers).toEqual(NEW.map(() => true));
expect(state.builds - before).toBe(1);
});
it('still refuses what the rebuild did not publish', async () => {
const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id)));
expect(answers).toEqual(NEW.map(() => false));
});
});
it('picks up an asset published since the last build', async () => {
await isPublishedAsset('a1');
state.albums['album-pub'].assets.push({ id: 'new-photo' });
try {
expect(await isPublishedAsset('new-photo')).toBe(true);
} finally {
state.albums['album-pub'].assets.pop();
}
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import { THEME_PRESETS } from '../config/theme';
/**
* The download refusal page cannot load the site's stylesheets, so it carries
* each preset's surface and text colours itself. This keeps that copy honest:
* it resolves the same custom properties from app/tokens.css the way the
* cascade does, and fails when a preset changes a colour the copy still has.
*/
vi.mock('../immich', () => ({ immich: {} }));
import { REFUSAL_PALETTES } from '../zipArchive';
const css = fs
.readFileSync(path.join(__dirname, '../../app/tokens.css'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '');
/** Custom properties declared by the rule with exactly this selector. */
function block(selector: string): Record<string, string> {
const out: Record<string, string> = {};
for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
if (m[1].trim() !== selector) continue;
for (const d of m[2].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) out[d[1]] = d[2].trim();
}
return out;
}
/** What `<html data-preset=… data-theme=…>` resolves, in cascade order. */
function resolve(preset: string, mode: 'dark' | 'light') {
const light = mode === 'light';
const vars = {
...block(':root'),
...(light ? block("[data-theme='light']") : {}),
...block(`[data-preset='${preset}']`),
...(light ? block(`[data-preset='${preset}'][data-theme='light']`) : {}),
};
return {
bg: vars['--bg-primary'],
text: vars['--text-primary'],
muted: vars['--text-secondary'],
};
}
describe('refusal page palette', () => {
const cases = Object.keys(THEME_PRESETS).flatMap((preset) =>
(['dark', 'light'] as const).map((mode) => [preset, mode] as const),
);
it.each(cases)('%s in %s mode matches app/tokens.css', (preset, mode) => {
const palette = REFUSAL_PALETTES[preset] ?? REFUSAL_PALETTES.default;
expect(palette[mode]).toEqual(resolve(preset, mode));
});
it('no theme stylesheet redefines the colours the copy is taken from', () => {
const dir = path.join(__dirname, '../../app/themes');
for (const file of fs.readdirSync(dir)) {
const text = fs.readFileSync(path.join(dir, file), 'utf8');
expect(text, file).not.toMatch(/--(bg-primary|text-primary|text-secondary)\s*:/);
}
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { validateSettingValues } from '../config/settingValues';
import { resolveTheme } from '../config/theme';
import { normaliseSiteUrl } from '../siteUrl';
const fields = (input: unknown) => validateSettingValues(input).map((e) => e.field);
/**
* The save route and the doctor refuse exactly what the site would ignore, no
* more (QA A-14). Each case is paired with the resolver it mirrors, so the two
* cannot drift apart unnoticed.
*/
describe('validateSettingValues', () => {
it('passes an empty or partial file', () => {
expect(fields({})).toEqual([]);
expect(fields({ title: 'Folio' })).toEqual([]);
expect(fields(null)).toEqual([]);
});
it('refuses an accent resolveTheme would replace, and only that', () => {
for (const accent of ['rot', 'red', 'rgb(230 0 18)', '#12345']) {
expect(fields({ theme: { accent } })).toEqual(['theme.accent']);
expect(resolveTheme({ preset: 'minimal', accent }).accent).toBe(
resolveTheme('minimal').accent,
);
}
for (const accent of ['#e60012', '#FFF', ' #e60012 ', '#e60012cc', '']) {
expect(fields({ theme: { accent } })).toEqual([]);
}
// A preset name alone carries no accent.
expect(fields({ theme: 'minimal' })).toEqual([]);
});
it('refuses a site URL normaliseSiteUrl cannot use', () => {
for (const url of ['folio.example', 'ftp://folio.example', 'javascript:alert(1)']) {
expect(fields({ url })).toEqual(['url']);
expect(normaliseSiteUrl(url)).toBeNull();
}
for (const url of ['https://folio.example', 'http://folio.example/sub/', '', ' ']) {
expect(fields({ url })).toEqual([]);
}
});
it('refuses grid values outside the bounds getConfig clamps to', () => {
expect(fields({ grid: { columns: 9 } })).toEqual(['grid.columns']);
expect(fields({ grid: { columns: 0 } })).toEqual(['grid.columns']);
expect(fields({ grid: { columns: 2.5 } })).toEqual(['grid.columns']);
expect(fields({ grid: { gap: 49 } })).toEqual(['grid.gap']);
expect(fields({ grid: { gap: -1 } })).toEqual(['grid.gap']);
expect(fields({ grid: { columns: 1, gap: 0 } })).toEqual([]);
expect(fields({ grid: { columns: 6, gap: 48 } })).toEqual([]);
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { siteNavLinks, type SiteNavInput } from '../siteNav';
import { getDictionary } from '../i18n';
const t = getDictionary('en');
const base: SiteNavInput = {
menu: [
{ key: 'sp-travel', href: '/travel', label: 'Travel' },
{ key: 'page-prices', href: '/prices', label: 'Prices' },
{ key: 'sp-people', href: '/people', label: 'People' },
],
subpageAlbumCounts: new Map([
['travel', 3],
['people', 1],
]),
albums: [{ id: 'a1', slug: 'birds', albumName: 'Birds', assetCount: 12 }],
hasJournal: true,
aboutEnabled: true,
map: true,
};
describe('siteNavLinks', () => {
it('orders subpages and pages, albums, Journal, About, Map (#696)', () => {
expect(siteNavLinks(base, t).map((l) => l.href)).toEqual([
'/travel',
'/prices',
'/people',
'/birds',
'/journal',
'/about',
'/map',
]);
});
it('leaves out Journal, About and Map when they are off', () => {
const links = siteNavLinks({ ...base, hasJournal: false, aboutEnabled: false, map: false }, t);
expect(links.map((l) => l.href)).not.toContain('/about');
expect(links.map((l) => l.href)).not.toContain('/journal');
expect(links.map((l) => l.href)).not.toContain('/map');
});
it('attaches counts to subpages and albums only', () => {
const byHref = new Map(siteNavLinks(base, t).map((l) => [l.href, l.count]));
expect(byHref.get('/travel')).toBe('3 albums');
expect(byHref.get('/people')).toBe('1 album');
expect(byHref.get('/birds')).toBe('12 photos');
expect(byHref.get('/prices')).toBeUndefined();
expect(byHref.get('/about')).toBeUndefined();
});
it('is empty for a site with nothing to link', () => {
expect(
siteNavLinks({ menu: [], albums: [], hasJournal: false, aboutEnabled: false, map: false }, t),
).toEqual([]);
});
});

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/lib/config', () => ({
getConfig: () => ({
subpages: [],
standaloneAlbums: [],
aboutEnabled: false,
map: false,
}),
}));
vi.mock('@/lib/immich', () => ({
immich: { getAlbums: async () => [] },
}));
vi.mock('@/lib/auth', () => ({
isProtected: () => false,
isSiteLocked: () => false,
}));
vi.mock('@/lib/admin/journal-service', () => ({
listJournalEntries: async () => [
{ slug: 'open', frontmatter: { title: 'Open' } },
{ slug: 'locked', frontmatter: { title: 'Locked', password: 'scrypt:salt:hash' } },
{ slug: 'wip', frontmatter: { title: 'Draft', draft: true } },
],
}));
vi.mock('@/lib/admin/pages-service', () => ({
listPages: async () => [],
}));
import { buildSiteShape } from '@/lib/siteShape';
import { publicPaths } from '@/lib/publicPages';
describe('buildSiteShape → publicPaths (the sitemap)', () => {
it('lists a public journal entry', async () => {
expect(publicPaths(await buildSiteShape())).toContain('/journal/open');
});
it('leaves out a password-protected journal entry, as /journal does', async () => {
expect(publicPaths(await buildSiteShape())).not.toContain('/journal/locked');
});
it('leaves out a journal draft', async () => {
expect(publicPaths(await buildSiteShape())).not.toContain('/journal/wip');
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { isValidSlug, sanitizeSlug, slugFieldValue } from '../journal';
/** What a controlled slug input shows after each keystroke of `text`. */
function typeInto(text: string): string {
let value = '';
for (const ch of text) value = slugFieldValue(value + ch);
return value;
}
describe('slugFieldValue — a slug field while it is typed', () => {
it('lets a hyphen be typed between two words', () => {
// sanitizeSlug() on every keystroke dropped the trailing "-", so the next
// letter was glued on: "about-us" came out as "aboutus".
expect(typeInto('about-us')).toBe('about-us');
expect(typeInto('about us')).toBe('about-us');
});
it('can be emptied instead of jumping to "untitled"', () => {
expect(slugFieldValue('')).toBe('');
});
it('still refuses what a slug cannot hold', () => {
expect(slugFieldValue('Preise & Pakete!')).toBe('preise-pakete-');
expect(slugFieldValue('--a//b')).toBe('a-b');
});
it('becomes the stored slug through sanitizeSlug()', () => {
for (const input of ['about-', 'About Us ', ' a--b ', 'x_y']) {
const finished = sanitizeSlug(slugFieldValue(input));
expect(finished).toBe(sanitizeSlug(input));
expect(isValidSlug(finished)).toBe(true);
}
});
});
describe('sanitizeSlug — accented letters', () => {
it('keeps the base letter instead of splitting the word', () => {
// "für" used to become "f-r": every non-ASCII letter turned into a hyphen.
expect(sanitizeSlug('Preise für Hochzeiten')).toBe('preise-fur-hochzeiten');
expect(sanitizeSlug('Über mich')).toBe('uber-mich');
expect(sanitizeSlug('Café Olé')).toBe('cafe-ole');
expect(sanitizeSlug('Straße')).toBe('strasse');
});
it('agrees with the slug field', () => {
expect(typeInto('Über mich')).toBe('uber-mich');
});
it('still falls back for a title without any Latin letter', () => {
expect(sanitizeSlug('家族')).toBe('untitled');
});
});

View File

@@ -90,7 +90,7 @@ describe('writeYamlFile backup failure', () => {
beforeEach(() => vi.clearAllMocks());
it('still saves a brand-new file with no backup', async () => {
await expect(writeGalleryYaml(gallery)).resolves.toBeUndefined();
await expect(writeGalleryYaml(gallery)).resolves.toMatch(/^[0-9a-f]{32}$/);
expect(fs.copyFile).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
/**
* Resource bounds of the ZIP-of-originals stream: how many archives one client
* may hold open at once, that every way an archive can end gives its slot
* back, and that a client which stops reading loses the archive (and its
* upstream Immich body) instead of pinning both for as long as it likes.
*/
vi.mock('@/lib/immich', () => ({ immich: { streamAsset: vi.fn() } }));
vi.mock('@/lib/rate-limit', () => ({
getClientIp: vi.fn((req: NextRequest) => req.headers.get('x-test-ip') ?? 'unknown'),
}));
import {
ARCHIVE_STALL_TIMEOUT_MS,
MAX_CONCURRENT_ARCHIVES,
__archivesInFlight,
withArchiveSlot,
} from '@/lib/zipArchive';
import { immich, type ImmichAsset } from '@/lib/immich';
const mockStream = immich.streamAsset as unknown as ReturnType<typeof vi.fn>;
const ASSETS = [
{ id: 'a1', type: 'IMAGE', originalFileName: 'a1.jpg' },
{ id: 'a2', type: 'IMAGE', originalFileName: 'a2.jpg' },
] as unknown as ImmichAsset[];
let ipSeq = 0;
/** A fresh client per test, so slots can never leak from one test into another. */
function freshIp(): string {
ipSeq++;
return `203.0.113.${ipSeq}`;
}
const req = (ip: string) =>
new NextRequest('http://localhost/api/download/tok/archive', { headers: { 'x-test-ip': ip } });
/** Start an archive of `assets` for `ip` through the slot wrapper. */
function start(ip: string, assets: ImmichAsset[] = ASSETS) {
return withArchiveSlot(req(ip), async (stream) => stream('Album', assets));
}
/**
* An Immich originals body that produces `chunks` × 64 KB on demand and records
* whether it was cancelled — i.e. whether the upstream socket was let go.
*/
function upstream(chunks: number) {
const state = { pulled: 0, cancelled: false };
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (state.pulled >= chunks) {
controller.close();
return;
}
state.pulled++;
controller.enqueue(new Uint8Array(64 * 1024).fill(state.pulled));
},
cancel() {
state.cancelled = true;
},
});
return { state, result: { stream, contentType: 'image/jpeg', contentLength: null } };
}
beforeEach(() => {
mockStream.mockReset();
mockStream.mockImplementation(async () => upstream(2).result);
});
afterEach(() => {
vi.useRealTimers();
});
describe('archive concurrency cap', () => {
it(`refuses the ${MAX_CONCURRENT_ARCHIVES + 1}th open archive of one client with a 429`, async () => {
const ip = freshIp();
const open = [];
for (let i = 0; i < MAX_CONCURRENT_ARCHIVES; i++) {
const res = await start(ip);
expect(res.status).toBe(200);
open.push(res);
}
expect(__archivesInFlight(ip)).toBe(MAX_CONCURRENT_ARCHIVES);
const handler = vi.fn();
const refused = await withArchiveSlot(req(ip), handler);
expect(refused.status).toBe(429);
expect(Number(refused.headers.get('retry-after'))).toBeGreaterThan(0);
// Refused before the handler, so before any Immich request.
expect(handler).not.toHaveBeenCalled();
// Another client is unaffected.
const other = freshIp();
const res = await start(other);
expect(res.status).toBe(200);
await Promise.all([...open, res].map((r) => r.body!.cancel()));
});
it('gives the slot back when the archive is read to the end', async () => {
const ip = freshIp();
const res = await start(ip);
const bytes = new Uint8Array(await res.arrayBuffer());
expect(bytes[0]).toBe(0x50); // 'P'
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
});
it('gives the slot back when the archive fails', async () => {
const ip = freshIp();
mockStream.mockRejectedValue(new Error('Immich went away'));
vi.spyOn(console, 'error').mockImplementation(() => {});
const res = await start(ip);
await expect(res.arrayBuffer()).rejects.toThrow();
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
});
it('gives the slot back, and lets go of Immich, when the download is cancelled', async () => {
const ip = freshIp();
const origin = upstream(1000);
mockStream.mockResolvedValue(origin.result);
const res = await start(ip);
const reader = res.body!.getReader();
await reader.read();
await reader.cancel();
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
await vi.waitFor(() => expect(origin.state.cancelled).toBe(true));
});
it('gives the slot back when the route refuses or throws instead of streaming', async () => {
const ip = freshIp();
const refused = await withArchiveSlot(req(ip), async () => new Response(null, { status: 404 }));
expect(refused.status).toBe(404);
expect(__archivesInFlight(ip)).toBe(0);
await expect(
withArchiveSlot(req(ip), async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect(__archivesInFlight(ip)).toBe(0);
});
it('never releases a slot twice', async () => {
const ip = freshIp();
const first = await start(ip);
const second = await start(ip);
await first.arrayBuffer();
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(1));
// A late cancel on the finished body must not free the other archive's slot.
await first.body?.cancel().catch(() => {});
expect(__archivesInFlight(ip)).toBe(1);
await second.body!.cancel();
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
});
});
describe('archive stall timeout', () => {
it('tears down the archive and the upstream body when the client stops reading', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const ip = freshIp();
const origin = upstream(1000);
mockStream.mockResolvedValue(origin.result);
const res = await start(ip);
const reader = res.body!.getReader();
await reader.read();
// Still within the window: nothing happens.
await vi.advanceTimersByTimeAsync(ARCHIVE_STALL_TIMEOUT_MS - 1_000);
expect(origin.state.cancelled).toBe(false);
expect(__archivesInFlight(ip)).toBe(1);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => expect(origin.state.cancelled).toBe(true));
expect(__archivesInFlight(ip)).toBe(0);
// The response is ended with an error rather than left open. The one chunk
// queued before the stall may still be handed out first.
await expect(
(async () => {
for (;;) if ((await reader.read()).done) return;
})(),
).rejects.toThrow(/stalled/);
});
it('does not cut a slow download that keeps reading', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const ip = freshIp();
const origin = upstream(128);
mockStream.mockResolvedValue(origin.result);
const res = await start(ip, [ASSETS[0]]);
const reader = res.body!.getReader();
let bytes = 0;
let elapsed = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
// Well under the stall timeout per read, far over it in total.
await vi.advanceTimersByTimeAsync(ARCHIVE_STALL_TIMEOUT_MS / 2);
elapsed += ARCHIVE_STALL_TIMEOUT_MS / 2;
}
expect(elapsed).toBeGreaterThan(ARCHIVE_STALL_TIMEOUT_MS * 3);
expect(bytes).toBeGreaterThan(128 * 64 * 1024);
expect(origin.state.cancelled).toBe(false);
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
});
});