/** * ImmichService — credentials, settings, connection, browsing and the asset * proxy. * * The provider code came out of services/memories/ at ~61% branch coverage, * because that tree is outside the gate: the integration suites drive it over * HTTP and never reach most of the failure paths. These cases go at the service * directly with safeFetch stubbed, so every "upstream said no" branch is pinned. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); return { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null }; }); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {}, })); const { decryptMock, maybeEncryptMock } = vi.hoisted(() => ({ decryptMock: vi.fn((v: string) => v), maybeEncryptMock: vi.fn((v: string) => v), })); vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ decrypt_api_key: decryptMock, encrypt_api_key: (v: string) => v, maybe_encrypt_api_key: maybeEncryptMock, })); const { safeFetch, checkSsrf } = vi.hoisted(() => ({ safeFetch: vi.fn(), checkSsrf: vi.fn() })); vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetch, checkSsrf, createPinnedDispatcher: vi.fn(() => ({})), SsrfBlockedError: class extends Error {}, })); import { db as testDb } from '../../../src/db/database'; import { ImmichService } from '../../../src/nest/memories/immich.service'; import type { AuditService } from '../../../src/nest/audit/audit.service'; import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service'; import fs from 'node:fs'; import path from 'node:path'; import { makeStorageFixture } from '../../helpers/storage-fixture'; import { createTestUnitOfWork, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; import { deleteRows, upsertRow } from '../../helpers/factories/rows'; import { readUser } from '../../helpers/factories/users'; import { Users } from '../../../src/db/entities/Users.entity'; import { PROVIDER_SELECT_ALL_MAX_PAGES } from '@trek/shared'; const audit = { writeAudit: vi.fn() }; const access = { getAlbumIdFromLink: vi.fn() }; const journeyFx = makeStorageFixture('journey/'); let svc: ImmichService; const USER = 1; async function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0, allowInsecureTls = 0): Promise { await upsertRow(await sharedTestOrm(testDb), Users, { id, username: `u${id}`, email: `u${id}@example.test`, password_hash: 'x', immich_url: url, immich_api_key: key, immich_auto_upload: autoUpload, immich_allow_insecure_tls: allowInsecureTls, }); } /** The user's stored Immich URL and TLS switch, as the settings page round-trips them. */ async function storedConnection(id: number): Promise<{ immich_url: string | null | undefined; immich_allow_insecure_tls: number }> { const row = await readUser(await sharedTestOrm(testDb), id); return { immich_url: row.immich_url, immich_allow_insecure_tls: row.immich_allow_insecure_tls }; } /** A Response-ish object with only what the service reads. */ function upstream(opts: { ok?: boolean; status?: number; json?: unknown; url?: string; contentType?: string | null; body?: string }) { return { ok: opts.ok ?? true, status: opts.status ?? 200, url: opts.url ?? '', headers: { get: (h: string) => (h === 'content-type' ? (opts.contentType ?? 'image/jpeg') : null) }, json: async () => opts.json ?? {}, arrayBuffer: async () => Buffer.from(opts.body ?? 'bytes'), }; } beforeAll(async () => { const users = await createTestUsersRepo(testDb); svc = new ImmichService(audit as unknown as AuditService, access as unknown as MemoriesAccessService, journeyFx.storage, users, await createTestUnitOfWork(testDb)); }); beforeEach(async () => { vi.clearAllMocks(); decryptMock.mockImplementation((v: string) => v); maybeEncryptMock.mockImplementation((v: string) => v); checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }); await deleteRows(await sharedTestOrm(testDb), Users); await seedUser(USER, 'https://immich.test', 'key-1'); }); afterAll(() => testDb.close()); describe('getImmichCredentials', () => { it('IMMICH-001: returns null when the user row is missing', async () => { expect(await svc.getImmichCredentials(999)).toBeNull(); }); it('IMMICH-002: returns null without a URL', async () => { await seedUser(2, null, 'key'); expect(await svc.getImmichCredentials(2)).toBeNull(); }); it('IMMICH-003: returns null without an API key', async () => { await seedUser(3, 'https://immich.test', null); expect(await svc.getImmichCredentials(3)).toBeNull(); }); it('IMMICH-004: returns null when the stored key cannot be decrypted', async () => { decryptMock.mockReturnValue(null); expect(await svc.getImmichCredentials(USER)).toBeNull(); }); it('IMMICH-005: returns the decrypted pair otherwise', async () => { expect(await svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1', allow_insecure_tls: false }); }); }); describe('isValidAssetId', () => { it('IMMICH-006: accepts UUID-ish ids and rejects traversal or over-long input', () => { expect(svc.isValidAssetId('abc-123_XYZ')).toBe(true); expect(svc.isValidAssetId('../etc/passwd')).toBe(false); expect(svc.isValidAssetId('a'.repeat(101))).toBe(false); }); }); describe('getConnectionSettings / setImmichAutoUpload', () => { it('IMMICH-007: reports connected with the URL when configured', async () => { expect(await svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false, allow_insecure_tls: false }); }); it('IMMICH-008: reports an empty URL and not connected when it is not', async () => { await seedUser(4, null, null); expect(await svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false, allow_insecure_tls: false }); }); it('IMMICH-009: surfaces the auto-upload flag both ways', async () => { await svc.setImmichAutoUpload(USER, true); expect((await svc.getConnectionSettings(USER)).auto_upload).toBe(true); await svc.setImmichAutoUpload(USER, false); expect((await svc.getConnectionSettings(USER)).auto_upload).toBe(false); }); }); describe('saveImmichSettings', () => { it('IMMICH-010: refuses a URL the SSRF guard blocks, and writes nothing', async () => { checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' }); const result = await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null); expect(result).toEqual({ success: false, error: 'Invalid Immich URL: blocked host' }); expect((await svc.getImmichCredentials(USER))!.immich_url).toBe('https://immich.test'); }); it('IMMICH-011: stores a trimmed URL and reports plain success', async () => { const result = await svc.saveImmichSettings(USER, ' https://new.test ', 'k2', null); expect(result).toEqual({ success: true }); expect(await svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2', allow_insecure_tls: false }); }); it('IMMICH-012: warns and audits when the URL resolves to a private IP', async () => { checkSsrf.mockResolvedValue({ allowed: true, isPrivate: true, resolvedIp: '192.168.1.9' }); const result = await svc.saveImmichSettings(USER, 'http://nas.local', 'k', '5.6.7.8'); expect(result.success).toBe(true); expect(result.warning).toContain('192.168.1.9'); expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'immich.private_ip_configured', ip: '5.6.7.8' })); }); it('IMMICH-013: an empty URL clears the connection without an SSRF check', async () => { const result = await svc.saveImmichSettings(USER, undefined, undefined, null); expect(result).toEqual({ success: true }); expect(checkSsrf).not.toHaveBeenCalled(); expect(await svc.getImmichCredentials(USER)).toBeNull(); }); it('IMMICH-052: writes auto_upload with the connection, and keeps it when the flag is absent', async () => { const autoUpload = async () => (await readUser(await sharedTestOrm(testDb), USER)).immich_auto_upload; await svc.saveImmichSettings(USER, 'https://new.test', 'k2', null, undefined, true); expect(await autoUpload()).toBe(1); await svc.saveImmichSettings(USER, 'https://new.test', 'k2', null); expect(await autoUpload()).toBe(1); await svc.saveImmichSettings(USER, undefined, undefined, null, undefined, false); expect(await autoUpload()).toBe(0); }); it('IMMICH-053: a refused URL writes no auto_upload either', async () => { checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' }); await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null, undefined, true); expect((await readUser(await sharedTestOrm(testDb), USER)).immich_auto_upload).toBe(0); }); it('IMMICH-054: a failing auto_upload write rolls the new connection back with it', async () => { const spy = vi.spyOn(svc, 'setImmichAutoUpload').mockRejectedValueOnce(new Error('boom')); try { await expect(svc.saveImmichSettings(USER, 'https://new.test', 'k2', null, undefined, true)).rejects.toThrow('boom'); expect((await svc.getImmichCredentials(USER))!.immich_url).toBe('https://immich.test'); } finally { spy.mockRestore(); } }); // R6 — the encrypt call still runs; this file mocks `maybe_encrypt_api_key` // as identity everywhere else (so cases above can assert on the plaintext // round-trip), so this one case swaps in the REAL crypto for the duration // of a single call and reads the raw column back — proving // `UsersRepository.setImmichSettings` never bypasses the service's encrypt // step. `beforeEach` restores the identity stub for every other case. it('IMMICH-051 (R6): the stored immich_api_key is the encrypted envelope, never the plaintext', async () => { const real = await vi.importActual( '../../../src/nest/common/crypto/apiKeyCrypto', ); maybeEncryptMock.mockImplementation(real.maybe_encrypt_api_key); const plaintext = 'synthetic-test-immich-key-001'; await svc.saveImmichSettings(USER, 'https://immich.test', plaintext, null); const row = await readUser(await sharedTestOrm(testDb), USER); expect(row.immich_api_key).not.toBe(plaintext); expect(row.immich_api_key?.startsWith('enc:v1:')).toBe(true); }); }); describe('testConnection', () => { it('IMMICH-014: refuses a blocked URL before any request', async () => { checkSsrf.mockResolvedValue({ allowed: false, error: 'nope' }); expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'nope' }); expect(safeFetch).not.toHaveBeenCalled(); }); it('IMMICH-015: falls back to a generic message when the guard gives no reason', async () => { checkSsrf.mockResolvedValue({ allowed: false }); expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'Invalid Immich URL' }); }); it('IMMICH-016: reports the HTTP status when the server rejects the key', async () => { safeFetch.mockResolvedValue(upstream({ ok: false, status: 401 })); expect(await svc.testConnection('https://immich.test', 'bad')).toEqual({ connected: false, error: 'HTTP 401' }); }); it('IMMICH-017: returns the user on success', async () => { safeFetch.mockResolvedValue(upstream({ json: { name: 'Ada', email: 'ada@example.test' } })); const result = await svc.testConnection('https://immich.test', 'k'); expect(result.connected).toBe(true); expect(result.user).toEqual({ name: 'Ada', email: 'ada@example.test' }); }); it('IMMICH-018: reports the canonical URL after an http → https upgrade', async () => { safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://immich.test/api/users/me' })); const result = await svc.testConnection('http://immich.test', 'k'); expect(result.canonicalUrl).toBe('https://immich.test'); }); it('IMMICH-019: does NOT rewrite the URL when the host changed — that is a redirect elsewhere', async () => { safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://evil.test/api/users/me' })); const result = await svc.testConnection('http://immich.test', 'k'); expect(result.canonicalUrl).toBeUndefined(); }); it('IMMICH-020: reports the thrown message when the request fails', async () => { safeFetch.mockRejectedValue(new Error('ECONNREFUSED')); expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'ECONNREFUSED' }); }); it('IMMICH-021: falls back to "Connection failed" for a non-Error rejection', async () => { safeFetch.mockRejectedValue('boom'); expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'Connection failed' }); }); }); describe('getConnectionStatus', () => { it('IMMICH-022: says "Not configured" without credentials', async () => { await seedUser(5, null, null); expect(await svc.getConnectionStatus(5)).toEqual({ connected: false, error: 'Not configured' }); }); it('IMMICH-023: reports the HTTP status, the user, and a thrown failure', async () => { safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 500 })); expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'HTTP 500' }); safeFetch.mockResolvedValueOnce(upstream({ json: { name: 'Ada' } })); expect((await svc.getConnectionStatus(USER)).user).toEqual({ name: 'Ada', email: undefined }); safeFetch.mockRejectedValueOnce(new Error('timeout')); expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'timeout' }); }); }); describe('browseTimeline', () => { it('IMMICH-024: 400s without credentials', async () => { await seedUser(6, null, null); expect(await svc.browseTimeline(6)).toEqual({ error: 'Immich not configured', status: 400 }); }); it('IMMICH-025: forwards the upstream status on failure and the buckets on success', async () => { safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 503 })); expect(await svc.browseTimeline(USER)).toEqual({ error: 'Failed to fetch from Immich', status: 503 }); safeFetch.mockResolvedValueOnce(upstream({ json: [{ count: 3 }] })); expect(await svc.browseTimeline(USER)).toEqual({ buckets: [{ count: 3 }] }); }); it('IMMICH-026: maps an unreachable server to 502', async () => { safeFetch.mockRejectedValue(new Error('down')); expect(await svc.browseTimeline(USER)).toEqual({ error: 'Could not reach Immich', status: 502 }); }); }); /** * Three raw pages of a Sydney library, read newest first: the whole of 16 March * comes back before 15 March starts, which is what makes an answered page cost * more than one raw page once the day filter runs (#2336). */ const NEXT_DAY = [ { id: 'next-1', fileCreatedAt: '2026-03-16T01:00:00.000Z', localDateTime: '2026-03-16T12:00:00.000Z' }, { id: 'next-2', fileCreatedAt: '2026-03-16T00:30:00.000Z', localDateTime: '2026-03-16T11:30:00.000Z' }, ]; const DAY_HEAD = [ { id: 'day-1', fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' }, { id: 'day-2', fileCreatedAt: '2026-03-15T08:00:00.000Z', localDateTime: '2026-03-15T19:00:00.000Z' }, ]; const DAY_TAIL = [ { id: 'day-3', fileCreatedAt: '2026-03-15T07:00:00.000Z', localDateTime: '2026-03-15T18:00:00.000Z' }, { id: 'day-4', fileCreatedAt: '2026-03-15T06:00:00.000Z', localDateTime: '2026-03-15T17:00:00.000Z' }, ]; describe('searchPhotos', () => { it('IMMICH-027: 400s without credentials and 502s when the server is unreachable', async () => { await seedUser(7, null, null); expect(await svc.searchPhotos(7)).toEqual({ error: 'Immich not configured', status: 400 }); safeFetch.mockRejectedValue(new Error('down')); expect(await svc.searchPhotos(USER)).toEqual({ error: 'Could not reach Immich', status: 502 }); }); it('IMMICH-028: forwards the upstream status on a failed search', async () => { safeFetch.mockResolvedValue(upstream({ ok: false, status: 400 })); expect(await svc.searchPhotos(USER)).toEqual({ error: 'Search failed', status: 400 }); }); it('IMMICH-029: drops hidden assets an older server still returns (#1474)', async () => { safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [ { id: 'ok', fileCreatedAt: '2026-01-01' }, { id: 'hidden-flag', visibility: 'hidden' }, { id: 'legacy-flag', isVisible: false }, ] } }, })); const result = await svc.searchPhotos(USER); expect(result.assets!.map(a => a.id)).toEqual(['ok']); }); it('IMMICH-030: maps exif fields, falls back to createdAt, and marks videos', async () => { safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [ { id: 'a', createdAt: '2026-02-02', type: 'VIDEO', exifInfo: { city: 'Kyoto', country: 'JP', latitude: 35.0, longitude: 135.7 } }, { id: 'b', fileCreatedAt: '2026-03-03', type: 'IMAGE', exifInfo: { latitude: 'nope' } }, ] } }, })); // By id, not by position: the result is ordered newest first, so 'b' leads. const assets = (await svc.searchPhotos(USER)).assets!; const a = assets.find(x => x.id === 'a'); const b = assets.find(x => x.id === 'b'); expect(a).toMatchObject({ takenAt: '2026-02-02', city: 'Kyoto', country: 'JP', lat: 35.0, lng: 135.7, mediaType: 'video' }); // A non-numeric coordinate becomes null rather than reaching the client as a string. expect(b).toMatchObject({ takenAt: '2026-03-03', city: null, country: null, lat: null, lng: null, mediaType: 'image' }); }); it('IMMICH-031: hasMore counts the raw page, not the filtered one', async () => { // Otherwise a page that is entirely hidden assets would stop pagination dead. safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [{ id: 'x', visibility: 'hidden' }, { id: 'y', visibility: 'hidden' }] } }, })); const result = await svc.searchPhotos(USER, undefined, undefined, 1, 2); expect(result.assets).toEqual([]); expect(result.hasMore).toBe(true); }); it('IMMICH-032: pads the upstream date bounds by a day on each side', async () => { // The bounds filter on fileCreatedAt, a UTC instant, while from/to name // calendar days on a wall clock. No zone sits further than 14 hours from // UTC, so a day of slack each way reaches every asset that belongs to them; // the local-date filter below narrows the page again (#2336). safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [] } } })); await svc.searchPhotos(USER, '2026-01-01', '2026-01-31'); const body = JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body); expect(body.takenAfter).toBe('2025-12-31T00:00:00.000Z'); expect(body.takenBefore).toBe('2026-02-01T23:59:59.999Z'); // Load-bearing on Immich >= 1.133: hidden assets must not cross the wire. expect(body.visibility).toBe('timeline'); expect(body.order).toBe('desc'); }); it('IMMICH-032c: pads only the bound it was given', async () => { safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [] } } })); await svc.searchPhotos(USER, '2026-01-01'); const fromOnly = JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body); expect(fromOnly.takenAfter).toBe('2025-12-31T00:00:00.000Z'); expect(fromOnly.takenBefore).toBeUndefined(); await svc.searchPhotos(USER, undefined, '2026-01-31'); const toOnly = JSON.parse((safeFetch.mock.calls[1][1] as { body: string }).body); expect(toOnly.takenAfter).toBeUndefined(); expect(toOnly.takenBefore).toBe('2026-02-01T23:59:59.999Z'); }); it('IMMICH-032d: keeps the photos whose OWN local capture date is the day asked for', async () => { // A Sydney reader asking for the 15th: the 07:32 shot is 20:32Z on the 14th // and the UTC window used to miss it, while the next morning leaked in. safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [ { id: 'morning', fileCreatedAt: '2026-03-14T20:32:00.000Z', localDateTime: '2026-03-15T07:32:00.000Z' }, { id: 'evening', fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' }, { id: 'next-morning', fileCreatedAt: '2026-03-15T21:00:00.000Z', localDateTime: '2026-03-16T08:00:00.000Z' }, ] } }, })); const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15'); expect(result.assets!.map(a => a.id)).toEqual(['evening', 'morning']); expect(result.assets![1]).toMatchObject({ takenAt: '2026-03-14T20:32:00.000Z', localTakenAt: '2026-03-15T07:32:00.000Z' }); }); it('IMMICH-049: a server without localDateTime answers exactly as it did before', async () => { // An old or forked Immich: the fallback is the capture instant, so the day // is the UTC day the window has always searched, and the padding buys the // neighbouring days nothing. safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [ { id: 'day-before', fileCreatedAt: '2026-03-14T23:00:00.000Z' }, { id: 'in-day', fileCreatedAt: '2026-03-15T09:00:00.000Z' }, { id: 'day-after', fileCreatedAt: '2026-03-16T01:00:00.000Z' }, ] } }, })); const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15'); expect(result.assets!.map(a => a.id)).toEqual(['in-day']); expect(result.assets![0].localTakenAt).toBeNull(); }); it('IMMICH-050: fills the answered page from as many raw pages as the day filter costs', async () => { // `order: 'desc'` hands the padding day back first, so the first raw page of // a day whose neighbour was busy is entirely the wrong day. Answering that // page empty made the picker load nothing several times over, and made the // MCP tool report a day that has photos as having none (#2336). safeFetch .mockResolvedValueOnce(upstream({ json: { assets: { items: NEXT_DAY } } })) .mockResolvedValueOnce(upstream({ json: { assets: { items: DAY_HEAD } } })); const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1, 2); expect(result.assets!.map(a => a.id)).toEqual(['day-1', 'day-2']); expect(result.hasMore).toBe(true); expect(safeFetch).toHaveBeenCalledTimes(2); expect(JSON.parse((safeFetch.mock.calls[1][1] as { body: string }).body).page).toBe(2); }); it('IMMICH-050b: pages on without repeating an asset the page before already answered with', async () => { // The scan restarts at raw page 1 every call, so what it skips has to be // counted in assets that survived the filter rather than in raw rows. const page = (items: unknown[]) => upstream({ json: { assets: { items } } }); safeFetch .mockResolvedValueOnce(page(NEXT_DAY)) .mockResolvedValueOnce(page(DAY_HEAD)) .mockResolvedValueOnce(page(NEXT_DAY)) .mockResolvedValueOnce(page(DAY_HEAD)) .mockResolvedValueOnce(page(DAY_TAIL)); const first = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1, 2); const second = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 2, 2); expect(first.assets!.map(a => a.id)).toEqual(['day-1', 'day-2']); expect(second.assets!.map(a => a.id)).toEqual(['day-3', 'day-4']); }); it('IMMICH-050c: gives up after the page budget instead of scanning a library forever', async () => { // Every raw page is the padding day, so the answered page can never fill. // hasMore has to go false here: the next call would scan the same pages and // hand back the same empty page for ever. safeFetch.mockResolvedValue(upstream({ json: { assets: { items: NEXT_DAY } } })); const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1, 2); expect(result.assets).toEqual([]); expect(result.hasMore).toBe(false); // SEARCH_MAX_RAW_PAGES. expect(safeFetch).toHaveBeenCalledTimes(20); }); it('IMMICH-050e: a page past the twentieth still answers on a window full of the searched day', async () => { // The budget used to be 20 raw pages whatever page was asked for, and every // call restarts at raw page 1, so answered page 21 onwards was always empty // with hasMore false: a trip stopped at 1,000 photos without a word (#1587). safeFetch.mockImplementation(async (_url: string, init: { body: string }) => { const rawPage = JSON.parse(init.body).page as number; return upstream({ json: { assets: { items: [ { id: `d-${rawPage}-1`, fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' }, { id: `d-${rawPage}-2`, fileCreatedAt: '2026-03-15T08:00:00.000Z', localDateTime: '2026-03-15T19:00:00.000Z' }, ] } } }); }); const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 25, 2); expect(result.assets!.map(a => a.id)).toEqual(['d-25-1', 'd-25-2']); expect(result.hasMore).toBe(true); expect(safeFetch).toHaveBeenCalledTimes(25); }); it('IMMICH-050f: a deep page still stops once its own allowance of padding pages is spent', async () => { // Page 3 needs the two pages in front of it plus the same allowance page 1 // gets, and a window of nothing but the neighbouring day never fills it. safeFetch.mockResolvedValue(upstream({ json: { assets: { items: NEXT_DAY } } })); const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 3, 2); expect(result.assets).toEqual([]); expect(result.hasMore).toBe(false); expect(safeFetch).toHaveBeenCalledTimes(22); }); it('IMMICH-050g: a huge page stops at the absolute ceiling instead of walking the whole library', async () => { // page is not bounded by the routes, and the relative allowance alone would // have read a million raw pages here before answering. safeFetch.mockImplementation(async (_url: string, init: { body: string }) => { const rawPage = JSON.parse(init.body).page as number; return upstream({ json: { assets: { items: [ { id: `h-${rawPage}-1`, fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' }, { id: `h-${rawPage}-2`, fileCreatedAt: '2026-03-15T08:00:00.000Z', localDateTime: '2026-03-15T19:00:00.000Z' }, ] } } }); }); const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1_000_000, 2); expect(result.assets).toEqual([]); expect(result.hasMore).toBe(false); // The ceiling: the pages in front of the picker's deepest page plus its // allowance, 250 + 20. expect(safeFetch).toHaveBeenCalledTimes(270); }); it('IMMICH-050h: the deepest page the picker asks for still gets its full allowance under the ceiling', async () => { // The picker never asks a date-bounded search past page 251, by Select all // or by scrolling: 250 pages in front of it plus the allowance of 20. safeFetch.mockResolvedValue(upstream({ json: { assets: { items: NEXT_DAY } } })); const deepest = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', PROVIDER_SELECT_ALL_MAX_PAGES + 1, 2); expect(deepest.hasMore).toBe(false); expect(safeFetch).toHaveBeenCalledTimes(270); // One page further the ceiling already bites: the allowance shrinks by one. safeFetch.mockClear(); await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', PROVIDER_SELECT_ALL_MAX_PAGES + 2, 2); expect(safeFetch).toHaveBeenCalledTimes(270); }); it('IMMICH-050i: at the picker page size of 200 the deepest page it asks for answers in full', async () => { // A trip of more than 50,000 photos, every raw page full of the searched // day: page 251 is photos 50,001 to 50,200, read off raw page 251. safeFetch.mockImplementation(async (_url: string, init: { body: string }) => { const { page, size } = JSON.parse(init.body) as { page: number; size: number }; return upstream({ json: { assets: { items: Array.from({ length: size }, (_, i) => ({ id: `p-${page}-${i}`, fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z', })) } } }); }); const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', PROVIDER_SELECT_ALL_MAX_PAGES + 1, 200); expect(result.assets).toHaveLength(200); expect(result.assets!.every(a => a.id.startsWith('p-251-'))).toBe(true); expect(result.hasMore).toBe(true); expect(safeFetch).toHaveBeenCalledTimes(251); }); it('IMMICH-050d: an unfiltered browse still costs one round trip, at the page it was asked for', async () => { // Nothing narrows those pages, so a raw page and an answered page are the // same page and the scan must not start over at page 1. safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [{ id: 'x', visibility: 'hidden' }, { id: 'y', visibility: 'hidden' }] } }, })); const result = await svc.searchPhotos(USER, undefined, undefined, 3, 2); expect(safeFetch).toHaveBeenCalledTimes(1); expect(JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body).page).toBe(3); // Every row was hidden, and pagination still advances past that page. expect(result.assets).toEqual([]); expect(result.hasMore).toBe(true); }); it('IMMICH-032b: orders each page itself, so an unsorted page still lands chronological within itself', async () => { // Older builds drop the unknown property silently (whitelist: true without // forbidNonWhitelisted), which is exactly why the request cannot be trusted. safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [ { id: 'older', fileCreatedAt: '2026-03-01T09:00:00Z' }, { id: 'newest', fileCreatedAt: '2026-03-31T09:00:00Z' }, { id: 'middle', fileCreatedAt: '2026-03-15T09:00:00Z' }, ] } }, })); const result = await svc.searchPhotos(USER); expect(result.assets!.map(a => a.id)).toEqual(['newest', 'middle', 'older']); }); }); describe('getAssetInfo', () => { it('IMMICH-033: 404s when the owner has no credentials', async () => { await seedUser(8, null, null); expect(await svc.getAssetInfo(USER, 'a1', 8)).toEqual({ error: 'Not found', status: 404 }); }); it('IMMICH-034: reads the OWNER\'s credentials, not the requester\'s', async () => { await seedUser(9, 'https://owner.test', 'owner-key'); safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } })); await svc.getAssetInfo(USER, 'a1', 9); expect(safeFetch.mock.calls[0][0]).toContain('https://owner.test'); expect((safeFetch.mock.calls[0][1] as { headers: Record }).headers['x-api-key']).toBe('owner-key'); }); it('IMMICH-035: forwards the upstream status and maps a proxy failure to 502', async () => { safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 404 })); expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Failed', status: 404 }); safeFetch.mockRejectedValueOnce(new Error('down')); expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 }); }); it('IMMICH-036: composes camera/focal/aperture only when both halves are present', async () => { safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'a1', fileCreatedAt: 'x', originalFileName: 'IMG.jpg', exifInfo: { make: 'Fuji', model: 'X100V', focalLength: 23, fNumber: 2, iso: 200 } }, })); const full = (await svc.getAssetInfo(USER, 'a1')).data; expect(full).toMatchObject({ camera: 'Fuji X100V', focalLength: '23mm', aperture: 'f/2', iso: 200, fileName: 'IMG.jpg' }); safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'a2', exifInfo: { make: 'Fuji' } } })); const partial = (await svc.getAssetInfo(USER, 'a2')).data; expect(partial.camera).toBeNull(); expect(partial.focalLength).toBeNull(); }); it('IMMICH-037: survives an asset with no exif block at all', async () => { safeFetch.mockResolvedValue(upstream({ json: { id: 'bare' } })); const data = (await svc.getAssetInfo(USER, 'bare')).data; expect(data).toMatchObject({ id: 'bare', width: null, height: null, city: null, fileName: null }); }); it('IMMICH-037b: reports the media type here too, not only in the listing', async () => { // Same mapping as the listing, so the detail route and the picker agree on // what an asset is. Nothing persists it from here yet — trek_photos.media_type // comes from the add call's media_types. safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'clip', type: 'VIDEO' } })); expect((await svc.getAssetInfo(USER, 'clip')).data.mediaType).toBe('video'); safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'still', type: 'IMAGE' } })); expect((await svc.getAssetInfo(USER, 'still')).data.mediaType).toBe('image'); }); }); describe('getAlbumPhotos', () => { it('IMMICH-037c: 400s without credentials', async () => { await seedUser(11, null, null); expect(await svc.getAlbumPhotos(11, 'alb-1')).toEqual({ error: 'Immich not configured', status: 400 }); }); it('IMMICH-037d: orders an album newest first and keeps its coordinates and media type', async () => { // The v2 branch reads /api/albums/{id} directly and never passes a search, // so this ordering is the only one an album on that version ever gets. safeFetch.mockResolvedValue(upstream({ json: { assets: [ { id: 'older', fileCreatedAt: '2026-03-01T09:00:00Z', localDateTime: '2026-03-01T18:00:00.000Z', type: 'IMAGE', exifInfo: { latitude: 35.0, longitude: 135.7 } }, { id: 'clip', fileCreatedAt: '2026-03-31T09:00:00Z', type: 'VIDEO' }, ] }, })); const assets = (await svc.getAlbumPhotos(USER, 'alb-1')).assets!; expect(assets.map(a => a.id)).toEqual(['clip', 'older']); expect(assets[0].mediaType).toBe('video'); expect(assets[1]).toMatchObject({ lat: 35.0, lng: 135.7 }); // The album path carries the same local capture stamp the search path does, // so a photo picked out of an album lands under the day heading a search // would have given it instead of under the UTC one (#2336). A server that // predates the field leaves it null, and the readers fall back to takenAt. expect(assets[1].localTakenAt).toBe('2026-03-01T18:00:00.000Z'); expect(assets[0].localTakenAt).toBeNull(); }); }); describe('fetchImmichThumbnailBytes', () => { it('IMMICH-038: 404s without credentials for the owner', async () => { await seedUser(10, null, null); expect(await svc.fetchImmichThumbnailBytes(USER, 'a1', 10)).toEqual({ error: 'Not found', status: 404 }); }); it('IMMICH-039: returns the bytes with the upstream content type', async () => { safeFetch.mockResolvedValue(upstream({ contentType: 'image/webp', body: 'thumb' })); const result = await svc.fetchImmichThumbnailBytes(USER, 'a1'); expect(result).toEqual({ bytes: Buffer.from('thumb'), contentType: 'image/webp' }); }); it('IMMICH-040: defaults the content type when the server omits it', async () => { safeFetch.mockResolvedValue(upstream({ contentType: null, body: 'thumb' })); const result = await svc.fetchImmichThumbnailBytes(USER, 'a1') as { contentType: string }; expect(result.contentType).toBe('image/jpeg'); }); it('IMMICH-041: forwards the upstream status and maps a throw to 502', async () => { safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 403 })); expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Upstream error', status: 403 }); safeFetch.mockRejectedValueOnce(new Error('down')); expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 }); }); }); describe('streamImmichAsset', () => { function makeRes() { return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), end: vi.fn() }; } it('IMMICH-041b: answers 404 when the owner has no connection, instead of leaving the request open', async () => { // It used to return { error, status } and touch nothing. All four callers // ignored that return, so the client got no status line at all and waited // out its own timeout. Synology's counterpart always wrote; this matches. await seedUser(11, null, null); const res = makeRes(); await svc.streamImmichAsset(res as never, USER, 'a1', 'thumbnail', 11); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Not found' }); expect(safeFetch).not.toHaveBeenCalled(); }); it('IMMICH-041c: reads the connection of the OWNER, not of the viewer', async () => { // A shared album is proxied with the owner's key on the viewer's behalf, so // a viewer who happens to have Immich configured must not paper over an // owner who does not. await seedUser(USER, 'https://immich.example', 'viewer-key'); await seedUser(12, null, null); const res = makeRes(); await svc.streamImmichAsset(res as never, USER, 'a1', 'original', 12); expect(res.status).toHaveBeenCalledWith(404); expect(safeFetch).not.toHaveBeenCalled(); }); it('IMMICH-041d: proxies with the owner key once a connection exists', async () => { await seedUser(13, 'https://immich.example', 'owner-key'); safeFetch.mockResolvedValue(upstream({ contentType: 'image/jpeg', body: 'bytes' })); const res = makeRes(); await svc.streamImmichAsset(res as never, USER, 'a1', 'thumbnail', 13); expect(safeFetch).toHaveBeenCalledTimes(1); const [url, init] = safeFetch.mock.calls[0]; expect(url).toContain('/api/assets/a1/thumbnail'); expect((init.headers as Record)['x-api-key']).toBe('owner-key'); }); }); describe('collectAlbumSelection', () => { it('IMMICH-042: 404s when the album link does not resolve', async () => { access.getAlbumIdFromLink.mockReturnValue({ success: false }); expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Album link not found', status: 404 }); }); it('IMMICH-043: 400s when the user has no Immich credentials', async () => { access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' }); await seedUser(11, null, null); expect(await svc.collectAlbumSelection('1', 'l1', 11)).toEqual({ error: 'Immich not configured', status: 400 }); }); it('IMMICH-044: returns only visible image assets, with the raw total', async () => { access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' }); safeFetch.mockResolvedValue(upstream({ json: { assets: [ { id: 'img', type: 'IMAGE' }, { id: 'vid', type: 'VIDEO' }, { id: 'hidden', type: 'IMAGE', visibility: 'hidden' }, ] }, })); const result = await svc.collectAlbumSelection('1', 'l1', USER) as { selection: { asset_ids: string[] }; total: number }; expect(result.selection.asset_ids).toEqual(['img']); expect(result.total).toBe(1); }); it('IMMICH-045: maps an unreachable server to 502', async () => { access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' }); safeFetch.mockRejectedValue(new Error('down')); expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Could not reach Immich', status: 502 }); }); }); describe('uploadToImmich', () => { function writeJourneyObject(name: string, bytes: string): void { const dir = path.join(journeyFx.root, 'journey'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, name), bytes); } it('IMMICH-UP-001: returns null without credentials and never fetches', async () => { expect(await svc.uploadToImmich(999, 'journey/pic.jpg', 'pic.jpg')).toBeNull(); expect(safeFetch).not.toHaveBeenCalled(); }); it('IMMICH-UP-002: returns null for a path outside the journey category', async () => { await seedUser(USER, 'https://immich.test', 'key-1'); expect(await svc.uploadToImmich(USER, 'files/doc.pdf', 'doc.pdf')).toBeNull(); expect(safeFetch).not.toHaveBeenCalled(); }); it('IMMICH-UP-003: returns null when the object is missing (old existsSync guard)', async () => { await seedUser(USER, 'https://immich.test', 'key-1'); expect(await svc.uploadToImmich(USER, 'journey/gone.jpg', 'gone.jpg')).toBeNull(); expect(safeFetch).not.toHaveBeenCalled(); }); it('IMMICH-UP-004: posts the bytes as multipart and answers the created asset id', async () => { await seedUser(USER, 'https://immich.test', 'key-1'); writeJourneyObject('up.jpg', 'jpeg-bytes-here'); safeFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'immich-42' }) }); expect(await svc.uploadToImmich(USER, 'journey/up.jpg', 'up.jpg')).toBe('immich-42'); const [url, init] = safeFetch.mock.calls[0] as [string, { method: string; body: Buffer; headers: Record }]; expect(url).toBe('https://immich.test/api/assets'); expect(init.method).toBe('POST'); expect(init.headers['x-api-key']).toBe('key-1'); expect(init.body.includes(Buffer.from('jpeg-bytes-here'))).toBe(true); }); it('IMMICH-UP-005: a rejected upstream answers null, not a throw', async () => { await seedUser(USER, 'https://immich.test', 'key-1'); writeJourneyObject('rej.jpg', 'bytes'); safeFetch.mockResolvedValueOnce({ ok: false, status: 500 }); expect(await svc.uploadToImmich(USER, 'journey/rej.jpg', 'rej.jpg')).toBeNull(); }); }); /** * The self-signed switch (#2475). Every request to a user's Immich carries the * TLS options as safeFetch's third argument; these pin that no path was left * on the strict default and that the switch is read fail closed. */ describe('self-signed certificates', () => { const LAX = { rejectUnauthorized: false }; const STRICT = { rejectUnauthorized: true }; function makeRes() { return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), end: vi.fn(), headersSent: false }; } function writeJourneyObject(name: string, bytes: string): void { const dir = path.join(journeyFx.root, 'journey'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, name), bytes); } /** Drives every request the service makes against the user's own server. */ async function exerciseEveryPath(userId: number): Promise { access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' }); safeFetch.mockImplementation(async (url: string) => { if (url.endsWith('/api/albums/album-1')) return upstream({ json: {} }); if (url.includes('/api/search/metadata')) return upstream({ json: { assets: { items: [] } } }); if (url.includes('/api/albums')) return upstream({ json: [] }); if (url.endsWith('/api/assets')) return upstream({ json: { id: 'up-1' } }); return upstream({ json: {} }); }); writeJourneyObject('tls.jpg', 'bytes'); await svc.getConnectionStatus(userId); await svc.browseTimeline(userId); await svc.searchPhotos(userId); await svc.getAssetInfo(userId, 'a1'); await svc.fetchImmichThumbnailBytes(userId, 'a1'); await svc.streamImmichAsset(makeRes() as never, userId, 'a1', 'thumbnail'); await svc.listAlbums(userId); await svc.getAlbumPhotos(userId, 'album-1'); await svc.collectAlbumSelection('1', 'l1', userId); await svc.uploadToImmich(userId, 'journey/tls.jpg', 'tls.jpg'); } /** * status, timeline, search, info, thumbnail, stream, both album lists, then * the album detail and its v3 search twice (browse and sync), and the upload. */ const EVERY_PATH_CALLS = 13; it('IMMICH-TLS-001: only a stored 1 turns the switch on', async () => { await seedUser(USER, 'https://immich.test', 'key-1', 0, 1); expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(true); await seedUser(USER, 'https://immich.test', 'key-1', 0, 0); expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(false); // Anything the column should never hold reads as off, not as truthy. await seedUser(USER, 'https://immich.test', 'key-1', 0, 2); expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(false); }); it('IMMICH-TLS-002: with the switch on, every request to the server skips the certificate check', async () => { await seedUser(USER, 'https://immich.test', 'key-1', 0, 1); await exerciseEveryPath(USER); expect(safeFetch).toHaveBeenCalledTimes(EVERY_PATH_CALLS); for (const call of safeFetch.mock.calls) expect(call[2]).toMatchObject(LAX); }); it('IMMICH-TLS-003: with the switch off, every request keeps the certificate check', async () => { await exerciseEveryPath(USER); expect(safeFetch).toHaveBeenCalledTimes(EVERY_PATH_CALLS); for (const call of safeFetch.mock.calls) expect(call[2]).toMatchObject(STRICT); }); it('IMMICH-TLS-004: a shared photo follows the switch of its owner, not of the viewer', async () => { // The request goes to the owner's server with the owner's key, so the // owner decided whether that server's certificate is trusted. await seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 0); await seedUser(20, 'https://owner.test', 'owner-key', 0, 1); safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } })); await svc.getAssetInfo(USER, 'a1', 20); await svc.fetchImmichThumbnailBytes(USER, 'a1', 20); await svc.streamImmichAsset(makeRes() as never, USER, 'a1', 'original', 20); expect(safeFetch).toHaveBeenCalledTimes(3); for (const call of safeFetch.mock.calls) { expect(call[0]).toContain('https://owner.test'); expect(call[2]).toMatchObject(LAX); } safeFetch.mockClear(); await seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 1); await seedUser(20, 'https://owner.test', 'owner-key', 0, 0); await svc.getAssetInfo(USER, 'a1', 20); expect(safeFetch.mock.calls[0][2]).toMatchObject(STRICT); }); it('IMMICH-TLS-005: saving without the switch keeps it, saving with it sets it, disconnecting clears it', async () => { await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, true); expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true); // An older client does not send the field; its save must not turn the switch off. await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null); expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true); await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, false); expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(false); await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, true); await svc.saveImmichSettings(USER, undefined, undefined, null, true); expect(await storedConnection(USER)).toEqual({ immich_url: null, immich_allow_insecure_tls: 0 }); }); it('IMMICH-TLS-010: the switch trusts one server, so a new URL saved without it starts off', async () => { await seedUser(USER, 'https://nas.local', 'key-1', 0, 1); // Same server with a trailing slash: still the same connection, the switch stays. await svc.saveImmichSettings(USER, 'https://nas.local/', 'key-1', null); expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true); await svc.saveImmichSettings(USER, 'https://photos.example.com', 'key-2', null); expect(await storedConnection(USER)).toEqual({ immich_url: 'https://photos.example.com', immich_allow_insecure_tls: 0, }); // Sent along with the new URL, it holds for that server. await svc.saveImmichSettings(USER, 'https://other.example.com', 'key-3', null, true); expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true); }); it('IMMICH-TLS-006: a URL the guard refuses leaves the stored switch alone', async () => { await seedUser(USER, 'https://immich.test', 'key-1', 0, 1); checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' }); await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null, false); expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true); }); it('IMMICH-TLS-007: the connection test uses the switch it is handed, strict by default', async () => { safeFetch.mockResolvedValue(upstream({ json: { name: 'Ada' } })); await svc.testConnection('https://immich.test', 'k', true); await svc.testConnection('https://immich.test', 'k', false); await svc.testConnection('https://immich.test', 'k'); expect(safeFetch.mock.calls.map(call => call[2])).toEqual([LAX, STRICT, STRICT]); }); it('IMMICH-TLS-008: a refused certificate names itself instead of a bare "fetch failed"', async () => { // undici keeps the reason on `cause`; the settings card used to show only // the outer message, which left the user nothing to go on. const refused = () => new TypeError('fetch failed', { cause: new Error('self-signed certificate') }); safeFetch.mockRejectedValueOnce(refused()); expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'fetch failed (self-signed certificate)', }); safeFetch.mockRejectedValueOnce(refused()); expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'fetch failed (self-signed certificate)' }); }); it('IMMICH-TLS-009: the upload mirror gives up on a server that never answers', async () => { writeJourneyObject('slow.jpg', 'bytes'); safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'up-2' } })); await svc.uploadToImmich(USER, 'journey/slow.jpg', 'slow.jpg'); expect((safeFetch.mock.calls[0][1] as { signal?: AbortSignal }).signal).toBeInstanceOf(AbortSignal); }); });