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

@@ -23,6 +23,15 @@ vi.mock('@/lib/env', () => ({
return process.env.INSTALL_CONTENT_DIR || '';
},
},
// Real implementation, not a stub: install.ts's own normalisation tests
// (#634) rely on this actually stripping trailing slashes.
normalizeApiUrl: (raw: string) => {
try {
return new URL(raw).toString().replace(/\/+$/, '');
} catch {
return '';
}
},
}));
/** Import fresh so the module-level install-file cache cannot leak between cases. */
@@ -103,6 +112,45 @@ describe('getInstallCredentials', () => {
adminPassword: '',
});
});
/**
* The environment path already strips a trailing slash (lib/env.ts); the
* file path used to store content/install.json's apiUrl completely
* unnormalised. `https://host/api/` then reached getConfig() as
* `.../api/`, which does not `endsWith('/api')`, so it became
* `.../api//api` and every request 404ed (#634). Normalising on every read
* means an install.json written before this fix heals without re-running
* the wizard.
*/
describe('heals an unnormalised URL already on disk (#634)', () => {
it.each([
['trailing slash, no /api', 'https://file-immich:2283/', 'https://file-immich:2283'],
[
'trailing slash, with /api',
'https://file-immich:2283/api/',
'https://file-immich:2283/api',
],
['no trailing slash, already clean', 'https://file-immich:2283', 'https://file-immich:2283'],
])('file path: %s', async (_name, stored, expected) => {
const { getInstallCredentials } = await loadInstall();
fs.writeFileSync(
path.join(dir, 'install.json'),
JSON.stringify({ apiUrl: stored, apiKey: 'file-key' }),
);
expect(getInstallCredentials().apiUrl).toBe(expected);
});
it.each([
['trailing slash, no /api', 'https://env-immich:2283/', 'https://env-immich:2283'],
['trailing slash, with /api', 'https://env-immich:2283/api/', 'https://env-immich:2283/api'],
])('env path: %s', async (_name, envValue, expected) => {
const { getInstallCredentials } = await loadInstall();
process.env.__T_INSTALL_URL = envValue;
expect(getInstallCredentials().apiUrl).toBe(expected);
});
});
});
describe('isInstalled', () => {
@@ -168,6 +216,23 @@ describe('completeInstall', () => {
expect(getInstallCredentials().authSecret).toBe(creds.authSecret);
});
/**
* The route verifies the connection against normalizeApiBase(apiUrl),
* which strips a trailing slash, but this used to write input.apiUrl.trim()
* unchanged — so a URL that passed the ping test was stored as typed and
* every request afterwards 404ed (#634).
*/
it.each([
['trailing slash, no /api', 'https://immich.example/', 'https://immich.example'],
['trailing slash, with /api', 'https://immich.example/api/', 'https://immich.example/api'],
])('normalises the stored apiUrl: %s', async (_name, typed, expected) => {
const { completeInstall } = await loadInstall();
await completeInstall({ apiUrl: typed, apiKey: 'api-key-123' });
const creds = JSON.parse(fs.readFileSync(path.join(dir, 'install.json'), 'utf8'));
expect(creds.apiUrl).toBe(expected);
});
it('writes an empty albums list when no albums are selected', async () => {
const { completeInstall } = await loadInstall();
await completeInstall({
@@ -202,6 +267,68 @@ describe('completeInstall', () => {
expect(isInstalled()).toBe(true);
});
/**
* isInstalled() is false whenever Immich credentials fail to resolve,
* whether or not gallery.yaml exists — a bad IMMICH_API_URL or a corrupt
* install.json is enough. Re-running the wizard after that used to replace
* every subpage, section, password, assetOrder and per-album override with
* a two-key skeleton (#627).
*/
it('does not overwrite an existing gallery.yaml', async () => {
const { completeInstall } = await loadInstall();
const galleryPath = path.join(dir, 'gallery.yaml');
const existing = 'albums:\n - existing-album\nsubpages:\n - slug: trips\n';
fs.writeFileSync(galleryPath, existing);
const result = await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
albums: [ALBUM_ID],
});
expect(result.galleryWritten).toBe(false);
expect(fs.readFileSync(galleryPath, 'utf8')).toBe(existing);
});
it('does not overwrite an existing settings.yaml', async () => {
const { completeInstall } = await loadInstall();
const settingsPath = path.join(dir, 'settings.yaml');
const existing = 'title: "Existing Site"\n';
fs.writeFileSync(settingsPath, existing);
const result = await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
siteTitle: 'New Title',
});
expect(result.settingsWritten).toBe(false);
expect(fs.readFileSync(settingsPath, 'utf8')).toBe(existing);
});
it('writes both files on a genuinely fresh install and reports it', async () => {
const { completeInstall } = await loadInstall();
const result = await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
});
expect(result).toEqual({ galleryWritten: true, settingsWritten: true });
});
it('still writes fresh credentials into install.json when gallery.yaml is kept', async () => {
const { completeInstall, getInstallCredentials } = await loadInstall();
fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'fresh-key',
});
expect(getInstallCredentials().apiKey).toBe('fresh-key');
});
});
describe('normalizeApiBase', () => {