mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
1e35a99c11
"This day" sent the entry's calendar date to the provider as both bounds, and both providers turned that date into a UTC window — Immich by concatenating a Z suffix, Synology by relying on a date-only string parsing as UTC midnight. For a reader at UTC+10 the searched window was 10:00 on the day to 09:59 the next morning, so every breakfast photo was missing while the next morning's turned up in its place, and nothing said the day was incomplete. The date headings had the mirror defect, filing a 07:32 photo under the previous day. Immich publishes the right anchor already: localDateTime is required on every search result and is what its own timeline groups by. The window is padded by a day on each side and the page narrowed again by each photo's own local date, carried through as localTakenAt; a server that does not send it falls back to the timestamp used until now, so nothing changes there. Because the padding means raw rows and answered rows are no longer the same thing, the page is filled server-side until it holds what the caller asked for, bounded so a pathological album cannot spin. Synology has no local stamp, so its anchor comes from the caller as a UTC offset, resolved for the day being searched rather than for today — a day looked up across a DST boundary would otherwise start an hour out. The MCP tool takes the same optional input, defaulting to the behaviour it has now. The picker's pagination sentinel moved out of the non-empty branch: a page the new filter empties has to keep paging, and it now says so instead of claiming there are no photos. Closes #2336
432 lines
24 KiB
TypeScript
432 lines
24 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);`);
|
|
// StorageRegistryService (behind StorageModule, now in this module chain) reads
|
|
// this at onModuleInit.
|
|
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
|
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(),
|
|
},
|
|
}));
|
|
// The three provider services and the access check are injected since the fold,
|
|
// so they are overridden at the container instead of mocked by module path.
|
|
const { canAccessUserPhoto } = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn() }));
|
|
|
|
import { DatabaseModule } from '../../src/nest/database/database.module';
|
|
import { MemoriesModule } from '../../src/nest/memories/memories.module';
|
|
import { UnifiedMemoriesService } from '../../src/nest/memories/unified-memories.service';
|
|
import { ImmichService } from '../../src/nest/memories/immich.service';
|
|
import { SynologyService } from '../../src/nest/memories/synology.service';
|
|
import { MemoriesAccessService } from '../../src/nest/memories/memories-access.service';
|
|
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: [DatabaseModule, RealtimeModule, MemoriesModule] })
|
|
.overrideProvider(UnifiedMemoriesService).useValue(unified)
|
|
.overrideProvider(ImmichService).useValue(immich)
|
|
.overrideProvider(SynologyService).useValue(synology)
|
|
.overrideProvider(MemoriesAccessService).useValue({ canAccessUserPhoto })
|
|
.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, 0);
|
|
});
|
|
|
|
it('200 search carries the zone the dates are meant in, separately from the row offset', 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({ from: '2026-03-15', to: '2026-03-15', offset: 5, utc_offset_minutes: 600 });
|
|
expect(res.status).toBe(200);
|
|
// Two numbers that mean nothing alike and must not swap places (#2336).
|
|
expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(1, '2026-03-15', '2026-03-15', 5, 100, 600);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|