Files
TREK/server/tests/e2e/memories.e2e.test.ts
T
Maurice 790dd3b728 refactor(server): break the memories import cycle
immich -> unified -> photoResolver -> immich was a real cycle, every edge a
value import; journeyService only got past it with a dynamic import(). Nothing
in that cluster can become a provider while it stands, because Nest would need a
forwardRef through three modules.

It had exactly one cause: syncAlbumAssets and syncSynologyAlbumLink called
addTripPhotos, which lives in unifiedService. Both functions are two things
stapled together - fetch the album from the provider, then write it to the trip
- and only the first half is provider-specific.

So the providers keep the half they own (collectAlbumSelection,
collectSynologyAlbumSelection) and unifiedService keeps the half it owns
(syncImmichAlbum, syncSynologyAlbum). Every error string, status code and return
shape is the one the combined function produced, including the per-provider
wording the split had to preserve ('Could not reach Immich' stays in immich).

The plan called for pulling addTripPhotos and getOrCreateTrekPhoto into one
neutral module instead. Together they do not work as a pair: such a module still
needs immich and synology to fetch, so the cycle only moves.

There is a second cut that works - photoResolverService is really two files, and
its trek_photos CRUD half touches no provider, so splitting that out also opens
both loops with less churn. It is not the one taken here because it leaves
immich -> unified standing, i.e. a provider depending on the trip aggregate.
That direction is what tied the knot in the first place. The CRUD split is still
worth doing, but as the photos/ vs memories/ module boundary, not as the cycle
fix.

server/src/services/memories/ is a DAG now: helpers <- immich/synology <-
photoResolver <- unified.
2026-08-08 12:43:29 +02:00

416 lines
23 KiB
TypeScript

/**
* 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(),
// The album-sync orchestration moved here from the provider modules when the
// immich -> unified import cycle was broken.
syncImmichAlbum: vi.fn(), syncSynologyAlbum: 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(), collectAlbumSelection: 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(),
collectSynologyAlbumSelection: 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<typeof import('../../src/services/memories/helpersService')>(
'../../src/services/memories/helpersService',
);
return { ...actual, canAccessUserPhoto };
});
import { MemoriesModule } from '../../src/nest/memories/memories.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.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<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [RealtimeModule, 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 () => {
unified.syncImmichAlbum.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 });
unified.syncImmichAlbum.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 () => {
unified.syncSynologyAlbum.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);
});
});
});