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:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user