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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user