import { describe, it, expect, vi } from 'vitest'; import type { Response } from 'express'; import type { Request } from 'express'; import { UnifiedMemoriesController } from '../../../src/nest/memories/unified.controller'; import { ImmichMemoriesController } from '../../../src/nest/memories/immich.controller'; import { SynologyMemoriesController } from '../../../src/nest/memories/synology.controller'; import type { MemoriesService } from '../../../src/nest/memories/memories.service'; import type { User } from '../../../src/types'; const { getClientIp } = vi.hoisted(() => ({ getClientIp: vi.fn(() => '1.2.3.4') })); vi.mock('../../../src/services/auditLog', () => ({ getClientIp })); const user = { id: 7, role: 'user', email: 'u@example.test' } as User; function makeService(overrides: Partial = {}): MemoriesService { return { ...overrides } as unknown as MemoriesService; } type MockRes = Response & { status: ReturnType; json: ReturnType; statusCode: number; }; function makeRes(): MockRes { const res = { statusCode: 200, status: vi.fn(function (this: unknown, c: number) { (res as { statusCode: number }).statusCode = c; return res; }), json: vi.fn(function () { return res; }), }; return res as unknown as MockRes; } // ───────────────────────────────────────────────────────────────────────────── describe('UnifiedMemoriesController (parity with /api/integrations/memories/unified)', () => { describe('GET /trips/:tripId/photos', () => { it('returns the photos on success', () => { const svc = makeService({ listTripPhotos: vi.fn().mockReturnValue({ data: [{ id: 1 }] }) }); const res = makeRes(); new UnifiedMemoriesController(svc).listPhotos(user, '5', res); expect(svc.listTripPhotos).toHaveBeenCalledWith('5', 7); expect(res.json).toHaveBeenCalledWith({ photos: [{ id: 1 }] }); expect(res.status).not.toHaveBeenCalled(); }); it('maps the error envelope to its status + message', () => { const svc = makeService({ listTripPhotos: vi.fn().mockReturnValue({ error: { status: 404, message: 'Trip not found' } }) }); const res = makeRes(); new UnifiedMemoriesController(svc).listPhotos(user, '5', res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Trip not found' }); }); }); describe('POST /trips/:tripId/photos', () => { it('defaults shared to true and selections to [] when both are absent', async () => { const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 3 } }); const svc = makeService({ addTripPhotos }); const res = makeRes(); await new UnifiedMemoriesController(svc).addPhotos(user, '5', {}, 'sock', res); expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock'); expect(res.json).toHaveBeenCalledWith({ success: true, added: 3 }); }); it('coerces a falsy shared flag and forwards an array of selections', async () => { const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } }); const svc = makeService({ addTripPhotos }); const selections = [{ provider: 'immich', asset_ids: ['a'] }]; await new UnifiedMemoriesController(svc).addPhotos(user, '5', { shared: 0, selections }, 'sock', makeRes()); expect(addTripPhotos).toHaveBeenCalledWith('5', 7, false, selections, 'sock'); }); it('ignores a non-array selections payload', async () => { const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } }); const svc = makeService({ addTripPhotos }); await new UnifiedMemoriesController(svc).addPhotos(user, '5', { selections: 'nope', shared: true }, 'sock', makeRes()); expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock'); }); it('maps the error envelope', async () => { const svc = makeService({ addTripPhotos: vi.fn().mockResolvedValue({ error: { status: 403, message: 'No access' } }) }); const res = makeRes(); await new UnifiedMemoriesController(svc).addPhotos(user, '5', {}, 'sock', res); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ error: 'No access' }); }); }); describe('PUT /trips/:tripId/photos/sharing', () => { it('coerces photo_id to a number and forwards shared', async () => { const setTripPhotoSharing = vi.fn().mockResolvedValue({ data: {} }); const svc = makeService({ setTripPhotoSharing }); const res = makeRes(); await new UnifiedMemoriesController(svc).setSharing(user, '5', { photo_id: '9', shared: true }, res); expect(setTripPhotoSharing).toHaveBeenCalledWith('5', 7, 9, true); expect(res.json).toHaveBeenCalledWith({ success: true }); }); it('maps the error envelope', async () => { const svc = makeService({ setTripPhotoSharing: vi.fn().mockResolvedValue({ error: { status: 404, message: 'Photo not found' } }) }); const res = makeRes(); await new UnifiedMemoriesController(svc).setSharing(user, '5', { photo_id: '9', shared: false }, res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Photo not found' }); }); }); describe('DELETE /trips/:tripId/photos', () => { it('removes the photo on success', () => { const removeTripPhoto = vi.fn().mockReturnValue({ data: {} }); const svc = makeService({ removeTripPhoto }); const res = makeRes(); new UnifiedMemoriesController(svc).removePhoto(user, '5', { photo_id: 11 }, res); expect(removeTripPhoto).toHaveBeenCalledWith('5', 7, 11); expect(res.json).toHaveBeenCalledWith({ success: true }); }); it('maps the error envelope', () => { const svc = makeService({ removeTripPhoto: vi.fn().mockReturnValue({ error: { status: 404, message: 'Photo not found' } }) }); const res = makeRes(); new UnifiedMemoriesController(svc).removePhoto(user, '5', { photo_id: 11 }, res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Photo not found' }); }); }); describe('GET /trips/:tripId/album-links', () => { it('returns the links on success', () => { const svc = makeService({ listTripAlbumLinks: vi.fn().mockReturnValue({ data: [{ id: 'l1' }] }) }); const res = makeRes(); new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res); expect(res.json).toHaveBeenCalledWith({ links: [{ id: 'l1' }] }); }); it('maps the error envelope', () => { const svc = makeService({ listTripAlbumLinks: vi.fn().mockReturnValue({ error: { status: 404, message: 'Trip not found' } }) }); const res = makeRes(); new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Trip not found' }); }); }); describe('POST /trips/:tripId/album-links', () => { it('forwards a coerced passphrase when present', () => { const createTripAlbumLink = vi.fn().mockReturnValue({ data: {} }); const svc = makeService({ createTripAlbumLink }); const res = makeRes(); new UnifiedMemoriesController(svc).createAlbumLink( user, '5', { provider: 'synologyphotos', album_id: 'a1', album_name: 'Trip', passphrase: 123 }, res, ); expect(createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'synologyphotos', 'a1', 'Trip', '123'); expect(res.json).toHaveBeenCalledWith({ success: true }); }); it('passes undefined when the passphrase is absent or empty', () => { const createTripAlbumLink = vi.fn().mockReturnValue({ data: {} }); const svc = makeService({ createTripAlbumLink }); new UnifiedMemoriesController(svc).createAlbumLink(user, '5', { provider: 'immich', album_id: 'a1', album_name: 'Trip', passphrase: '' }, makeRes()); expect(createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'immich', 'a1', 'Trip', undefined); }); it('maps the error envelope', () => { const svc = makeService({ createTripAlbumLink: vi.fn().mockReturnValue({ error: { status: 400, message: 'Invalid provider' } }) }); const res = makeRes(); new UnifiedMemoriesController(svc).createAlbumLink(user, '5', {}, res); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid provider' }); }); }); describe('DELETE /trips/:tripId/album-links/:linkId', () => { it('removes the link on success', () => { const removeAlbumLink = vi.fn().mockReturnValue({ data: {} }); const svc = makeService({ removeAlbumLink }); const res = makeRes(); new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res); expect(removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7); expect(res.json).toHaveBeenCalledWith({ success: true }); }); it('maps the error envelope', () => { const svc = makeService({ removeAlbumLink: vi.fn().mockReturnValue({ error: { status: 404, message: 'Link not found' } }) }); const res = makeRes(); new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Link not found' }); }); }); }); // ───────────────────────────────────────────────────────────────────────────── describe('ImmichMemoriesController (parity with /api/integrations/memories/immich)', () => { describe('GET /settings', () => { it('delegates to the service', () => { const immichGetConnectionSettings = vi.fn().mockReturnValue({ immich_url: 'u' }); const svc = makeService({ immichGetConnectionSettings }); expect(new ImmichMemoriesController(svc).getSettings(user)).toEqual({ immich_url: 'u' }); expect(immichGetConnectionSettings).toHaveBeenCalledWith(7); }); }); describe('PUT /settings', () => { const req = {} as Request; it('400 when the save fails', async () => { const svc = makeService({ immichSaveSettings: vi.fn().mockResolvedValue({ success: false, error: 'Bad URL' }) }); const res = makeRes(); await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k' }, req, res); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: 'Bad URL' }); }); it('applies auto_upload when it is a boolean and returns success', async () => { const immichSaveSettings = vi.fn().mockResolvedValue({ success: true }); const immichSetAutoUpload = vi.fn(); const svc = makeService({ immichSaveSettings, immichSetAutoUpload }); const res = makeRes(); await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k', auto_upload: true }, req, res); expect(immichSaveSettings).toHaveBeenCalledWith(7, 'x', 'k', '1.2.3.4'); expect(immichSetAutoUpload).toHaveBeenCalledWith(7, true); expect(res.json).toHaveBeenCalledWith({ success: true }); }); it('skips auto_upload when it is not a boolean', async () => { const immichSaveSettings = vi.fn().mockResolvedValue({ success: true }); const immichSetAutoUpload = vi.fn(); const svc = makeService({ immichSaveSettings, immichSetAutoUpload }); await new ImmichMemoriesController(svc).putSettings(user, { auto_upload: 'yes' as unknown as boolean }, req, makeRes()); expect(immichSetAutoUpload).not.toHaveBeenCalled(); }); it('returns the warning when the save carries one', async () => { const svc = makeService({ immichSaveSettings: vi.fn().mockResolvedValue({ success: true, warning: 'Unverified TLS' }) }); const res = makeRes(); await new ImmichMemoriesController(svc).putSettings(user, {}, req, res); expect(res.json).toHaveBeenCalledWith({ success: true, warning: 'Unverified TLS' }); }); }); describe('GET /status', () => { it('delegates to the service', async () => { const svc = makeService({ immichGetConnectionStatus: vi.fn().mockResolvedValue({ connected: true }) }); await expect(new ImmichMemoriesController(svc).getStatus(user)).resolves.toEqual({ connected: true }); }); }); describe('POST /test', () => { it('short-circuits to a 200 envelope when url is missing', async () => { const immichTestConnection = vi.fn(); const svc = makeService({ immichTestConnection }); expect(await new ImmichMemoriesController(svc).test({ immich_api_key: 'k' })).toEqual({ connected: false, error: 'URL and API key required' }); expect(immichTestConnection).not.toHaveBeenCalled(); }); it('short-circuits when the api key is missing', async () => { const immichTestConnection = vi.fn(); const svc = makeService({ immichTestConnection }); expect(await new ImmichMemoriesController(svc).test({ immich_url: 'u' })).toEqual({ connected: false, error: 'URL and API key required' }); expect(immichTestConnection).not.toHaveBeenCalled(); }); it('delegates when both are present', async () => { const immichTestConnection = vi.fn().mockResolvedValue({ connected: true }); const svc = makeService({ immichTestConnection }); expect(await new ImmichMemoriesController(svc).test({ immich_url: 'u', immich_api_key: 'k' })).toEqual({ connected: true }); expect(immichTestConnection).toHaveBeenCalledWith('u', 'k'); }); }); describe('GET /browse', () => { it('returns the buckets on success', async () => { const svc = makeService({ immichBrowseTimeline: vi.fn().mockResolvedValue({ buckets: [{ id: 'b' }] }) }); const res = makeRes(); await new ImmichMemoriesController(svc).browse(user, res); expect(res.json).toHaveBeenCalledWith({ buckets: [{ id: 'b' }] }); }); it('maps the error with its status', async () => { const svc = makeService({ immichBrowseTimeline: vi.fn().mockResolvedValue({ error: 'Not connected', status: 412 }) }); const res = makeRes(); await new ImmichMemoriesController(svc).browse(user, res); expect(res.status).toHaveBeenCalledWith(412); expect(res.json).toHaveBeenCalledWith({ error: 'Not connected' }); }); }); describe('POST /search', () => { it('clamps page to >=1 and size to <=200 and defaults both', async () => { const immichSearchPhotos = vi.fn().mockResolvedValue({ assets: [{ id: 'a' }], hasMore: true }); const svc = makeService({ immichSearchPhotos }); const res = makeRes(); await new ImmichMemoriesController(svc).search(user, { from: 'f', to: 't' }, res); expect(immichSearchPhotos).toHaveBeenCalledWith(7, 'f', 't', 1, 50); expect(res.json).toHaveBeenCalledWith({ assets: [{ id: 'a' }], hasMore: true }); }); it('floors a sub-1 page to 1 and caps an oversized size at 200', async () => { const immichSearchPhotos = vi.fn().mockResolvedValue({}); const svc = makeService({ immichSearchPhotos }); await new ImmichMemoriesController(svc).search(user, { page: 0, size: 9999 }, makeRes()); expect(immichSearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 1, 200); }); it('honours an explicit page and size within range', async () => { const immichSearchPhotos = vi.fn().mockResolvedValue({}); const svc = makeService({ immichSearchPhotos }); await new ImmichMemoriesController(svc).search(user, { page: 3, size: 25 }, makeRes()); expect(immichSearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 3, 25); }); it('defaults assets to [] and hasMore to false when omitted', async () => { const svc = makeService({ immichSearchPhotos: vi.fn().mockResolvedValue({}) }); const res = makeRes(); await new ImmichMemoriesController(svc).search(user, {}, res); expect(res.json).toHaveBeenCalledWith({ assets: [], hasMore: false }); }); it('maps the error envelope', async () => { const svc = makeService({ immichSearchPhotos: vi.fn().mockResolvedValue({ error: 'down', status: 502 }) }); const res = makeRes(); await new ImmichMemoriesController(svc).search(user, {}, res); expect(res.status).toHaveBeenCalledWith(502); expect(res.json).toHaveBeenCalledWith({ error: 'down' }); }); }); describe('GET /assets/:tripId/:assetId/:ownerId/info', () => { it('400 on an invalid asset id', async () => { const immichIsValidAssetId = vi.fn().mockReturnValue(false); const svc = makeService({ immichIsValidAssetId }); const res = makeRes(); await new ImmichMemoriesController(svc).assetInfo(user, '5', 'bad', '2', res); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid asset ID' }); }); it('403 when access is denied', async () => { const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(true), canAccessUserPhoto: vi.fn().mockReturnValue(false), }); const res = makeRes(); await new ImmichMemoriesController(svc).assetInfo(user, '5', 'a', '2', res); expect(svc.canAccessUserPhoto).toHaveBeenCalledWith(7, 2, '5', 'a', 'immich'); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' }); }); it('maps a service error after the guards pass', async () => { const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(true), canAccessUserPhoto: vi.fn().mockReturnValue(true), immichGetAssetInfo: vi.fn().mockResolvedValue({ error: 'Asset gone', status: 404 }), }); const res = makeRes(); await new ImmichMemoriesController(svc).assetInfo(user, '5', 'a', '2', res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Asset gone' }); }); it('returns the asset data on success', async () => { const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(true), canAccessUserPhoto: vi.fn().mockReturnValue(true), immichGetAssetInfo: vi.fn().mockResolvedValue({ data: { id: 'a', takenAt: 't' } }), }); const res = makeRes(); await new ImmichMemoriesController(svc).assetInfo(user, '5', 'a', '2', res); expect(res.json).toHaveBeenCalledWith({ id: 'a', takenAt: 't' }); }); }); describe('GET /assets/.../thumbnail + /original', () => { it('thumbnail: 400 on invalid id', async () => { const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(false) }); const res = makeRes(); await new ImmichMemoriesController(svc).assetThumbnail(user, '5', 'bad', '2', res); expect(res.status).toHaveBeenCalledWith(400); }); it('thumbnail: 403 when access denied', async () => { const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(true), canAccessUserPhoto: vi.fn().mockReturnValue(false), }); const res = makeRes(); await new ImmichMemoriesController(svc).assetThumbnail(user, '5', 'a', '2', res); expect(res.status).toHaveBeenCalledWith(403); }); it('thumbnail: streams with kind=thumbnail when allowed', async () => { const immichStreamAsset = vi.fn().mockResolvedValue(undefined); const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(true), canAccessUserPhoto: vi.fn().mockReturnValue(true), immichStreamAsset, }); const res = makeRes(); await new ImmichMemoriesController(svc).assetThumbnail(user, '5', 'a', '2', res); expect(immichStreamAsset).toHaveBeenCalledWith(res, 7, 'a', 'thumbnail', 2); }); it('original: 400 on invalid id', async () => { const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(false) }); const res = makeRes(); await new ImmichMemoriesController(svc).assetOriginal(user, '5', 'bad', '2', res); expect(res.status).toHaveBeenCalledWith(400); }); it('original: 403 when access denied', async () => { const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(true), canAccessUserPhoto: vi.fn().mockReturnValue(false), }); const res = makeRes(); await new ImmichMemoriesController(svc).assetOriginal(user, '5', 'a', '2', res); expect(res.status).toHaveBeenCalledWith(403); }); it('original: streams with kind=original when allowed', async () => { const immichStreamAsset = vi.fn().mockResolvedValue(undefined); const svc = makeService({ immichIsValidAssetId: vi.fn().mockReturnValue(true), canAccessUserPhoto: vi.fn().mockReturnValue(true), immichStreamAsset, }); const res = makeRes(); await new ImmichMemoriesController(svc).assetOriginal(user, '5', 'a', '2', res); expect(immichStreamAsset).toHaveBeenCalledWith(res, 7, 'a', 'original', 2); }); }); describe('GET /albums + /albums/:albumId/photos', () => { it('albums: returns the list on success', async () => { const svc = makeService({ immichListAlbums: vi.fn().mockResolvedValue({ albums: [{ id: 'a' }] }) }); const res = makeRes(); await new ImmichMemoriesController(svc).albums(user, res); expect(res.json).toHaveBeenCalledWith({ albums: [{ id: 'a' }] }); }); it('albums: maps the error envelope', async () => { const svc = makeService({ immichListAlbums: vi.fn().mockResolvedValue({ error: 'nope', status: 500 }) }); const res = makeRes(); await new ImmichMemoriesController(svc).albums(user, res); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'nope' }); }); it('albumPhotos: returns the assets on success', async () => { const svc = makeService({ immichGetAlbumPhotos: vi.fn().mockResolvedValue({ assets: [{ id: 'p' }] }) }); const res = makeRes(); await new ImmichMemoriesController(svc).albumPhotos(user, 'al1', res); expect(svc.immichGetAlbumPhotos).toHaveBeenCalledWith(7, 'al1'); expect(res.json).toHaveBeenCalledWith({ assets: [{ id: 'p' }] }); }); it('albumPhotos: maps the error envelope', async () => { const svc = makeService({ immichGetAlbumPhotos: vi.fn().mockResolvedValue({ error: 'gone', status: 404 }) }); const res = makeRes(); await new ImmichMemoriesController(svc).albumPhotos(user, 'al1', res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'gone' }); }); }); describe('POST /trips/:tripId/album-links/:linkId/sync', () => { it('maps the error envelope without broadcasting', async () => { const broadcast = vi.fn(); const svc = makeService({ immichSyncAlbumAssets: vi.fn().mockResolvedValue({ error: 'Link gone', status: 404 }), broadcast }); const res = makeRes(); await new ImmichMemoriesController(svc).sync(user, '5', 'l1', 'sock', res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Link gone' }); expect(broadcast).not.toHaveBeenCalled(); }); it('broadcasts when at least one asset was added', async () => { const broadcast = vi.fn(); const svc = makeService({ immichSyncAlbumAssets: vi.fn().mockResolvedValue({ added: 2, total: 10 }), broadcast }); const res = makeRes(); await new ImmichMemoriesController(svc).sync(user, '5', 'l1', 'sock', res); expect(res.json).toHaveBeenCalledWith({ success: true, added: 2, total: 10 }); expect(broadcast).toHaveBeenCalledWith('5', 'memories:updated', { userId: 7 }, 'sock'); }); it('does not broadcast when nothing was added', async () => { const broadcast = vi.fn(); const svc = makeService({ immichSyncAlbumAssets: vi.fn().mockResolvedValue({ added: 0, total: 10 }), broadcast }); const res = makeRes(); await new ImmichMemoriesController(svc).sync(user, '5', 'l1', 'sock', res); expect(res.json).toHaveBeenCalledWith({ success: true, added: 0, total: 10 }); expect(broadcast).not.toHaveBeenCalled(); }); }); }); // ───────────────────────────────────────────────────────────────────────────── describe('SynologyMemoriesController (parity with /api/integrations/memories/synologyphotos)', () => { describe('GET /settings + /status', () => { it('settings: returns the data on success', async () => { const svc = makeService({ synologyGetSettings: vi.fn().mockResolvedValue({ success: true, data: { synology_url: 'u' } }) }); const res = makeRes(); await new SynologyMemoriesController(svc).getSettings(user, res); expect(res.json).toHaveBeenCalledWith({ synology_url: 'u' }); }); it('settings: maps the error envelope', async () => { const svc = makeService({ synologyGetSettings: vi.fn().mockResolvedValue({ success: false, error: { status: 500, message: 'DB error' } }) }); const res = makeRes(); await new SynologyMemoriesController(svc).getSettings(user, res); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'DB error' }); }); it('status: delegates', async () => { const svc = makeService({ synologyGetStatus: vi.fn().mockResolvedValue({ success: true, data: { connected: true } }) }); const res = makeRes(); await new SynologyMemoriesController(svc).getStatus(user, res); expect(res.json).toHaveBeenCalledWith({ connected: true }); }); }); describe('PUT /settings', () => { it('400 when the url is missing', async () => { const synologyUpdateSettings = vi.fn(); const svc = makeService({ synologyUpdateSettings }); const res = makeRes(); await new SynologyMemoriesController(svc).putSettings(user, { synology_username: 'admin' }, res); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: 'URL and username are required' }); expect(synologyUpdateSettings).not.toHaveBeenCalled(); }); it('400 when the username is missing', async () => { const synologyUpdateSettings = vi.fn(); const svc = makeService({ synologyUpdateSettings }); const res = makeRes(); await new SynologyMemoriesController(svc).putSettings(user, { synology_url: 'http://nas' }, res); expect(res.status).toHaveBeenCalledWith(400); expect(synologyUpdateSettings).not.toHaveBeenCalled(); }); it('delegates with trimmed values and the boolean skip-ssl flag (true keyword)', async () => { const synologyUpdateSettings = vi.fn().mockResolvedValue({ success: true, data: {} }); const svc = makeService({ synologyUpdateSettings }); const res = makeRes(); await new SynologyMemoriesController( svc, ).putSettings(user, { synology_url: ' http://nas ', synology_username: ' admin ', synology_password: ' pw ', synology_skip_ssl: 'true' }, res); expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'http://nas', 'admin', 'pw', true); expect(res.json).toHaveBeenCalledWith({}); }); it('treats a literal-true skip-ssl flag as true and other values as false', async () => { const synologyUpdateSettings = vi.fn().mockResolvedValue({ success: true, data: {} }); const svc = makeService({ synologyUpdateSettings }); await new SynologyMemoriesController(svc).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: true }, makeRes()); expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', true); const svc2 = makeService({ synologyUpdateSettings: vi.fn().mockResolvedValue({ success: true, data: {} }) }); await new SynologyMemoriesController(svc2).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }, makeRes()); expect(svc2.synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', false); }); }); describe('POST /test', () => { it('reports a single missing field with "is required"', async () => { const synologyTestConnection = vi.fn(); const svc = makeService({ synologyTestConnection }); const res = makeRes(); await new SynologyMemoriesController(svc).test(user, { synology_url: 'u', synology_username: 'a' }, res); expect(res.json).toHaveBeenCalledWith({ connected: false, error: 'Password is required' }); expect(synologyTestConnection).not.toHaveBeenCalled(); }); it('reports multiple missing fields with "are required"', async () => { const svc = makeService({ synologyTestConnection: vi.fn() }); const res = makeRes(); await new SynologyMemoriesController(svc).test(user, {}, res); expect(res.json).toHaveBeenCalledWith({ connected: false, error: 'URL, Username, Password are required' }); }); it('delegates when every field is present (otp + skip-ssl forwarded)', async () => { const synologyTestConnection = vi.fn().mockResolvedValue({ success: true, data: { connected: true } }); const svc = makeService({ synologyTestConnection }); const res = makeRes(); await new SynologyMemoriesController( svc, ).test(user, { synology_url: 'u', synology_username: 'a', synology_password: 'p', synology_otp: '123', synology_skip_ssl: true }, res); expect(synologyTestConnection).toHaveBeenCalledWith(7, 'u', 'a', 'p', '123', true); expect(res.json).toHaveBeenCalledWith({ connected: true }); }); }); describe('GET /albums + /albums/:albumId/photos', () => { it('albums: delegates', async () => { const svc = makeService({ synologyListAlbums: vi.fn().mockResolvedValue({ success: true, data: { albums: [] } }) }); const res = makeRes(); await new SynologyMemoriesController(svc).albums(user, res); expect(res.json).toHaveBeenCalledWith({ albums: [] }); }); it('albumPhotos: forwards a coerced passphrase when present', async () => { const synologyGetAlbumPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); const svc = makeService({ synologyGetAlbumPhotos }); const res = makeRes(); await new SynologyMemoriesController(svc).albumPhotos(user, 'al1', 'secret', res); expect(synologyGetAlbumPhotos).toHaveBeenCalledWith(7, 'al1', 'secret'); expect(res.json).toHaveBeenCalledWith({ assets: [] }); }); it('albumPhotos: passes undefined when the passphrase query is absent', async () => { const synologyGetAlbumPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); const svc = makeService({ synologyGetAlbumPhotos }); await new SynologyMemoriesController(svc).albumPhotos(user, 'al1', undefined, makeRes()); expect(synologyGetAlbumPhotos).toHaveBeenCalledWith(7, 'al1', undefined); }); }); describe('POST /trips/:tripId/album-links/:linkId/sync', () => { it('delegates and unwraps the success envelope', async () => { const synologySyncAlbumLink = vi.fn().mockResolvedValue({ success: true, data: { added: 1, total: 2 } }); const svc = makeService({ synologySyncAlbumLink }); const res = makeRes(); await new SynologyMemoriesController(svc).sync(user, '5', 'l1', 'sock', res); expect(synologySyncAlbumLink).toHaveBeenCalledWith(7, '5', 'l1', 'sock'); expect(res.json).toHaveBeenCalledWith({ added: 1, total: 2 }); }); }); describe('POST /search', () => { it('uses the default offset/limit when nothing is provided', async () => { const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); const svc = makeService({ synologySearchPhotos }); await new SynologyMemoriesController(svc).search(user, {}, makeRes()); expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100); }); it('forwards from/to and uses size as the limit when size > 0', async () => { const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); const svc = makeService({ synologySearchPhotos }); await new SynologyMemoriesController(svc).search(user, { from: '2024-01-01', to: '2024-02-01', size: 30 }, makeRes()); expect(synologySearchPhotos).toHaveBeenCalledWith(7, '2024-01-01', '2024-02-01', 0, 30); }); it('derives the offset from a 1-based page using the limit', async () => { const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); const svc = makeService({ synologySearchPhotos }); await new SynologyMemoriesController(svc).search(user, { page: 3, limit: 20 }, makeRes()); // page-1 = 2, offset = 2 * 20 = 40 expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 40, 20); }); it('keeps the explicit offset when page resolves to <= 0', async () => { const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); const svc = makeService({ synologySearchPhotos }); await new SynologyMemoriesController(svc).search(user, { page: 1, offset: 5, limit: 10 }, makeRes()); expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 5, 10); }); it('falls back to defaults when numeric fields are non-finite', async () => { const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } }); const svc = makeService({ synologySearchPhotos }); await new SynologyMemoriesController(svc).search(user, { offset: 'x', limit: 'y', page: 'z', size: 'q' }, makeRes()); expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100); }); }); describe('GET /assets/:tripId/:photoId/:ownerId/info', () => { it('403 when access is denied', async () => { const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(false) }); const res = makeRes(); await new SynologyMemoriesController(svc).assetInfo(user, '5', 'p1', '2', undefined, res); expect(svc.canAccessUserPhoto).toHaveBeenCalledWith(7, 2, '5', 'p1', 'synologyphotos'); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ error: "You don't have access to this photo" }); }); it('delegates with the coerced passphrase when access is granted', async () => { const synologyGetAssetInfo = vi.fn().mockResolvedValue({ success: true, data: { id: 'p1' } }); const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyGetAssetInfo }); const res = makeRes(); await new SynologyMemoriesController(svc).assetInfo(user, '5', 'p1', '2', 'secret', res); expect(synologyGetAssetInfo).toHaveBeenCalledWith(7, 'p1', 2, 'secret'); expect(res.json).toHaveBeenCalledWith({ id: 'p1' }); }); it('passes undefined passphrase when the query is absent', async () => { const synologyGetAssetInfo = vi.fn().mockResolvedValue({ success: true, data: { id: 'p1' } }); const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyGetAssetInfo }); await new SynologyMemoriesController(svc).assetInfo(user, '5', 'p1', '2', undefined, makeRes()); expect(synologyGetAssetInfo).toHaveBeenCalledWith(7, 'p1', 2, undefined); }); }); describe('GET /assets/:tripId/:photoId/:ownerId/:kind', () => { it('400 on an invalid kind', async () => { const synologyStreamAsset = vi.fn(); const svc = makeService({ synologyStreamAsset }); const res = makeRes(); await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'preview', undefined, undefined, res); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid asset kind' }); expect(synologyStreamAsset).not.toHaveBeenCalled(); }); it('403 when access is denied for a valid kind', async () => { const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(false) }); const res = makeRes(); await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'thumbnail', undefined, undefined, res); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ error: "You don't have access to this photo" }); }); it('streams a thumbnail, defaulting size to "sm" when omitted', async () => { const synologyStreamAsset = vi.fn().mockResolvedValue(undefined); const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyStreamAsset }); const res = makeRes(); await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'thumbnail', undefined, undefined, res); expect(synologyStreamAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'thumbnail', 'sm', undefined); }); it('keeps a whitelisted size and forwards the passphrase for an original', async () => { const synologyStreamAsset = vi.fn().mockResolvedValue(undefined); const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyStreamAsset }); const res = makeRes(); await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'original', 'xl', 'secret', res); expect(synologyStreamAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'original', 'xl', 'secret'); }); it('coerces a non-whitelisted size back to "sm"', async () => { const synologyStreamAsset = vi.fn().mockResolvedValue(undefined); const svc = makeService({ canAccessUserPhoto: vi.fn().mockReturnValue(true), synologyStreamAsset }); const res = makeRes(); await new SynologyMemoriesController(svc).asset(user, '5', 'p1', '2', 'thumbnail', 'huge', undefined, res); expect(synologyStreamAsset).toHaveBeenCalledWith(res, 7, 2, 'p1', 'thumbnail', 'sm', undefined); }); }); });