mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
622 lines
28 KiB
TypeScript
622 lines
28 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 { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { ImmichService } from '../../src/nest/memories/immich.service';
|
|
import { MemoriesAccessService } from '../../src/nest/memories/memories-access.service';
|
|
import { MemoriesModule } from '../../src/nest/memories/memories.module';
|
|
import { SynologyService } from '../../src/nest/memories/synology.service';
|
|
import { UnifiedMemoriesService } from '../../src/nest/memories/unified-memories.service';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { seedUser, sessionCookie } from './harness';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
|
|
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: () => {} }));
|
|
|
|
// 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() }));
|
|
|
|
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: [
|
|
await TestUnitOfWorkModule.forRoot(db),
|
|
await createTestMikroOrmModule(db),
|
|
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());
|
|
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
|
|
// by metatype, exactly as they do under buildApp().
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
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.saveImmichSettings).toHaveBeenCalledWith(1, 'https://x', 'k', expect.anything(), undefined, true);
|
|
expect(immich.setImmichAutoUpload).not.toHaveBeenCalled();
|
|
|
|
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('PUT settings hands the self-signed switch to the save, and leaves it undefined when absent (#2475)', async () => {
|
|
immich.saveImmichSettings.mockResolvedValue({ success: true });
|
|
const on = await request(server)
|
|
.put(`${IMMICH}/settings`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: true });
|
|
expect(on.status).toBe(200);
|
|
expect(immich.saveImmichSettings).toHaveBeenLastCalledWith(
|
|
1,
|
|
'https://x',
|
|
'k',
|
|
expect.anything(),
|
|
true,
|
|
undefined,
|
|
);
|
|
|
|
await request(server)
|
|
.put(`${IMMICH}/settings`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ immich_url: 'https://x', immich_api_key: 'k' });
|
|
expect(immich.saveImmichSettings).toHaveBeenLastCalledWith(
|
|
1,
|
|
'https://x',
|
|
'k',
|
|
expect.anything(),
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('400 PUT settings and /test with a switch value that is not a boolean, before the service runs', async () => {
|
|
const put = await request(server)
|
|
.put(`${IMMICH}/settings`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: 'true' });
|
|
expect(put.status).toBe(400);
|
|
const test = await request(server)
|
|
.post(`${IMMICH}/test`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: 1 });
|
|
expect(test.status).toBe(400);
|
|
expect(immich.saveImmichSettings).not.toHaveBeenCalled();
|
|
expect(immich.testConnection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('200 /test probes with the switch the form carries', async () => {
|
|
immich.testConnection.mockResolvedValue({ connected: false, error: 'HTTP 404' });
|
|
const res = await request(server)
|
|
.post(`${IMMICH}/test`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: true });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ connected: false, error: 'HTTP 404' });
|
|
expect(immich.testConnection).toHaveBeenCalledWith('https://x', 'k', true);
|
|
});
|
|
|
|
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,
|
|
);
|
|
});
|
|
});
|
|
});
|