Files
immich-folio/lib/__tests__/immich-follows-content.test.ts

231 lines
8.1 KiB
TypeScript
Raw Normal View History

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();
});
});