/** * Immich-specific integration tests (IMMICH-030 – IMMICH-070). * Covers status, test-connection, browse, search, asset proxy, access control, * and albums — everything NOT covered by the existing immich.test.ts. * * safeFetch is mocked to return fake Immich API responses based on URL patterns. * No real HTTP calls are made. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; import type { INestApplication } from '@nestjs/common'; // ── Non-database hoisted state (kept separate from the db mock below) ──────── // Mutable fixture driving the fake Immich responses. Tests may override // `albumAssetPages` to exercise pagination, and read `searchCalls` to assert // on the request bodies TREK sends to POST /api/search/metadata. const immichState = vi.hoisted(() => ({ albumAssets: [] as any[], albumAssetPages: null as any[][] | null, searchCalls: [] as any[], /** Immich v2 embeds `assets` in the album detail body; v3 does not (#1492). */ albumDetailHasAssets: false, })); /** * Default contents of `album-uuid-1` as Immich returns them from * POST /api/search/metadata. Two visible assets plus two hidden ones (Live Photo * motion parts): `visibility: 'hidden'` is the modern marker, `isVisible: false` * the legacy one. Both must be filtered out of the picker (#1474). */ const DEFAULT_ALBUM_ASSETS = [ { id: 'asset-sync-1', type: 'IMAGE', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France', latitude: 48.8584, longitude: 2.2945 } }, { id: 'asset-sync-2', type: 'VIDEO', fileCreatedAt: '2024-06-02T10:00:00.000Z', exifInfo: { city: 'Lyon', country: 'France' } }, { id: 'asset-hidden', type: 'VIDEO', fileCreatedAt: '2024-06-03T10:00:00.000Z', visibility: 'hidden' }, { id: 'asset-legacy-hidden', type: 'VIDEO', fileCreatedAt: '2024-06-04T10:00:00.000Z', isVisible: false }, ]; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, SESSION_DURATION: '24h', SESSION_DURATION_MS: 86400000, SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // ── SSRF guard mock — routes all Immich API calls to fake responses ─────────── vi.mock('../../src/utils/ssrfGuard', async () => { const actual = await vi.importActual('../../src/utils/ssrfGuard'); function makeFakeImmichFetch(url: string, init?: any) { const u = typeof url === 'string' ? url : String(url); // /api/users/me — used by status + test-connection if (u.includes('/api/users/me')) { return Promise.resolve({ ok: true, status: 200, headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null }, json: () => Promise.resolve({ name: 'Test User', email: 'test@immich.local' }), body: null, }); } // /api/timeline/buckets — browse if (u.includes('/api/timeline/buckets')) { return Promise.resolve({ ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve([{ timeBucket: '2024-01-01T00:00:00.000Z', count: 3 }]), body: null, }); } // /api/search/metadata — timeline search AND (since Immich v3) album contents if (u.includes('/api/search/metadata')) { const body = init?.body ? JSON.parse(init.body) : {}; immichState.searchCalls.push(body); // Album query: Immich v3 removed AlbumResponseDto.assets, so album // contents are fetched via an albumIds-filtered metadata search. if (body.albumIds?.length) { const pages = immichState.albumAssetPages ?? [immichState.albumAssets]; const items = pages[(body.page ?? 1) - 1] ?? []; return Promise.resolve({ ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: { items } }), body: null, }); } return Promise.resolve({ ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: { items: [ { id: 'asset-search-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France', latitude: 48.8566, longitude: 2.3522 } }, ], }, }), body: null, }); } // /api/assets/:id/thumbnail — thumbnail proxy if (u.includes('/thumbnail')) { const imageBytes = Buffer.from('fake-thumbnail-data'); return Promise.resolve({ ok: true, status: 200, headers: { get: (h: string) => h === 'content-type' ? 'image/webp' : null }, body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }), }); } // /api/assets/:id/original — original proxy if (u.includes('/original')) { const imageBytes = Buffer.from('fake-original-data'); return Promise.resolve({ ok: true, status: 200, headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null }, body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }), }); } // /api/assets/:id — asset info if (/\/api\/assets\/[^/]+$/.test(u)) { return Promise.resolve({ ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ id: 'asset-info-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', originalFileName: 'photo.jpg', exifInfo: { exifImageWidth: 4032, exifImageHeight: 3024, make: 'Apple', model: 'iPhone 15', lensModel: null, focalLength: 5.1, fNumber: 1.8, exposureTime: '1/500', iso: 100, city: 'Paris', state: 'Île-de-France', country: 'France', latitude: 48.8566, longitude: 2.3522, fileSizeInByte: 2048000, }, }), body: null, }); } // /api/albums — list albums (owned and shared?=true variant) if (/\/api\/albums(\?.*)?$/.test(u)) { return Promise.resolve({ ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve([ { id: 'album-uuid-1', albumName: 'Vacation 2024', assetCount: 42, startDate: '2024-06-01', endDate: '2024-06-14', albumThumbnailAssetId: null }, ]), body: null, }); } // /api/albums/:id — album detail. // Immich v3 removed the `assets` property from AlbumResponseDto (#1492). // Defaults to the v3 shape; `albumDetailHasAssets` models a v2 server. if (/\/api\/albums\//.test(u)) { const base: any = { id: 'album-uuid-1', albumName: 'Vacation 2024', assetCount: 42 }; if (immichState.albumDetailHasAssets) base.assets = immichState.albumAssets; return Promise.resolve({ ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve(base), body: null, }); } // fallback — unexpected call return Promise.reject(new Error(`Unexpected safeFetch call: ${u}`)); } return { ...actual, checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => { try { const url = new URL(rawUrl); const h = url.hostname; if (h === '127.0.0.1' || h === '::1' || h === 'localhost') { return { allowed: false, isPrivate: true, error: 'Loopback not allowed' }; } if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) { return { allowed: false, isPrivate: true, error: 'Private IP not allowed' }; } return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' }; } catch { return { allowed: false, isPrivate: false, error: 'Invalid URL' }; } }), safeFetch: vi.fn().mockImplementation(makeFakeImmichFetch), }; }); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db'; import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { safeFetch } from '../../src/utils/ssrfGuard'; import { findRow, findRows, insertRow, updateRows } from '../helpers/factories/rows'; import { readUser } from '../helpers/factories/users'; import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity'; import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity'; import { TripPhotos } from '../../src/db/entities/TripPhotos.entity'; import { Users } from '../../src/db/entities/Users.entity'; let nestApp: INestApplication; let app: Application; let orm: FactoryOrm; const IMMICH = '/api/integrations/memories/immich'; /** The trip's photo rows, each with the provider and asset id of the photo it points at. */ async function tripPhotoAssets(tripId: number, userId?: number) { const links = await findRows(orm, TripPhotos, userId === undefined ? { trip: tripId } : { trip: tripId, user: userId }); const rows = []; for (const link of links) { const photo = await findRow(orm, TrekPhotos, { id: link.photo_id }); if (photo) rows.push({ ...link, provider: photo.provider, asset_id: photo.asset_id }); } return rows; } beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); orm = nestApp.get(MikroORM); }); beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); // Providers only count as enabled under an enabled journey addon (migration 84 seeds it off). setAddonEnabled(testDb, 'journey', true); // The migrated snapshot seeds photo_providers.immich.enabled = 0 (an admin must // configure it before it's usable in production); the legacy test helper always // seeded it enabled, which is what these tests assume. Same convention // memories-synology.test.ts already uses for its own provider. await updateRows(orm, PhotoProviders, { id: 'immich' }, { enabled: 1 }); immichState.albumAssets = DEFAULT_ALBUM_ASSETS.map((a) => ({ ...a })); immichState.albumAssetPages = null; immichState.searchCalls = []; immichState.albumDetailHasAssets = false; // default: Immich v3 }); afterAll(async () => { await nestApp.close(); testDb.close(); }); // ── Connection status ───────────────────────────────────────────────────────── describe('Immich connection status', () => { it('IMMICH-030 — GET /status when not configured returns { connected: false }', async () => { const { user } = createUser(testDb); const res = await request(app) .get(`${IMMICH}/status`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.connected).toBe(false); }); it('IMMICH-031 — GET /status when configured returns connected + user info', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .get(`${IMMICH}/status`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.connected).toBe(true); expect(res.body.user).toMatchObject({ name: 'Test User', email: 'test@immich.local' }); }); }); // ── Test connection ─────────────────────────────────────────────────────────── describe('Immich test connection', () => { it('IMMICH-032 — POST /test with missing fields returns { connected: false }', async () => { const { user } = createUser(testDb); const res = await request(app) .post(`${IMMICH}/test`) .set('Cookie', authCookie(user.id)) .send({ immich_url: 'https://immich.example.com' }); // missing api_key expect(res.status).toBe(200); expect(res.body.connected).toBe(false); }); it('IMMICH-033 — POST /test with valid credentials returns { connected: true }', async () => { const { user } = createUser(testDb); const res = await request(app) .post(`${IMMICH}/test`) .set('Cookie', authCookie(user.id)) .send({ immich_url: 'https://immich.example.com', immich_api_key: 'valid-key' }); expect(res.status).toBe(200); expect(res.body.connected).toBe(true); expect(res.body.user).toBeDefined(); }); }); // ── Browse & Search ─────────────────────────────────────────────────────────── describe('Immich browse and search', () => { it('IMMICH-040 — GET /browse when not configured returns 400', async () => { const { user } = createUser(testDb); const res = await request(app) .get(`${IMMICH}/browse`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(400); }); it('IMMICH-041 — GET /browse returns timeline buckets', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .get(`${IMMICH}/browse`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(Array.isArray(res.body.buckets)).toBe(true); expect(res.body.buckets.length).toBeGreaterThan(0); }); it('IMMICH-042 — POST /search returns mapped assets with hasMore flag', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) .send({ page: 1, size: 50 }); expect(res.status).toBe(200); expect(Array.isArray(res.body.assets)).toBe(true); expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522 }); expect(typeof res.body.hasMore).toBe('boolean'); expect(immichState.searchCalls[0]).toMatchObject({ withExif: true }); }); it('IMMICH-043 — POST /search when upstream throws returns 502', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); vi.mocked(safeFetch).mockRejectedValueOnce(new Error('upstream unreachable')); const res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) .send({}); expect(res.status).toBe(502); expect(res.body.error).toBeDefined(); }); }); // ── Asset proxy ─────────────────────────────────────────────────────────────── describe('Immich asset proxy', () => { it('IMMICH-050 — GET /assets/info returns asset metadata for own photo', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); addTripPhoto(testDb, trip.id, user.id, 'asset-info-1', 'immich', { shared: false }); const res = await request(app) .get(`${IMMICH}/assets/${trip.id}/asset-info-1/${user.id}/info`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body).toMatchObject({ id: 'asset-info-1', city: 'Paris', country: 'France' }); }); it('IMMICH-051 — GET /assets/info with invalid assetId (special chars) returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // ID contains characters outside [a-zA-Z0-9_-] → fails isValidAssetId() const invalidId = 'asset!@#$%'; const res = await request(app) .get(`${IMMICH}/assets/${trip.id}/${encodeURIComponent(invalidId)}/${user.id}/info`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(400); }); it('IMMICH-052 — GET /assets/info by non-owner of unshared photo returns 403', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key'); // private photo — shared = false addTripPhoto(testDb, trip.id, owner.id, 'asset-private', 'immich', { shared: false }); const res = await request(app) .get(`${IMMICH}/assets/${trip.id}/asset-private/${owner.id}/info`) .set('Cookie', authCookie(member.id)); expect(res.status).toBe(403); }); it('IMMICH-053 — GET /assets/info by trip member for shared photo returns 200', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key'); // shared photo addTripPhoto(testDb, trip.id, owner.id, 'asset-shared', 'immich', { shared: true }); const res = await request(app) .get(`${IMMICH}/assets/${trip.id}/asset-shared/${owner.id}/info`) .set('Cookie', authCookie(member.id)); expect(res.status).toBe(200); }); it('IMMICH-054 — GET /assets/thumbnail for own photo streams image data', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); addTripPhoto(testDb, trip.id, user.id, 'asset-thumb', 'immich', { shared: false }); const res = await request(app) .get(`${IMMICH}/assets/${trip.id}/asset-thumb/${user.id}/thumbnail`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.headers['content-type']).toContain('image/webp'); expect(res.body).toBeDefined(); }); it('IMMICH-055 — GET /assets/thumbnail for other\'s unshared photo returns 403', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); addTripPhoto(testDb, trip.id, owner.id, 'asset-noshare', 'immich', { shared: false }); const res = await request(app) .get(`${IMMICH}/assets/${trip.id}/asset-noshare/${owner.id}/thumbnail`) .set('Cookie', authCookie(member.id)); expect(res.status).toBe(403); }); it('IMMICH-056 — GET /assets/original for shared photo streams image data', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key'); addTripPhoto(testDb, trip.id, owner.id, 'asset-orig', 'immich', { shared: true }); const res = await request(app) .get(`${IMMICH}/assets/${trip.id}/asset-orig/${owner.id}/original`) .set('Cookie', authCookie(member.id)); expect(res.status).toBe(200); expect(res.headers['content-type']).toContain('image/'); }); it('IMMICH-057 — GET /assets/info where trip does not exist returns 403', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); // Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily) testDb.exec('PRAGMA foreign_keys = OFF'); const known = await findRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-notrip', owner: owner.id }); const photoId = known?.id ?? (await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-notrip', owner: owner.id })); await insertRow(orm, TripPhotos, { trip: 9999, user: owner.id, photo: photoId, shared: 1 }); testDb.exec('PRAGMA foreign_keys = ON'); const res = await request(app) .get(`${IMMICH}/assets/9999/asset-notrip/${owner.id}/info`) .set('Cookie', authCookie(member.id)); // canAccessUserPhoto: shared photo found, but canAccessTrip(9999) → null → false → 403 expect(res.status).toBe(403); }); it('IMMICH-058 — GET /assets/info when upstream returns error propagates status', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); addTripPhoto(testDb, trip.id, user.id, 'asset-upstream-err', 'immich', { shared: false }); vi.mocked(safeFetch).mockResolvedValueOnce({ ok: false, status: 503, headers: { get: () => null } as any, json: async () => ({}), } as any); const res = await request(app) .get(`${IMMICH}/assets/${trip.id}/asset-upstream-err/${user.id}/info`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(503); expect(res.body.error).toBeDefined(); }); }); // ── Albums ──────────────────────────────────────────────────────────────────── describe('Immich albums', () => { it('IMMICH-060 — GET /albums when not configured returns 400', async () => { const { user } = createUser(testDb); const res = await request(app) .get(`${IMMICH}/albums`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(400); }); it('IMMICH-061 — GET /albums returns album list', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .get(`${IMMICH}/albums`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(Array.isArray(res.body.albums)).toBe(true); expect(res.body.albums[0]).toMatchObject({ id: 'album-uuid-1', albumName: 'Vacation 2024' }); }); }); // ── Album photos (#1492) ────────────────────────────────────────────────────── describe('Immich album photos', () => { it('IMMICH-062 — GET /albums/:id/photos returns photos even though the album detail body has no `assets` (Immich v3)', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.assets).toHaveLength(2); // Newest first: asset-sync-2 was taken on the 2nd, asset-sync-1 on the 1st. expect(res.body.assets.map((a: any) => a.id)).toEqual(['asset-sync-2', 'asset-sync-1']); }); it('IMMICH-063 — GET /albums/:id/photos filters hidden assets (both visibility and legacy isVisible markers)', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); const ids = res.body.assets.map((a: any) => a.id); expect(ids).not.toContain('asset-hidden'); expect(ids).not.toContain('asset-legacy-hidden'); }); it('IMMICH-064 — GET /albums/:id/photos maps exif and media type', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); // Keyed by id, not by position: the response is sorted by capture time now, // so an index would pin the ordering here as a side effect. const byId = Object.fromEntries(res.body.assets.map((a: any) => [a.id, a])); expect(byId['asset-sync-1']).toMatchObject({ id: 'asset-sync-1', takenAt: '2024-06-01T10:00:00.000Z', city: 'Paris', country: 'France', mediaType: 'image', }); expect(byId['asset-sync-2'].mediaType).toBe('video'); }); // #1614 — the album mapping used to drop lat/lng even though the search path // kept them, so a photo picked from an album could never reach a map. it('IMMICH-064b — album photos carry their capture coordinates, and tolerate their absence', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); const byId = Object.fromEntries(res.body.assets.map((a: any) => [a.id, a])); expect(byId['asset-sync-1']).toMatchObject({ lat: 48.8584, lng: 2.2945 }); // asset-sync-2 has no coordinates; it must come back null, not undefined // or a half pair. expect(byId['asset-sync-2'].lat).toBeNull(); expect(byId['asset-sync-2'].lng).toBeNull(); }); it('IMMICH-065 — album photos are fetched via search/metadata with albumIds and withExif', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); expect(immichState.searchCalls).toHaveLength(1); // withExif is required: without it Immich omits exifInfo entirely and // city/country would silently become null for every photo. expect(immichState.searchCalls[0]).toMatchObject({ albumIds: ['album-uuid-1'], withExif: true, // The album path asks for the order too; without this it could fall out // again unnoticed, because the local sort would still hide it here. order: 'desc', page: 1, }); }); it('IMMICH-066 — GET /albums/:id/photos pages through albums larger than one page', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); // Page 1 is exactly `size` long, so the service must ask for page 2. const pageOne = Array.from({ length: 1000 }, (_, i) => ({ id: `bulk-${i}`, type: 'IMAGE', fileCreatedAt: '2024-06-01T10:00:00.000Z', })); const pageTwo = [{ id: 'tail-asset', type: 'IMAGE', fileCreatedAt: '2024-06-02T10:00:00.000Z' }]; immichState.albumAssetPages = [pageOne, pageTwo]; const res = await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.assets).toHaveLength(1001); // tail-asset is the only one from page two and the newest of the 1001, so // the chronological sort puts it first. Its presence is what proves page // two was fetched at all. expect(res.body.assets[0].id).toBe('tail-asset'); expect(immichState.searchCalls.map((c) => c.page)).toEqual([1, 2]); }); // Immich v2 still embeds `assets` in the album detail body. It must be used as-is: // v2's searchMetadata unconditionally scopes to `[self, ...partners]`, so an // albumIds search there returns nothing for an album shared by a non-partner. it('IMMICH-069a — on Immich v2 album photos come from the album detail body, with no search call', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); immichState.albumDetailHasAssets = true; const res = await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.assets.map((a: any) => a.id)).toEqual(['asset-sync-2', 'asset-sync-1']); expect(immichState.searchCalls).toHaveLength(0); }); it('IMMICH-069b — on Immich v2 an empty album stays empty and does not fall back to search', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); immichState.albumDetailHasAssets = true; immichState.albumAssets = []; const res = await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.assets).toEqual([]); expect(immichState.searchCalls).toHaveLength(0); }); it('IMMICH-069c — on Immich v2 sync reads the album detail body', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024'); immichState.albumDetailHasAssets = true; const res = await request(app) .post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.added).toBe(1); expect(immichState.searchCalls).toHaveLength(0); }); it('IMMICH-067 — GET /albums/:id/photos when not configured returns 400', async () => { const { user } = createUser(testDb); const res = await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(400); }); it('IMMICH-068 — GET /albums/:id/photos when Immich is unreachable returns 502', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); vi.mocked(safeFetch).mockRejectedValueOnce(new Error('network failure')); const res = await request(app) .get(`${IMMICH}/albums/album-uuid-1/photos`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(502); }); }); // ── Auth checks ─────────────────────────────────────────────────────────────── describe('Immich auth checks', () => { it('IMMICH-070 — GET /status without auth returns 401', async () => { expect((await request(app).get(`${IMMICH}/status`)).status).toBe(401); }); it('IMMICH-070 — POST /test without auth returns 401', async () => { expect((await request(app).post(`${IMMICH}/test`)).status).toBe(401); }); it('IMMICH-070 — GET /browse without auth returns 401', async () => { expect((await request(app).get(`${IMMICH}/browse`)).status).toBe(401); }); it('IMMICH-070 — POST /search without auth returns 401', async () => { expect((await request(app).post(`${IMMICH}/search`)).status).toBe(401); }); it('IMMICH-070 — GET /albums without auth returns 401', async () => { expect((await request(app).get(`${IMMICH}/albums`)).status).toBe(401); }); it('IMMICH-070 — GET /assets/info without auth returns 401', async () => { expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/info`)).status).toBe(401); }); it('IMMICH-070 — GET /assets/thumbnail without auth returns 401', async () => { expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/thumbnail`)).status).toBe(401); }); it('IMMICH-070 — GET /assets/original without auth returns 401', async () => { expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/original`)).status).toBe(401); }); }); // ── Album sync ──────────────────────────────────────────────────────────────── describe('Immich syncAlbumAssets', () => { it('IMMICH-080 — POST sync happy path: trip owner with album link saves photos to DB', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024'); const res = await request(app) .post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.success).toBe(true); // Only asset-sync-1 is an IMAGE; the video and the hidden motion parts are // not synced. A zero here is the #1492 silent-failure mode. expect(res.body.total).toBe(1); expect(res.body.added).toBe(1); // Verify photos were inserted into the DB const photos = await tripPhotoAssets(trip.id, user.id); expect(photos.length).toBeGreaterThan(0); expect(photos[0].provider).toBe('immich'); }); it('IMMICH-087 — POST sync does not persist hidden assets (#1474)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024'); // A hidden IMAGE slips past the `type === 'IMAGE'` filter. Persisting it // yields a permanently broken tile: nothing re-checks visibility on the // render path, and Immich has no thumbnail to serve. immichState.albumAssets = [ { id: 'visible-still', type: 'IMAGE', visibility: 'timeline', fileCreatedAt: '2024-06-01T10:00:00.000Z' }, { id: 'hidden-image', type: 'IMAGE', visibility: 'hidden', fileCreatedAt: '2024-06-02T10:00:00.000Z' }, { id: 'legacy-hidden-image', type: 'IMAGE', isVisible: false, fileCreatedAt: '2024-06-03T10:00:00.000Z' }, ]; const res = await request(app) .post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.total).toBe(1); const rows = await tripPhotoAssets(trip.id); expect(rows.map((r) => r.asset_id)).toEqual(['visible-still']); }); it('IMMICH-086 — POST sync fetches album contents via search/metadata with albumIds', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024'); await request(app) .post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`) .set('Cookie', authCookie(user.id)); expect(immichState.searchCalls[0]).toMatchObject({ albumIds: ['album-uuid-1'] }); }); it('IMMICH-081 — POST sync when user is not a trip member returns 404', async () => { const { user: owner } = createUser(testDb); const { user: outsider } = createUser(testDb); const trip = createTrip(testDb, owner.id); setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key'); const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024'); // outsider is not a trip member — getAlbumIdFromLink checks canAccessTrip const res = await request(app) .post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`) .set('Cookie', authCookie(outsider.id)); expect(res.status).toBe(404); }); it('IMMICH-082 — POST sync when Immich is not configured returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // No Immich credentials set — but still need a valid album link owned by user const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024'); const res = await request(app) .post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(400); expect(res.body.error).toBeDefined(); }); it('IMMICH-083 — POST sync when safeFetch throws returns 502', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024'); vi.mocked(safeFetch).mockRejectedValueOnce(new Error('network failure during sync')); const res = await request(app) .post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(502); expect(res.body.error).toBeDefined(); }); it('IMMICH-084 — POST sync when album link does not belong to requesting user returns 404', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); setImmichCredentials(testDb, member.id, 'https://immich.example.com', 'test-api-key'); // Album link is owned by owner, not member const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024'); // member is a trip member but the album link belongs to owner — getAlbumIdFromLink checks user_id const res = await request(app) .post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`) .set('Cookie', authCookie(member.id)); expect(res.status).toBe(404); }); it('IMMICH-085 — POST sync without auth returns 401', async () => { expect((await request(app).post(`${IMMICH}/trips/1/album-links/1/sync`)).status).toBe(401); }); }); // ── searchPhotos pagination safety ──────────────────────────────────────────── describe('Immich searchPhotos pagination pass-through', () => { it('IMMICH-090 — POST /search proxies client page param and returns hasMore', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); // Return a full page so hasMore=true (items.length >= size) const fullPageResponse = { ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: { items: Array.from({ length: 50 }, (_, i) => ({ id: `asset-p2-${i}`, fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Berlin', country: 'Germany' }, })), }, }), body: null, } as any; vi.mocked(safeFetch).mockClear(); vi.mocked(safeFetch).mockResolvedValue(fullPageResponse); const res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) .send({ page: 2, size: 50 }); expect(res.status).toBe(200); expect(Array.isArray(res.body.assets)).toBe(true); // Single page returned — not 20× aggregation expect(res.body.assets.length).toBe(50); expect(res.body.hasMore).toBe(true); // Immich was called exactly once expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(1); // page=2 was forwarded to Immich const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string); expect(callBody.page).toBe(2); }); it('IMMICH-091 — POST /search returns hasMore=false on last page', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); // Partial page → hasMore=false const partialPageResponse = { ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: { items: Array.from({ length: 3 }, (_, i) => ({ id: `asset-last-${i}`, fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Rome', country: 'Italy' }, })), }, }), body: null, } as any; vi.mocked(safeFetch).mockResolvedValue(partialPageResponse); const res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) .send({ page: 5, size: 50 }); expect(res.status).toBe(200); expect(res.body.assets.length).toBe(3); expect(res.body.hasMore).toBe(false); }); it('IMMICH-094: POST /search reaches a date-filtered page past the twentieth at the picker page size (#1587)', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); // Every raw page is 200 photos of the searched day, so answered page 21 // needs raw pages 1 to 21. The old flat budget of 20 answered it empty with // hasMore false, which is where a trip stopped without a word. const previous = vi.mocked(safeFetch).getMockImplementation(); vi.mocked(safeFetch).mockClear(); vi.mocked(safeFetch).mockImplementation(async (_url: unknown, init?: { body?: unknown }) => { const rawPage = JSON.parse(String(init?.body ?? '{}')).page as number; return { ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: { items: Array.from({ length: 200 }, (_, i) => ({ id: `deep-${rawPage}-${i}`, fileCreatedAt: '2024-06-01T10:00:00.000Z', localDateTime: '2024-06-01T12:00:00.000Z', })), }, }), body: null, } as never; }); let res: request.Response; try { res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) .send({ from: '2024-06-01', to: '2024-06-01', page: 21, size: 200 }); } finally { if (previous) vi.mocked(safeFetch).mockImplementation(previous); } expect(res.status).toBe(200); expect(res.body.assets).toHaveLength(200); expect(res.body.assets[0].id).toMatch(/^deep-21-/); expect(res.body.hasMore).toBe(true); expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(21); const sizes = vi.mocked(safeFetch).mock.calls.map(c => JSON.parse(String((c[1] as { body?: unknown }).body)).size); expect(new Set(sizes)).toEqual(new Set([200])); }); it('IMMICH-093 — POST /search requests timeline visibility so Immich v3 never returns hidden assets (#1474)', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); // A sibling test installs a permanent mockResolvedValue on safeFetch, so // assert on the spy's recorded call rather than immichState.searchCalls. vi.mocked(safeFetch).mockClear(); vi.mocked(safeFetch).mockResolvedValue({ ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: { items: [] } }), body: null, } as any); await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) .send({ from: '2024-06-01', to: '2024-06-14' }); // Immich v2 hard-defaulted metadata search to `timeline` visibility; v3 // defaults to "any except locked", which is what started surfacing Live // Photo motion parts. Asking for `timeline` explicitly restores v2 // semantics on both, so hidden assets never cross the wire. const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string); expect(callBody.visibility).toBe('timeline'); }); it('IMMICH-092 — POST /search filters hidden Live Photo motion assets (#1474)', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); // A Live Photo pair (visible still + hidden motion video) plus a legacy // hidden asset — only the still should survive, but hasMore reflects the // raw page count (4 >= size 4). const mixedResponse = { ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: { items: [ { id: 'still-1', type: 'IMAGE', visibility: 'timeline', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Kyoto', country: 'Japan' }, livePhotoVideoId: 'motion-1' }, { id: 'motion-1', type: 'VIDEO', visibility: 'hidden', fileCreatedAt: '2024-06-01T10:00:00.000Z' }, { id: 'legacy-hidden', type: 'VIDEO', isVisible: false, fileCreatedAt: '2024-06-01T10:00:00.000Z' }, { id: 'video-visible', type: 'VIDEO', visibility: 'timeline', fileCreatedAt: '2024-06-01T10:00:00.000Z' }, ], }, }), body: null, } as any; vi.mocked(safeFetch).mockResolvedValue(mixedResponse); const res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) .send({ page: 1, size: 4 }); expect(res.status).toBe(200); // motion-1 (hidden) and legacy-hidden (isVisible:false) are dropped. expect(res.body.assets.map((a: any) => a.id)).toEqual(['still-1', 'video-visible']); // Ordinary visible video survives, tagged as video. expect(res.body.assets.find((a: any) => a.id === 'video-visible').mediaType).toBe('video'); // hasMore stays on raw page length so pagination advances past a filtered page. expect(res.body.hasMore).toBe(true); }); }); // ── saveImmichSettings clearing credentials ─────────────────────────────────── describe('Immich saveImmichSettings clearing URL', () => { it('IMMICH-095 — PUT /settings with no URL clears immich_url but preserves (updates) api key', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key'); // Send without immich_url to trigger the else branch (clear URL path) const res = await request(app) .put(`${IMMICH}/settings`) .set('Cookie', authCookie(user.id)) .send({ immich_api_key: 'new-key' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect((await readUser(orm, user.id)).immich_url).toBeNull(); }); it('IMMICH-096 — PUT /settings with empty string URL clears immich_url', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key'); const res = await request(app) .put(`${IMMICH}/settings`) .set('Cookie', authCookie(user.id)) .send({ immich_url: '', immich_api_key: 'old-key' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect((await readUser(orm, user.id)).immich_url).toBeNull(); }); }); // ── testConnection canonical URL detection ──────────────────────────────────── describe('Immich testConnection canonical URL detection', () => { it('IMMICH-100 — POST /test with http URL that gets upgraded to https returns canonicalUrl', async () => { const { user } = createUser(testDb); // Mock safeFetch so the response.url reflects https upgrade vi.mocked(safeFetch).mockResolvedValueOnce({ ok: true, status: 200, url: 'https://immich.example.com/api/users/me', headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null } as any, json: async () => ({ name: 'Redirect User', email: 'redirect@immich.local' }), body: null, } as any); const res = await request(app) .post(`${IMMICH}/test`) .set('Cookie', authCookie(user.id)) .send({ immich_url: 'http://immich.example.com', immich_api_key: 'valid-key' }); expect(res.status).toBe(200); expect(res.body.connected).toBe(true); expect(res.body.canonicalUrl).toBe('https://immich.example.com'); }); it('IMMICH-101 — POST /test with https URL that stays https does not return canonicalUrl', async () => { const { user } = createUser(testDb); // The default mock returns a response without .url property — no upgrade const res = await request(app) .post(`${IMMICH}/test`) .set('Cookie', authCookie(user.id)) .send({ immich_url: 'https://immich.example.com', immich_api_key: 'valid-key' }); expect(res.status).toBe(200); expect(res.body.connected).toBe(true); expect(res.body.canonicalUrl).toBeUndefined(); }); }); // ── Self-signed certificates (#2475) ───────────────────────────────────────── describe('Immich self-signed certificate switch (#2475)', () => { const LAX = { rejectUnauthorized: false }; const STRICT = { rejectUnauthorized: true }; /** Only what the service and the asset proxy read off a response. */ function okJson(json: unknown): Response { return { ok: true, status: 200, url: '', headers: { get: () => null }, json: async () => json, body: null } as unknown as Response; } async function allowInsecureTls(userId: number): Promise { return (await readUser(orm, userId)).immich_allow_insecure_tls; } beforeEach(() => { // Earlier cases leave a fixed response behind; these need a plain answer per path. vi.mocked(safeFetch).mockReset(); vi.mocked(safeFetch).mockImplementation(async (url: string) => String(url).includes('/api/search/metadata') ? okJson({ assets: { items: [] } }) : okJson({}), ); }); it('IMMICH-102: PUT /settings stores the switch and GET /settings reports it', async () => { const { user } = createUser(testDb); const put = await request(app) .put(`${IMMICH}/settings`) .set('Cookie', authCookie(user.id)) .send({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls: true }); expect(put.status).toBe(200); const get = await request(app).get(`${IMMICH}/settings`).set('Cookie', authCookie(user.id)); expect(get.status).toBe(200); expect(get.body.allow_insecure_tls).toBe(true); }); it('IMMICH-103: a save without the switch keeps it for the same server, a new server or disconnecting clears it', async () => { const { user } = createUser(testDb); const put = (body: Record) => request(app).put(`${IMMICH}/settings`).set('Cookie', authCookie(user.id)).send(body); await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls: true }); expect(await allowInsecureTls(user.id)).toBe(1); // What a client that predates the switch sends. await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k' }); expect(await allowInsecureTls(user.id)).toBe(1); // A different server is a different trust decision: without the switch it starts off. await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k' }); expect(await allowInsecureTls(user.id)).toBe(0); await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k', allow_insecure_tls: true }); expect(await allowInsecureTls(user.id)).toBe(1); await put({ immich_url: '', immich_api_key: 'k', allow_insecure_tls: true }); expect(await allowInsecureTls(user.id)).toBe(0); }); it('IMMICH-104: browse, search and the thumbnail proxy follow the stored switch', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); addTripPhoto(testDb, trip.id, user.id, 'asset-tls', 'immich', { shared: false }); const cookie = authCookie(user.id); const hitEveryPath = async () => { expect((await request(app).get(`${IMMICH}/browse`).set('Cookie', cookie)).status).toBe(200); expect((await request(app).post(`${IMMICH}/search`).set('Cookie', cookie).send({ page: 1, size: 10 })).status).toBe(200); expect((await request(app).get(`${IMMICH}/assets/${trip.id}/asset-tls/${user.id}/thumbnail`).set('Cookie', cookie)).status).toBe(200); }; await updateRows(orm, Users, { id: user.id }, { immich_allow_insecure_tls: 1 }); await hitEveryPath(); expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3); for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toMatchObject(LAX); vi.mocked(safeFetch).mockClear(); await updateRows(orm, Users, { id: user.id }, { immich_allow_insecure_tls: 0 }); await hitEveryPath(); expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3); for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toMatchObject(STRICT); }); it('IMMICH-105: POST /test probes with the switch in the form, and refuses a value that is not a boolean', async () => { const { user } = createUser(testDb); const test = (allow_insecure_tls: unknown) => request(app) .post(`${IMMICH}/test`) .set('Cookie', authCookie(user.id)) .send({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls }); const lax = await test(true); expect(lax.status).toBe(200); expect(lax.body.connected).toBe(true); expect(vi.mocked(safeFetch).mock.calls[0][2]).toMatchObject(LAX); const strict = await test(false); expect(strict.status).toBe(200); expect(vi.mocked(safeFetch).mock.calls[1][2]).toMatchObject(STRICT); // Only a real boolean: the form has always sent one, and anything else // must not be read as either answer. for (const value of ['true', 'yes']) { expect((await test(value)).status).toBe(400); } expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(2); }); });