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
441 lines
20 KiB
TypeScript
441 lines
20 KiB
TypeScript
/**
|
|
* SynologyService — credentials, the SID session with its retry, album listing
|
|
* across three sources, and the asset paths.
|
|
*
|
|
* Same gap as Immich: the provider arrived from services/memories/ at ~60%
|
|
* branch coverage because that tree is outside the gate, and the integration
|
|
* suite drives it over HTTP without reaching the failure paths. The session
|
|
* retry in particular — Synology invalidates a SID on timeout (106), a
|
|
* duplicate login (107) or an unknown SID (119), and the service is supposed to
|
|
* re-login once and repeat the call — had no case at all.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
return {
|
|
testDb: db,
|
|
dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null },
|
|
};
|
|
});
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'x'.repeat(40),
|
|
ENCRYPTION_KEY: 'a'.repeat(64),
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
const { decryptMock } = vi.hoisted(() => ({ decryptMock: vi.fn((v: string) => v) }));
|
|
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
|
decrypt_api_key: decryptMock,
|
|
encrypt_api_key: (v: string) => v,
|
|
maybe_encrypt_api_key: (v: string) => v,
|
|
}));
|
|
|
|
const { safeFetch, checkSsrf, SsrfBlockedError } = vi.hoisted(() => {
|
|
class SsrfBlockedError extends Error {}
|
|
return { safeFetch: vi.fn(), checkSsrf: vi.fn(), SsrfBlockedError };
|
|
});
|
|
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
|
safeFetch, checkSsrf, SsrfBlockedError, createPinnedDispatcher: vi.fn(() => ({})),
|
|
}));
|
|
// The service fires the session-cleared notice and .catch()es it, so the stub
|
|
// has to be a promise.
|
|
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { SynologyService } from '../../../src/nest/memories/synology.service';
|
|
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
|
|
import { notificationsStub } from '../../helpers/notifications';
|
|
|
|
const access = { getAlbumLinkForSync: vi.fn(), updateSyncTimeForAlbumLink: vi.fn() };
|
|
const svc = new SynologyService(new DatabaseService(testDb), access as unknown as MemoriesAccessService, notificationsStub());
|
|
|
|
const USER = 1;
|
|
|
|
function seedUser(id: number, cols: Partial<Record<string, unknown>> = {}): void {
|
|
const base = { synology_url: 'https://nas.test', synology_username: 'ada', synology_password: 'pw', synology_sid: 'sid-1', synology_did: null, synology_skip_ssl: 1 };
|
|
const row = { ...base, ...cols };
|
|
testDb.prepare(
|
|
`INSERT OR REPLACE INTO users (id, username, email, password_hash, synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl)
|
|
VALUES (?, ?, ?, 'x', ?, ?, ?, ?, ?, ?)`
|
|
).run(id, `u${id}`, `u${id}@example.test`, row.synology_url, row.synology_username, row.synology_password, row.synology_sid, row.synology_did, row.synology_skip_ssl);
|
|
}
|
|
|
|
/** A Synology API envelope: { success, data } or { success:false, error:{ code } }. */
|
|
function api(data: unknown) {
|
|
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data }), arrayBuffer: async () => Buffer.from('x') };
|
|
}
|
|
function apiError(code: number) {
|
|
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code } }), arrayBuffer: async () => Buffer.from('x') };
|
|
}
|
|
function httpError(status: number) {
|
|
return { ok: false, status, headers: { get: () => null }, json: async () => ({}), arrayBuffer: async () => Buffer.from('x') };
|
|
}
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
decryptMock.mockImplementation((v: string) => v);
|
|
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
|
testDb.prepare('DELETE FROM users').run();
|
|
seedUser(USER);
|
|
});
|
|
|
|
afterAll(() => testDb.close());
|
|
|
|
describe('credentials', () => {
|
|
it('SYNO-U001: an unknown user is 404, not "not configured"', async () => {
|
|
const result = await svc.getSynologySettings(999);
|
|
expect(result).toEqual({ success: false, error: { message: 'User not found', status: 404 } });
|
|
});
|
|
|
|
it('SYNO-U002: a half-filled row is "Synology not configured"', async () => {
|
|
seedUser(2, { synology_password: null });
|
|
const result = await svc.getSynologySettings(2);
|
|
expect(result).toEqual({ success: false, error: { message: 'Synology not configured', status: 400 } });
|
|
});
|
|
|
|
it('SYNO-U003: a password that will not decrypt is a 500, not a silent retry', async () => {
|
|
decryptMock.mockReturnValue(null);
|
|
const result = await svc.getSynologySettings(USER);
|
|
expect(result).toEqual({ success: false, error: { message: 'Synology credentials corrupted', status: 500 } });
|
|
});
|
|
|
|
it('SYNO-U004: skip_ssl is read as a boolean, 0 meaning verify', async () => {
|
|
seedUser(3, { synology_skip_ssl: 0 });
|
|
const result = await svc.getSynologySettings(3);
|
|
expect(result.success && result.data.synology_skip_ssl).toBe(false);
|
|
});
|
|
|
|
it('SYNO-U005: settings report the URL, the username and a live session', async () => {
|
|
const result = await svc.getSynologySettings(USER);
|
|
expect(result.success && result.data).toMatchObject({ synology_url: 'https://nas.test', synology_username: 'ada', connected: true });
|
|
});
|
|
});
|
|
|
|
describe('the session', () => {
|
|
it('SYNO-U010: a cached SID is used without logging in', async () => {
|
|
safeFetch.mockResolvedValue(api({ list: [] }));
|
|
await svc.searchSynologyPhotos(USER);
|
|
const bodies = safeFetch.mock.calls.map(c => String((c[1] as { body: URLSearchParams }).body));
|
|
expect(bodies.some(b => b.includes('SYNO.API.Auth'))).toBe(false);
|
|
});
|
|
|
|
it('SYNO-U011: a SID that will not decrypt is cleared and a fresh login happens', async () => {
|
|
decryptMock.mockImplementation((v: string) => (v === 'sid-1' ? null : v));
|
|
safeFetch.mockResolvedValueOnce(api({ sid: 'sid-2' })).mockResolvedValueOnce(api({ list: [] }));
|
|
|
|
await svc.searchSynologyPhotos(USER);
|
|
|
|
const first = String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body);
|
|
expect(first).toContain('SYNO.API.Auth');
|
|
});
|
|
|
|
it('SYNO-U012: a login that returns no sid is a 500', async () => {
|
|
seedUser(4, { synology_sid: null });
|
|
safeFetch.mockResolvedValue(api({}));
|
|
const result = await svc.searchSynologyPhotos(4);
|
|
expect(result).toEqual({ success: false, error: { message: 'Failed to get session ID from Synology', status: 500 } });
|
|
});
|
|
|
|
it('SYNO-U013: a stored device id rides along so a trusted device skips OTP', async () => {
|
|
seedUser(5, { synology_sid: null, synology_did: 'device-1' });
|
|
safeFetch.mockResolvedValueOnce(api({ sid: 's' })).mockResolvedValueOnce(api({ list: [] }));
|
|
|
|
await svc.searchSynologyPhotos(5);
|
|
|
|
expect(String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body)).toContain('device_id=device-1');
|
|
});
|
|
|
|
it.each([106, 107, 119])('SYNO-U014: error %i clears the SID, re-logs in and repeats the call', async (code) => {
|
|
safeFetch
|
|
.mockResolvedValueOnce(apiError(code)) // the call, with a dead SID
|
|
.mockResolvedValueOnce(api({ sid: 'sid-2' })) // the re-login
|
|
.mockResolvedValueOnce(api({ list: [] })); // the retry
|
|
|
|
const result = await svc.searchSynologyPhotos(USER);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(String((safeFetch.mock.calls[1][1] as { body: URLSearchParams }).body)).toContain('SYNO.API.Auth');
|
|
});
|
|
|
|
it('SYNO-U015: an app-level error that is NOT a session code is not retried', async () => {
|
|
safeFetch.mockResolvedValue(apiError(105));
|
|
const result = await svc.searchSynologyPhotos(USER);
|
|
expect(result.success).toBe(false);
|
|
// one call only — no re-login, no repeat
|
|
expect(safeFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('the API envelope', () => {
|
|
it('SYNO-U020: an HTTP failure carries the upstream status', async () => {
|
|
safeFetch.mockResolvedValue(httpError(502));
|
|
const result = await svc.searchSynologyPhotos(USER);
|
|
expect(result).toEqual({ success: false, error: { message: 'Synology API request failed with status 502', status: 502 } });
|
|
});
|
|
|
|
it('SYNO-U021: a known app error code becomes its documented message at HTTP 400', async () => {
|
|
safeFetch.mockResolvedValue(apiError(400));
|
|
const result = await svc.searchSynologyPhotos(USER);
|
|
expect(result.success).toBe(false);
|
|
expect((result as { error: { status: number } }).error.status).toBe(400);
|
|
});
|
|
|
|
it('SYNO-U022: an unknown code still produces a message rather than undefined', async () => {
|
|
safeFetch.mockResolvedValue(apiError(99999));
|
|
const result = await svc.searchSynologyPhotos(USER);
|
|
expect((result as { error: { message: string } }).error.message).toContain('99999');
|
|
});
|
|
|
|
it('SYNO-U023: an SSRF block is a 400 with the guard\'s own message', async () => {
|
|
safeFetch.mockRejectedValue(new SsrfBlockedError('blocked host'));
|
|
const result = await svc.searchSynologyPhotos(USER);
|
|
expect(result).toEqual({ success: false, error: { message: 'blocked host', status: 400 } });
|
|
});
|
|
|
|
it('SYNO-U024: any other transport failure is a 500', async () => {
|
|
safeFetch.mockRejectedValue(new Error('ECONNRESET'));
|
|
const result = await svc.searchSynologyPhotos(USER);
|
|
expect(result).toEqual({ success: false, error: { message: 'Failed to connect to Synology API', status: 500 } });
|
|
});
|
|
});
|
|
|
|
describe('updateSynologySettings', () => {
|
|
it('SYNO-U030: refuses a URL the SSRF guard blocks', async () => {
|
|
checkSsrf.mockResolvedValue({ allowed: false, error: 'private range' });
|
|
const result = await svc.updateSynologySettings(USER, 'http://10.0.0.1', 'ada', 'pw');
|
|
expect(result).toEqual({ success: false, error: { message: 'private range', status: 400 } });
|
|
});
|
|
|
|
it('SYNO-U031: keeps the stored password when none is supplied', async () => {
|
|
safeFetch.mockResolvedValue(api({ sid: 's' }));
|
|
await svc.updateSynologySettings(USER, 'https://nas2.test', 'ada');
|
|
const row = testDb.prepare('SELECT synology_password, synology_url FROM users WHERE id = ?').get(USER) as { synology_password: string; synology_url: string };
|
|
expect(row.synology_password).toBe('pw');
|
|
expect(row.synology_url).toBe('https://nas2.test');
|
|
});
|
|
});
|
|
|
|
describe('testSynologyConnection', () => {
|
|
it('SYNO-U040: sends the OTP and asks for a device token when one is given', async () => {
|
|
safeFetch.mockResolvedValue(api({ sid: 's', did: 'd' }));
|
|
|
|
await svc.testSynologyConnection(USER, 'https://nas.test', 'ada', 'pw', '123456');
|
|
|
|
const body = String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body);
|
|
expect(body).toContain('otp_code=123456');
|
|
expect(body).toContain('enable_device_token=yes');
|
|
});
|
|
|
|
it('SYNO-U041: omits the OTP fields when none is given', async () => {
|
|
safeFetch.mockResolvedValue(api({ sid: 's' }));
|
|
|
|
await svc.testSynologyConnection(USER, 'https://nas.test', 'ada', 'pw');
|
|
|
|
expect(String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body)).not.toContain('otp_code');
|
|
});
|
|
});
|
|
|
|
describe('listSynologyAlbums', () => {
|
|
it('SYNO-U050: merges personal, shared and shared-with-me, carrying each passphrase', async () => {
|
|
safeFetch
|
|
.mockResolvedValueOnce(api({ list: [{ id: 1, name: 'Personal', item_count: 2 }] }))
|
|
.mockResolvedValueOnce(api({ list: [{ id: 2, name: 'Shared', item_count: 1, passphrase: 'p2' }] }))
|
|
.mockResolvedValueOnce(api({ list: [{ id: 3, name: 'WithMe', item_count: 3, sharing_info: { passphrase: 'p3' } }] }));
|
|
|
|
const result = await svc.listSynologyAlbums(USER);
|
|
|
|
const albums = (result as { data: { albums: { id: string; passphrase?: string }[] } }).data.albums;
|
|
expect(albums.map(a => a.id).sort()).toEqual(['1', '2', '3']);
|
|
expect(albums.find(a => a.id === '2')!.passphrase).toBe('p2');
|
|
expect(albums.find(a => a.id === '3')!.passphrase).toBe('p3');
|
|
});
|
|
|
|
it('SYNO-U051: a partial source failure still returns the albums that came back', async () => {
|
|
safeFetch
|
|
.mockResolvedValueOnce(api({ list: [{ id: 1, name: 'Personal' }] }))
|
|
.mockResolvedValueOnce(apiError(105))
|
|
.mockResolvedValueOnce(apiError(105));
|
|
|
|
const result = await svc.listSynologyAlbums(USER);
|
|
|
|
expect((result as { data: { albums: unknown[] } }).data.albums).toHaveLength(1);
|
|
});
|
|
|
|
it('SYNO-U052: the same album id from two sources collapses to one entry', async () => {
|
|
safeFetch
|
|
.mockResolvedValueOnce(api({ list: [{ id: 9, name: 'Dup' }] }))
|
|
.mockResolvedValueOnce(api({ list: [] }))
|
|
.mockResolvedValueOnce(api({ list: [{ id: 9, name: 'Dup', sharing_info: { passphrase: 'p9' } }] }));
|
|
|
|
const result = await svc.listSynologyAlbums(USER);
|
|
|
|
const albums = (result as { data: { albums: { id: string; passphrase?: string }[] } }).data.albums;
|
|
expect(albums).toHaveLength(1);
|
|
// last write wins, so the shared-with-me passphrase survives
|
|
expect(albums[0].passphrase).toBe('p9');
|
|
});
|
|
|
|
it('SYNO-U053: when every source fails, the personal failure is surfaced', async () => {
|
|
safeFetch.mockResolvedValue(httpError(500));
|
|
const result = await svc.listSynologyAlbums(USER);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getSynologyAlbumPhotos', () => {
|
|
it('SYNO-U060: pages until a short page and keys assets by the thumbnail cache key', async () => {
|
|
const page = (n: number) => api({ list: Array.from({ length: n }, (_, i) => ({ id: i, time: 1700000000, additional: { thumbnail: { cache_key: `ck-${i}` } } })) });
|
|
safeFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3));
|
|
|
|
const result = await svc.getSynologyAlbumPhotos(USER, '7');
|
|
|
|
const assets = (result as { data: { assets: { id: string }[] } }).data.assets;
|
|
expect(assets).toHaveLength(53);
|
|
expect(assets[0].id).toBe('ck-0');
|
|
});
|
|
|
|
it('SYNO-U061: a passphrase album queries by passphrase, not album_id', async () => {
|
|
safeFetch.mockResolvedValue(api({ list: [] }));
|
|
|
|
await svc.getSynologyAlbumPhotos(USER, '7', 'secret');
|
|
|
|
const body = String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body);
|
|
expect(body).toContain('passphrase=secret');
|
|
expect(body).not.toContain('album_id');
|
|
});
|
|
|
|
it('SYNO-U062: an upstream failure is passed straight through', async () => {
|
|
safeFetch.mockResolvedValue(httpError(503));
|
|
expect((await svc.getSynologyAlbumPhotos(USER, '7')).success).toBe(false);
|
|
});
|
|
|
|
it('SYNO-U063: asks Browse.Item for no ordering of its own', async () => {
|
|
// The loop below drains every page before sorting, so an upstream sort buys
|
|
// nothing — while a parameter some DSM build rejects becomes a 400 and takes
|
|
// the whole album down.
|
|
safeFetch.mockResolvedValue(api({ list: [] }));
|
|
|
|
await svc.getSynologyAlbumPhotos(USER, '7');
|
|
|
|
const body = String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body);
|
|
expect(body).not.toContain('sort_by');
|
|
expect(body).not.toContain('sort_direction');
|
|
});
|
|
|
|
it('SYNO-U064: orders the album by capture time, newest first', async () => {
|
|
safeFetch.mockResolvedValue(api({ list: [
|
|
{ id: 1, time: 1700000000, additional: { thumbnail: { cache_key: 'older' } } },
|
|
{ id: 2, time: 1800000000, additional: { thumbnail: { cache_key: 'newer' } } },
|
|
] }));
|
|
|
|
const assets = (await svc.getSynologyAlbumPhotos(USER, '7') as { data: { assets: { id: string }[] } }).data.assets;
|
|
|
|
expect(assets.map(a => a.id)).toEqual(['newer', 'older']);
|
|
});
|
|
});
|
|
|
|
describe('collectSynologyAlbumSelection', () => {
|
|
it('SYNO-U070: fails when the album link does not resolve', async () => {
|
|
access.getAlbumLinkForSync.mockReturnValue({ success: false, error: { message: 'Album link not found', status: 404 } });
|
|
const result = await svc.collectSynologyAlbumSelection(USER, '1', 'l1');
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('SYNO-U071: returns the cache keys as the selection, with the raw total', async () => {
|
|
access.getAlbumLinkForSync.mockReturnValue({ success: true, data: { albumId: '7', passphrase: undefined } });
|
|
safeFetch.mockResolvedValue(api({ list: [
|
|
{ id: 1, additional: { thumbnail: { cache_key: 'ck-1' } } },
|
|
{ id: 2, additional: { thumbnail: { cache_key: '' } } },
|
|
] }));
|
|
|
|
const result = await svc.collectSynologyAlbumSelection(USER, '1', 'l1');
|
|
|
|
const data = (result as { data: { selection: { asset_ids: string[] }; total: number } }).data;
|
|
// the empty cache key is filtered out of the selection but still counted
|
|
expect(data.selection.asset_ids).toEqual(['ck-1']);
|
|
expect(data.total).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('the search order', () => {
|
|
it('SYNO-U065: search results come back newest first without a sort parameter upstream', async () => {
|
|
// SYNO.Foto.Search.Search documents no sort_by, and _fetchSynologyJson maps
|
|
// every app code except 106/107/119 onto a 400, so a rejected parameter would
|
|
// take search down for that user entirely. Ordering happens here instead.
|
|
safeFetch.mockResolvedValue(api({ list: [
|
|
{ id: 1, time: 1700000000, additional: { thumbnail: { cache_key: 'older' } } },
|
|
{ id: 2, time: 1800000000, additional: { thumbnail: { cache_key: 'newer' } } },
|
|
] }));
|
|
|
|
const result = await svc.searchSynologyPhotos(USER);
|
|
|
|
const assets = (result as { data: { assets: { id: string }[] } }).data.assets;
|
|
expect(assets.map(a => a.id)).toEqual(['newer', 'older']);
|
|
const body = String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body);
|
|
expect(body).not.toContain('sort_by');
|
|
});
|
|
|
|
});
|
|
|
|
describe('the search window', () => {
|
|
const windowOf = async (from?: string, to?: string, tzOffsetMinutes?: number) => {
|
|
safeFetch.mockResolvedValue(api({ list: [] }));
|
|
await svc.searchSynologyPhotos(USER, from, to, 0, 100, tzOffsetMinutes);
|
|
return new URLSearchParams(String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body));
|
|
};
|
|
|
|
it('SYNO-U081: with no offset the day is the UTC day it has always been', async () => {
|
|
const body = await windowOf('2026-03-15', '2026-03-15');
|
|
|
|
expect(body.get('start_time')).toBe(String(Date.UTC(2026, 2, 15) / 1000));
|
|
expect(body.get('end_time')).toBe(String(Date.UTC(2026, 2, 15) / 1000 + 86400));
|
|
});
|
|
|
|
it("SYNO-U082: the caller's offset moves both bounds onto the day it meant", async () => {
|
|
// UTC+10: the 15th there runs from 14:00Z on the 14th. Without this the NAS
|
|
// was asked for 10:00 that morning to 09:59 the next (#2336).
|
|
const body = await windowOf('2026-03-15', '2026-03-15', 600);
|
|
|
|
expect(body.get('start_time')).toBe(String(Date.UTC(2026, 2, 15) / 1000 - 600 * 60));
|
|
expect(body.get('end_time')).toBe(String(Date.UTC(2026, 2, 15) / 1000 - 600 * 60 + 86400));
|
|
});
|
|
|
|
it('SYNO-U083: a one-sided range still sets only the bound it was given', async () => {
|
|
// Both are optional on the MCP tool, so this is reachable.
|
|
const fromOnly = await windowOf('2026-03-15', undefined, 600);
|
|
expect(fromOnly.get('start_time')).toBe(String(Date.UTC(2026, 2, 15) / 1000 - 600 * 60));
|
|
expect(fromOnly.get('end_time')).toBeNull();
|
|
|
|
vi.clearAllMocks();
|
|
const toOnly = await windowOf(undefined, '2026-03-15', -480);
|
|
expect(toOnly.get('start_time')).toBeNull();
|
|
expect(toOnly.get('end_time')).toBe(String(Date.UTC(2026, 2, 15) / 1000 + 480 * 60 + 86400));
|
|
});
|
|
|
|
it('SYNO-U084: no dates at all still means no window, offset or not', async () => {
|
|
const body = await windowOf(undefined, undefined, 600);
|
|
|
|
expect(body.get('start_time')).toBeNull();
|
|
expect(body.get('end_time')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('fetchSynologyThumbnailBytes', () => {
|
|
it('SYNO-U080: fails without credentials rather than fetching', async () => {
|
|
seedUser(6, { synology_url: null });
|
|
const result = await svc.fetchSynologyThumbnailBytes(6, 6, 'a1');
|
|
expect(result).toHaveProperty('error');
|
|
expect(safeFetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|