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>
129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
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', () => ({
|
|
env: {
|
|
IMMICH_API_URL: 'http://localhost:2283',
|
|
IMMICH_API_KEY: 'test-key',
|
|
SITE_TITLE: 'Test Gallery',
|
|
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',
|
|
}));
|
|
|
|
// 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';
|
|
|
|
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 notice the file changed instead of short-circuiting
|
|
// on a stale in-memory copy.
|
|
expect(getConfig().siteTitle).toBe('Old 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');
|
|
});
|
|
});
|