File size: 23,177 Bytes
1f5ea39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
/**
 * 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<typeof import('../../src/services/memories/helpersService')>(
    '../../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<ReturnType<typeof build>>;

  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);
    });
  });
});