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:
53
lib/__tests__/asset-selection.test.ts
Normal file
53
lib/__tests__/asset-selection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
71
lib/__tests__/auth-page.test.ts
Normal file
71
lib/__tests__/auth-page.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
59
lib/__tests__/config-default-title.test.ts
Normal file
59
lib/__tests__/config-default-title.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
35
lib/__tests__/config-memo-tracing.test.ts
Normal file
35
lib/__tests__/config-memo-tracing.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
194
lib/__tests__/content-version.test.ts
Normal file
194
lib/__tests__/content-version.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
93
lib/__tests__/content-write-guard.test.ts
Normal file
93
lib/__tests__/content-write-guard.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
63
lib/__tests__/envLocks.test.ts
Normal file
63
lib/__tests__/envLocks.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
73
lib/__tests__/image-url-per-tier.test.ts
Normal file
73
lib/__tests__/image-url-per-tier.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
230
lib/__tests__/immich-follows-content.test.ts
Normal file
230
lib/__tests__/immich-follows-content.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
66
lib/__tests__/journal-order.test.ts
Normal file
66
lib/__tests__/journal-order.test.ts
Normal 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']);
|
||||
},
|
||||
);
|
||||
});
|
||||
268
lib/__tests__/journal-photo-caption.test.ts
Normal file
268
lib/__tests__/journal-photo-caption.test.ts
Normal 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>',
|
||||
'',
|
||||
];
|
||||
|
||||
describe('photo captions with parentheses (A-1)', () => {
|
||||
it('keeps a wide photo with `(560 m)` in its caption a photo block', () => {
|
||||
const source = `)`;
|
||||
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 = `, 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 = `)`;
|
||||
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(`)`);
|
||||
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 = `, 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 = ``;
|
||||
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}]()`,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
`![:wide]()`,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
];
|
||||
|
||||
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(`\n)`);
|
||||
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`);
|
||||
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 `);
|
||||
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(` 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(` 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: `);
|
||||
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 oben)`);
|
||||
expect(words).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
51
lib/__tests__/journalDate.test.ts
Normal file
51
lib/__tests__/journalDate.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
97
lib/__tests__/pages-service.test.ts
Normal file
97
lib/__tests__/pages-service.test.ts
Normal 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
155
lib/__tests__/pages.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
128
lib/__tests__/parsedFileCache.test.ts
Normal file
128
lib/__tests__/parsedFileCache.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
52
lib/__tests__/proofExport.test.ts
Normal file
52
lib/__tests__/proofExport.test.ts
Normal 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,"?'/);
|
||||
}
|
||||
});
|
||||
});
|
||||
198
lib/__tests__/proofSessions.test.ts
Normal file
198
lib/__tests__/proofSessions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
76
lib/__tests__/proofWebhook.test.ts
Normal file
76
lib/__tests__/proofWebhook.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }],
|
||||
}),
|
||||
|
||||
300
lib/__tests__/publishedAssets.test.ts
Normal file
300
lib/__tests__/publishedAssets.test.ts
Normal 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: '' },
|
||||
// Taken offline with `enabled: false`: its albums stay on the allowlist.
|
||||
{
|
||||
albumIds: ['album-offline'],
|
||||
enabled: false,
|
||||
essayText: '',
|
||||
},
|
||||
] 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
64
lib/__tests__/refusal-palette.test.ts
Normal file
64
lib/__tests__/refusal-palette.test.ts
Normal 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*:/);
|
||||
}
|
||||
});
|
||||
});
|
||||
53
lib/__tests__/settingValues.test.ts
Normal file
53
lib/__tests__/settingValues.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
57
lib/__tests__/siteNav.test.ts
Normal file
57
lib/__tests__/siteNav.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
48
lib/__tests__/siteShape.test.ts
Normal file
48
lib/__tests__/siteShape.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
53
lib/__tests__/slug-field.test.ts
Normal file
53
lib/__tests__/slug-field.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
213
lib/__tests__/zipArchive.test.ts
Normal file
213
lib/__tests__/zipArchive.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user