Spaces:
Sleeping
Sleeping
| /** | |
| * 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'; | |
| // ββ Hoisted DB mock ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const { testDb, dbMock } = vi.hoisted(() => { | |
| const Database = require('better-sqlite3'); | |
| const db = new Database(':memory:'); | |
| db.exec('PRAGMA journal_mode = WAL'); | |
| db.exec('PRAGMA foreign_keys = ON'); | |
| db.exec('PRAGMA busy_timeout = 5000'); | |
| const mock = { | |
| db, | |
| closeDb: () => {}, | |
| reinitialize: () => {}, | |
| getPlaceWithTags: () => null, | |
| canAccessTrip: (tripId: any, userId: number) => | |
| db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), | |
| isOwner: (tripId: any, userId: number) => | |
| !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), | |
| }; | |
| return { testDb: db, dbMock: mock }; | |
| }); | |
| vi.mock('../../src/db/database', () => dbMock); | |
| 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<typeof import('../../src/utils/ssrfGuard')>('../../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 β search | |
| if (u.includes('/api/search/metadata')) { | |
| 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' } }, | |
| ], | |
| }, | |
| }), | |
| 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 (for sync) | |
| if (/\/api\/albums\//.test(u)) { | |
| return Promise.resolve({ | |
| ok: true, status: 200, | |
| headers: { get: () => null }, | |
| json: () => Promise.resolve({ assets: [{ id: 'asset-sync-1', type: 'IMAGE' }] }), | |
| 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 { buildApp } from '../../src/bootstrap'; | |
| import { createTables } from '../../src/db/schema'; | |
| import { runMigrations } from '../../src/db/migrations'; | |
| import { resetTestDb, resetRateLimits } from '../helpers/test-db'; | |
| import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories'; | |
| import { authCookie } from '../helpers/auth'; | |
| import { safeFetch } from '../../src/utils/ssrfGuard'; | |
| let nestApp: INestApplication; | |
| let app: Application; | |
| const IMMICH = '/api/integrations/memories/immich'; | |
| beforeAll(async () => { | |
| createTables(testDb); | |
| runMigrations(testDb); | |
| nestApp = await buildApp(); | |
| app = nestApp.getHttpAdapter().getInstance(); | |
| }); | |
| beforeEach(() => { | |
| resetTestDb(testDb); | |
| resetRateLimits(nestApp); | |
| }); | |
| 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' }); | |
| expect(typeof res.body.hasMore).toBe('boolean'); | |
| }); | |
| 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'); | |
| testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-notrip', owner.id); | |
| const tkpNotrip = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-notrip', owner.id) as any; | |
| testDb.prepare( | |
| 'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)' | |
| ).run(9999, owner.id, tkpNotrip.id, 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' }); | |
| }); | |
| }); | |
| // ββ 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); | |
| expect(typeof res.body.total).toBe('number'); | |
| expect(typeof res.body.added).toBe('number'); | |
| // Verify photos were inserted into the DB | |
| const photos = testDb.prepare(` | |
| SELECT tp.*, tkp.provider FROM trip_photos tp | |
| JOIN trek_photos tkp ON tkp.id = tp.photo_id | |
| WHERE tp.trip_id = ? AND tp.user_id = ? | |
| `).all(trip.id, user.id) as any[]; | |
| expect(photos.length).toBeGreaterThan(0); | |
| expect(photos[0].provider).toBe('immich'); | |
| }); | |
| 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); | |
| }); | |
| }); | |
| // ββ 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); | |
| const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any; | |
| expect(row.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); | |
| const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any; | |
| expect(row.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(); | |
| }); | |
| }); | |