/** * Memories (photo-providers) module e2e — exercises the migrated * /api/integrations/memories endpoints (unified + immich + synologyphotos) * through the real JwtAuthGuard against a temp SQLite db. The provider services * and canAccessUserPhoto are mocked; fail/success stay real so the envelope * shapes are produced by the actual helper code. * * Focus: auth (401), every route's happy path, the CRITICAL 200-on-failure * behaviour of /test + /status, and at least one error envelope per provider * router — all asserted byte-identical to the legacy Express routers. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const Database = require('better-sqlite3'); const tmp = new Database(':memory:'); tmp.exec('PRAGMA journal_mode = WAL'); tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); return { db: tmp }; }); vi.mock('../../src/db/database', () => ({ db, canAccessTrip: vi.fn(), closeDb: () => {}, reinitialize: () => {} })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); // Provider services — fully mocked. fail/success/canAccessUserPhoto from the // helper module are kept real except canAccessUserPhoto which we override. const { unified, immich, synology } = vi.hoisted(() => ({ unified: { listTripPhotos: vi.fn(), addTripPhotos: vi.fn(), setTripPhotoSharing: vi.fn(), removeTripPhoto: vi.fn(), listTripAlbumLinks: vi.fn(), createTripAlbumLink: vi.fn(), removeAlbumLink: vi.fn(), }, immich: { getConnectionSettings: vi.fn(), saveImmichSettings: vi.fn(), setImmichAutoUpload: vi.fn(), testConnection: vi.fn(), getConnectionStatus: vi.fn(), browseTimeline: vi.fn(), searchPhotos: vi.fn(), streamImmichAsset: vi.fn(), listAlbums: vi.fn(), getAlbumPhotos: vi.fn(), syncAlbumAssets: vi.fn(), getAssetInfo: vi.fn(), isValidAssetId: vi.fn(), }, synology: { getSynologySettings: vi.fn(), updateSynologySettings: vi.fn(), getSynologyStatus: vi.fn(), testSynologyConnection: vi.fn(), listSynologyAlbums: vi.fn(), getSynologyAlbumPhotos: vi.fn(), syncSynologyAlbumLink: vi.fn(), searchSynologyPhotos: vi.fn(), getSynologyAssetInfo: vi.fn(), streamSynologyAsset: vi.fn(), }, })); vi.mock('../../src/services/memories/unifiedService', () => unified); vi.mock('../../src/services/memories/immichService', () => immich); vi.mock('../../src/services/memories/synologyService', () => synology); const { canAccessUserPhoto } = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn() })); vi.mock('../../src/services/memories/helpersService', async () => { const actual = await vi.importActual( '../../src/services/memories/helpersService', ); return { ...actual, canAccessUserPhoto }; }); import { MemoriesModule } from '../../src/nest/memories/memories.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; const BASE = '/api/integrations/memories'; const UNIFIED = `${BASE}/unified`; const IMMICH = `${BASE}/immich`; const SYNO = `${BASE}/synologyphotos`; describe('Memories e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { const moduleRef = await Test.createTestingModule({ imports: [MemoriesModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); await nest.init(); return nest; } beforeAll(async () => { seedUser(db as never, { id: 1 }); app = await build(); server = app.getHttpServer(); }); afterAll(async () => { await app.close(); }); beforeEach(() => { vi.clearAllMocks(); canAccessUserPhoto.mockReturnValue(true); immich.isValidAssetId.mockReturnValue(true); }); // ── Auth ─────────────────────────────────────────────────────────────────── describe('auth', () => { it('401 without a cookie (unified photos)', async () => { expect((await request(server).get(`${UNIFIED}/trips/5/photos`)).status).toBe(401); }); it('401 without a cookie (immich status)', async () => { expect((await request(server).get(`${IMMICH}/status`)).status).toBe(401); }); it('401 without a cookie (synology albums)', async () => { expect((await request(server).get(`${SYNO}/albums`)).status).toBe(401); }); }); // ── Unified ────────────────────────────────────────────────────────────────── describe('unified', () => { it('200 list photos -> { photos }', async () => { unified.listTripPhotos.mockReturnValue({ success: true, data: [{ photo_id: 1, asset_id: 'a' }] }); const res = await request(server).get(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ photos: [{ photo_id: 1, asset_id: 'a' }] }); }); it('200 add photos -> { success, added } (POST stays 200, not 201)', async () => { unified.addTripPhotos.mockResolvedValue({ success: true, data: { added: 2, shared: true } }); const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)) .send({ shared: true, selections: [{ provider: 'immich', asset_ids: ['a', 'b'] }] }); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, added: 2 }); // x-socket-id absent -> undefined, matching the legacy `req.headers['x-socket-id'] as string`. expect(unified.addTripPhotos).toHaveBeenCalledWith('5', 1, true, [{ provider: 'immich', asset_ids: ['a', 'b'] }], undefined); }); it('400 add photos with empty selections -> error envelope', async () => { unified.addTripPhotos.mockResolvedValue({ success: false, error: { message: 'No photos selected', status: 400 } }); const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ selections: [] }); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'No photos selected' }); }); it('200 PUT sharing -> { success: true }', async () => { unified.setTripPhotoSharing.mockResolvedValue({ success: true, data: true }); const res = await request(server).put(`${UNIFIED}/trips/5/photos/sharing`).set('Cookie', sessionCookie(1)).send({ photo_id: 9, shared: true }); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); }); it('404 DELETE photo on inaccessible trip -> error envelope', async () => { unified.removeTripPhoto.mockReturnValue({ success: false, error: { message: 'Trip not found or access denied', status: 404 } }); const res = await request(server).delete(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ photo_id: 9 }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found or access denied' }); }); it('200 list album-links -> { links }', async () => { unified.listTripAlbumLinks.mockReturnValue({ success: true, data: [{ id: 'l1' }] }); const res = await request(server).get(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ links: [{ id: 'l1' }] }); }); it('200 create album-link / 409 duplicate envelope', async () => { unified.createTripAlbumLink.mockReturnValue({ success: true, data: true }); const ok = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' }); expect(ok.status).toBe(200); expect(ok.body).toEqual({ success: true }); unified.createTripAlbumLink.mockReturnValue({ success: false, error: { message: 'Album already linked', status: 409 } }); const dup = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' }); expect(dup.status).toBe(409); expect(dup.body).toEqual({ error: 'Album already linked' }); }); it('200 DELETE album-link -> { success: true }', async () => { unified.removeAlbumLink.mockReturnValue({ success: true, data: true }); const res = await request(server).delete(`${UNIFIED}/trips/5/album-links/7`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); }); }); // ── Immich ─────────────────────────────────────────────────────────────────── describe('immich', () => { it('200 settings', async () => { immich.getConnectionSettings.mockReturnValue({ immich_url: '', connected: false, auto_upload: false }); const res = await request(server).get(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ immich_url: '', connected: false, auto_upload: false }); }); it('200 PUT settings success / 400 invalid url', async () => { immich.saveImmichSettings.mockResolvedValue({ success: true }); const ok = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', auto_upload: true }); expect(ok.status).toBe(200); expect(ok.body).toEqual({ success: true }); expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(1, true); immich.saveImmichSettings.mockResolvedValue({ success: false, error: 'Invalid Immich URL: bad' }); const bad = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'bad' }); expect(bad.status).toBe(400); expect(bad.body).toEqual({ error: 'Invalid Immich URL: bad' }); }); it('CRITICAL: 200 /status with { connected: false } on failure', async () => { immich.getConnectionStatus.mockResolvedValue({ connected: false, error: 'Not configured' }); const res = await request(server).get(`${IMMICH}/status`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ connected: false, error: 'Not configured' }); }); it('CRITICAL: 200 /test missing fields -> { connected: false, error } without calling service', async () => { const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x' }); expect(res.status).toBe(200); expect(res.body).toEqual({ connected: false, error: 'URL and API key required' }); expect(immich.testConnection).not.toHaveBeenCalled(); }); it('200 /test with creds delegates to service', async () => { immich.testConnection.mockResolvedValue({ connected: true, user: { name: 'T' } }); const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k' }); expect(res.status).toBe(200); expect(res.body).toEqual({ connected: true, user: { name: 'T' } }); }); it('200 browse / 400 not configured', async () => { immich.browseTimeline.mockResolvedValue({ buckets: [{ count: 3 }] }); const ok = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1)); expect(ok.status).toBe(200); expect(ok.body).toEqual({ buckets: [{ count: 3 }] }); immich.browseTimeline.mockResolvedValue({ error: 'Immich not configured', status: 400 }); const bad = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1)); expect(bad.status).toBe(400); expect(bad.body).toEqual({ error: 'Immich not configured' }); }); it('200 search (POST stays 200) / 502 envelope', async () => { immich.searchPhotos.mockResolvedValue({ assets: [{ id: 'a' }], hasMore: true }); const ok = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({ page: 1, size: 50 }); expect(ok.status).toBe(200); expect(ok.body).toEqual({ assets: [{ id: 'a' }], hasMore: true }); expect(immich.searchPhotos).toHaveBeenCalledWith(1, undefined, undefined, 1, 50); immich.searchPhotos.mockResolvedValue({ error: 'Could not reach Immich', status: 502 }); const bad = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({}); expect(bad.status).toBe(502); expect(bad.body).toEqual({ error: 'Could not reach Immich' }); }); it('200 asset info / 400 invalid id / 403 no access', async () => { immich.getAssetInfo.mockResolvedValue({ data: { id: 'asset-1', city: 'Paris' } }); const ok = await request(server).get(`${IMMICH}/assets/5/asset-1/1/info`).set('Cookie', sessionCookie(1)); expect(ok.status).toBe(200); expect(ok.body).toEqual({ id: 'asset-1', city: 'Paris' }); immich.isValidAssetId.mockReturnValue(false); const invalid = await request(server).get(`${IMMICH}/assets/5/bad/1/info`).set('Cookie', sessionCookie(1)); expect(invalid.status).toBe(400); expect(invalid.body).toEqual({ error: 'Invalid asset ID' }); immich.isValidAssetId.mockReturnValue(true); canAccessUserPhoto.mockReturnValue(false); const forbidden = await request(server).get(`${IMMICH}/assets/5/asset-1/2/info`).set('Cookie', sessionCookie(1)); expect(forbidden.status).toBe(403); expect(forbidden.body).toEqual({ error: 'Forbidden' }); }); it('streams thumbnail bytes via the service helper', async () => { immich.streamImmichAsset.mockImplementation(async (res: any) => { res.status(200); res.set('Content-Type', 'image/webp'); res.end(Buffer.from('thumb-bytes')); }); const res = await request(server).get(`${IMMICH}/assets/5/asset-1/1/thumbnail`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.headers['content-type']).toContain('image/webp'); expect(immich.streamImmichAsset).toHaveBeenCalledWith(expect.anything(), 1, 'asset-1', 'thumbnail', 1); }); it('200 albums / 200 album photos', async () => { immich.listAlbums.mockResolvedValue({ albums: [{ id: 'al' }] }); const albums = await request(server).get(`${IMMICH}/albums`).set('Cookie', sessionCookie(1)); expect(albums.status).toBe(200); expect(albums.body).toEqual({ albums: [{ id: 'al' }] }); immich.getAlbumPhotos.mockResolvedValue({ assets: [{ id: 'p1' }] }); const photos = await request(server).get(`${IMMICH}/albums/al/photos`).set('Cookie', sessionCookie(1)); expect(photos.status).toBe(200); expect(photos.body).toEqual({ assets: [{ id: 'p1' }] }); }); it('200 album sync (POST stays 200) / 404 envelope', async () => { immich.syncAlbumAssets.mockResolvedValue({ success: true, added: 3, total: 10 }); const ok = await request(server).post(`${IMMICH}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1)); expect(ok.status).toBe(200); expect(ok.body).toEqual({ success: true, added: 3, total: 10 }); immich.syncAlbumAssets.mockResolvedValue({ error: 'Album link not found', status: 404 }); const bad = await request(server).post(`${IMMICH}/trips/5/album-links/9/sync`).set('Cookie', sessionCookie(1)); expect(bad.status).toBe(404); expect(bad.body).toEqual({ error: 'Album link not found' }); }); }); // ── Synology ─────────────────────────────────────────────────────────────── describe('synologyphotos', () => { it('200 settings', async () => { synology.getSynologySettings.mockResolvedValue({ success: true, data: { synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true } }); const res = await request(server).get(`${SYNO}/settings`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true }); }); it('400 PUT settings without url/username -> envelope', async () => { const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: '' }); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'URL and username are required' }); expect(synology.updateSynologySettings).not.toHaveBeenCalled(); }); it('200 PUT settings delegates when valid', async () => { synology.updateSynologySettings.mockResolvedValue({ success: true, data: 'settings updated' }); const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' }); expect(res.status).toBe(200); expect(res.body).toEqual('settings updated'); }); it('CRITICAL: 200 /status with { connected: false } on failure', async () => { synology.getSynologyStatus.mockResolvedValue({ success: true, data: { connected: false, error: 'Synology not configured' } }); const res = await request(server).get(`${SYNO}/status`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ connected: false, error: 'Synology not configured' }); }); it('CRITICAL: 200 /test missing fields -> 200 { connected: false, error } without calling service', async () => { const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas' }); expect(res.status).toBe(200); expect(res.body).toEqual({ connected: false, error: 'Username, Password are required' }); expect(synology.testSynologyConnection).not.toHaveBeenCalled(); }); it('200 /test delegates when all fields present', async () => { synology.testSynologyConnection.mockResolvedValue({ success: true, data: { connected: true, user: { name: 'admin' } } }); const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' }); expect(res.status).toBe(200); expect(res.body).toEqual({ connected: true, user: { name: 'admin' } }); }); it('200 albums / 200 album photos with passphrase', async () => { synology.listSynologyAlbums.mockResolvedValue({ success: true, data: { albums: [{ id: '1', albumName: 'A', assetCount: 3 }] } }); const albums = await request(server).get(`${SYNO}/albums`).set('Cookie', sessionCookie(1)); expect(albums.status).toBe(200); expect(albums.body).toEqual({ albums: [{ id: '1', albumName: 'A', assetCount: 3 }] }); synology.getSynologyAlbumPhotos.mockResolvedValue({ success: true, data: { assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false } }); const photos = await request(server).get(`${SYNO}/albums/1/photos?passphrase=secret`).set('Cookie', sessionCookie(1)); expect(photos.status).toBe(200); expect(photos.body).toEqual({ assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false }); expect(synology.getSynologyAlbumPhotos).toHaveBeenCalledWith(1, '1', 'secret'); }); it('200 search (POST stays 200) with offset/limit coercion', async () => { synology.searchSynologyPhotos.mockResolvedValue({ success: true, data: { assets: [], total: 0, hasMore: false } }); const res = await request(server).post(`${SYNO}/search`).set('Cookie', sessionCookie(1)).send({ page: 3, size: 20 }); expect(res.status).toBe(200); expect(res.body).toEqual({ assets: [], total: 0, hasMore: false }); // page=3 -> (3-1)=2; size=20 -> limit=20; offset = 2 * 20 = 40 expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(1, undefined, undefined, 40, 20); }); it('200 album sync (POST stays 200)', async () => { synology.syncSynologyAlbumLink.mockResolvedValue({ success: true, data: { added: 2, total: 5 } }); const res = await request(server).post(`${SYNO}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ added: 2, total: 5 }); }); it('200 asset info / 403 distinct synology string on no access', async () => { synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: '40808_1', takenAt: null } }); const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/info`).set('Cookie', sessionCookie(1)); expect(ok.status).toBe(200); expect(ok.body).toEqual({ id: '40808_1', takenAt: null }); canAccessUserPhoto.mockReturnValue(false); const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/info`).set('Cookie', sessionCookie(1)); expect(forbidden.status).toBe(403); expect(forbidden.body).toEqual({ error: "You don't have access to this photo" }); }); it('400 invalid asset kind / 403 no access / stream on valid kind', async () => { const invalid = await request(server).get(`${SYNO}/assets/5/40808_1/1/bogus`).set('Cookie', sessionCookie(1)); expect(invalid.status).toBe(400); expect(invalid.body).toEqual({ error: 'Invalid asset kind' }); canAccessUserPhoto.mockReturnValue(false); const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/thumbnail`).set('Cookie', sessionCookie(1)); expect(forbidden.status).toBe(403); expect(forbidden.body).toEqual({ error: "You don't have access to this photo" }); canAccessUserPhoto.mockReturnValue(true); synology.streamSynologyAsset.mockImplementation(async (res: any) => { res.status(200); res.set('Content-Type', 'image/jpeg'); res.end(Buffer.from('syno-bytes')); }); const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/thumbnail?size=xl`).set('Cookie', sessionCookie(1)); expect(ok.status).toBe(200); expect(ok.headers['content-type']).toContain('image/jpeg'); expect(synology.streamSynologyAsset).toHaveBeenCalledWith(expect.anything(), 1, 1, '40808_1', 'thumbnail', 'xl', undefined); }); }); });