release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

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>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -14,18 +14,28 @@ vi.mock('@/lib/admin/auth', () => ({
COOKIE_NAME: 'folio_admin_session',
}));
const baseConfig = {
needsCredentials: true,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
albums: [] as string[],
standaloneAlbums: [] as string[],
albumOverrides: {} as Record<string, string>,
subpages: [] as Array<{ name: string; albumIds: string[] }>,
albumPasswords: {},
sitePassword: '',
immich: { apiUrl: '', apiKey: '' },
immichTimeoutMs: 1000,
};
const getConfigMock = vi.fn(() => baseConfig);
vi.mock('@/lib/config', () => ({
getConfig: () => ({
needsCredentials: true,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
albums: [],
subpages: [],
albumPasswords: {},
sitePassword: '',
immich: { apiUrl: '', apiKey: '' },
immichTimeoutMs: 1000,
}),
getConfig: () => getConfigMock(),
slugify: (name: string) =>
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''),
}));
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
@@ -89,3 +99,119 @@ describe('GET /api/admin/doctor', () => {
expect(proxy.level).toBe('warn');
});
});
/**
* `if (albums.length)` used to skip the album check entirely when Immich
* answered with an empty list — an API key regenerated under a different
* account, say — so every connection check passed while every album page was
* silently empty (#629).
*/
describe('GET /api/admin/doctor — album checks', () => {
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
function withCredentialsAndAlbums(albums: string[]) {
getConfigMock.mockReturnValueOnce({
...baseConfig,
needsCredentials: false,
albums,
immich: { apiUrl: 'https://immich.example', apiKey: 'key' },
});
}
it('reports every configured album as missing when Immich returns an empty list', async () => {
withCredentialsAndAlbums([ALBUM_ID]);
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url.includes('/albums')) return { ok: true, json: async () => [] } as Response;
return { ok: true, json: async () => ({}) } as Response;
}),
);
const body = await (await GET(req())).json();
const albumFinding = body.findings.find((f: { id: string }) => f.id === 'album-ids');
expect(albumFinding?.level).toBe('error');
expect(albumFinding?.title).toContain('1 of 1');
vi.unstubAllGlobals();
});
it('does not reject a non-array album response outright', async () => {
withCredentialsAndAlbums([ALBUM_ID]);
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url.includes('/albums')) return { ok: true, json: async () => ({}) } as Response;
return { ok: true, json: async () => ({}) } as Response;
}),
);
const body = await (await GET(req())).json();
const albumFinding = body.findings.find((f: { id: string }) => f.id === 'album-ids');
// A non-array body is treated the same as an empty list, not left uncast
// and unchecked.
expect(albumFinding?.level).toBe('error');
vi.unstubAllGlobals();
});
});
/**
* Two albums that slugify to the same URL leave the second unreachable and
* its settings resolved from the first (#632).
*/
describe('GET /api/admin/doctor — album slug collisions', () => {
const knownAlbums = [
{ id: 'a', albumName: 'Japan Trip' },
{ id: 'b', albumName: 'Japan-Trip' },
];
function withCredentialsAndAlbums(config: Partial<typeof baseConfig>) {
getConfigMock.mockReturnValueOnce({
...baseConfig,
needsCredentials: false,
immich: { apiUrl: 'https://immich.example', apiKey: 'key' },
...config,
});
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url.includes('/albums')) return { ok: true, json: async () => knownAlbums } as Response;
return { ok: true, json: async () => ({}) } as Response;
}),
);
}
it('flags two standalone albums whose Immich names slugify alike', async () => {
withCredentialsAndAlbums({ albums: ['a', 'b'], standaloneAlbums: ['a', 'b'] });
const body = await (await GET(req())).json();
const finding = body.findings.find((f: { id: string }) => f.id === 'album-slugs');
expect(finding?.level).toBe('error');
expect(finding?.detail).toContain('Japan Trip');
expect(finding?.detail).toContain('Japan-Trip');
vi.unstubAllGlobals();
});
it('is quiet when the same names live in different subpages', async () => {
withCredentialsAndAlbums({
albums: ['a', 'b'],
standaloneAlbums: [],
subpages: [
{ name: 'Trips A', albumIds: ['a'] },
{ name: 'Trips B', albumIds: ['b'] },
],
});
const body = await (await GET(req())).json();
const finding = body.findings.find((f: { id: string }) => f.id === 'album-slugs');
expect(finding?.level).toBe('ok');
vi.unstubAllGlobals();
});
});