mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(memories): search the photo provider by the local day, not the UTC one
"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
This commit is contained in:
@@ -373,7 +373,18 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => {
|
||||
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);
|
||||
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 () => {
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('Tool: search_provider_photos', () => {
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assets).toEqual([SYNOLOGY_ASSET]);
|
||||
expect(data.total).toBe(1);
|
||||
expect(synologySearch).toHaveBeenCalledWith(user.id, undefined, undefined, 40, 20);
|
||||
expect(synologySearch).toHaveBeenCalledWith(user.id, undefined, undefined, 40, 20, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,7 +146,21 @@ describe('Tool: search_provider_photos', () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'search_provider_photos', arguments: { provider: 'synologyphotos' } });
|
||||
expect(synologySearch).toHaveBeenCalledWith(user.id, undefined, undefined, 0, 100);
|
||||
expect(synologySearch).toHaveBeenCalledWith(user.id, undefined, undefined, 0, 100, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('tells Synology which zone the dates are meant in, so a tool call is fixable too', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'search_provider_photos',
|
||||
arguments: { provider: 'synologyphotos', from: '2026-03-15', to: '2026-03-15', utc_offset_minutes: 600 },
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
// The tool takes calendar days and has no browser to ask, so the caller
|
||||
// says which 24 hours it means; omitted stays the UTC day (#2336).
|
||||
expect(synologySearch).toHaveBeenCalledWith(user.id, '2026-03-15', '2026-03-15', 0, 100, 600);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -262,6 +262,24 @@ describe('browseTimeline', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Three raw pages of a Sydney library, read newest first: the whole of 16 March
|
||||
* comes back before 15 March starts, which is what makes an answered page cost
|
||||
* more than one raw page once the day filter runs (#2336).
|
||||
*/
|
||||
const NEXT_DAY = [
|
||||
{ id: 'next-1', fileCreatedAt: '2026-03-16T01:00:00.000Z', localDateTime: '2026-03-16T12:00:00.000Z' },
|
||||
{ id: 'next-2', fileCreatedAt: '2026-03-16T00:30:00.000Z', localDateTime: '2026-03-16T11:30:00.000Z' },
|
||||
];
|
||||
const DAY_HEAD = [
|
||||
{ id: 'day-1', fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' },
|
||||
{ id: 'day-2', fileCreatedAt: '2026-03-15T08:00:00.000Z', localDateTime: '2026-03-15T19:00:00.000Z' },
|
||||
];
|
||||
const DAY_TAIL = [
|
||||
{ id: 'day-3', fileCreatedAt: '2026-03-15T07:00:00.000Z', localDateTime: '2026-03-15T18:00:00.000Z' },
|
||||
{ id: 'day-4', fileCreatedAt: '2026-03-15T06:00:00.000Z', localDateTime: '2026-03-15T17:00:00.000Z' },
|
||||
];
|
||||
|
||||
describe('searchPhotos', () => {
|
||||
it('IMMICH-027: 400s without credentials and 502s when the server is unreachable', async () => {
|
||||
seedUser(7, null, null);
|
||||
@@ -320,19 +338,137 @@ describe('searchPhotos', () => {
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('IMMICH-032: turns a from/to pair into the upstream date bounds', async () => {
|
||||
it('IMMICH-032: pads the upstream date bounds by a day on each side', async () => {
|
||||
// The bounds filter on fileCreatedAt, a UTC instant, while from/to name
|
||||
// calendar days on a wall clock. No zone sits further than 14 hours from
|
||||
// UTC, so a day of slack each way reaches every asset that belongs to them;
|
||||
// the local-date filter below narrows the page again (#2336).
|
||||
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [] } } }));
|
||||
|
||||
await svc.searchPhotos(USER, '2026-01-01', '2026-01-31');
|
||||
|
||||
const body = JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body);
|
||||
expect(body.takenAfter).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(body.takenBefore).toBe('2026-01-31T23:59:59.999Z');
|
||||
expect(body.takenAfter).toBe('2025-12-31T00:00:00.000Z');
|
||||
expect(body.takenBefore).toBe('2026-02-01T23:59:59.999Z');
|
||||
// Load-bearing on Immich >= 1.133: hidden assets must not cross the wire.
|
||||
expect(body.visibility).toBe('timeline');
|
||||
expect(body.order).toBe('desc');
|
||||
});
|
||||
|
||||
it('IMMICH-032c: pads only the bound it was given', async () => {
|
||||
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [] } } }));
|
||||
|
||||
await svc.searchPhotos(USER, '2026-01-01');
|
||||
const fromOnly = JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body);
|
||||
expect(fromOnly.takenAfter).toBe('2025-12-31T00:00:00.000Z');
|
||||
expect(fromOnly.takenBefore).toBeUndefined();
|
||||
|
||||
await svc.searchPhotos(USER, undefined, '2026-01-31');
|
||||
const toOnly = JSON.parse((safeFetch.mock.calls[1][1] as { body: string }).body);
|
||||
expect(toOnly.takenAfter).toBeUndefined();
|
||||
expect(toOnly.takenBefore).toBe('2026-02-01T23:59:59.999Z');
|
||||
});
|
||||
|
||||
it('IMMICH-032d: keeps the photos whose OWN local capture date is the day asked for', async () => {
|
||||
// A Sydney reader asking for the 15th: the 07:32 shot is 20:32Z on the 14th
|
||||
// and the UTC window used to miss it, while the next morning leaked in.
|
||||
safeFetch.mockResolvedValue(upstream({
|
||||
json: { assets: { items: [
|
||||
{ id: 'morning', fileCreatedAt: '2026-03-14T20:32:00.000Z', localDateTime: '2026-03-15T07:32:00.000Z' },
|
||||
{ id: 'evening', fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' },
|
||||
{ id: 'next-morning', fileCreatedAt: '2026-03-15T21:00:00.000Z', localDateTime: '2026-03-16T08:00:00.000Z' },
|
||||
] } },
|
||||
}));
|
||||
|
||||
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15');
|
||||
|
||||
expect(result.assets!.map(a => a.id)).toEqual(['evening', 'morning']);
|
||||
expect(result.assets![1]).toMatchObject({ takenAt: '2026-03-14T20:32:00.000Z', localTakenAt: '2026-03-15T07:32:00.000Z' });
|
||||
});
|
||||
|
||||
it('IMMICH-049: a server without localDateTime answers exactly as it did before', async () => {
|
||||
// An old or forked Immich: the fallback is the capture instant, so the day
|
||||
// is the UTC day the window has always searched, and the padding buys the
|
||||
// neighbouring days nothing.
|
||||
safeFetch.mockResolvedValue(upstream({
|
||||
json: { assets: { items: [
|
||||
{ id: 'day-before', fileCreatedAt: '2026-03-14T23:00:00.000Z' },
|
||||
{ id: 'in-day', fileCreatedAt: '2026-03-15T09:00:00.000Z' },
|
||||
{ id: 'day-after', fileCreatedAt: '2026-03-16T01:00:00.000Z' },
|
||||
] } },
|
||||
}));
|
||||
|
||||
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15');
|
||||
|
||||
expect(result.assets!.map(a => a.id)).toEqual(['in-day']);
|
||||
expect(result.assets![0].localTakenAt).toBeNull();
|
||||
});
|
||||
|
||||
it('IMMICH-050: fills the answered page from as many raw pages as the day filter costs', async () => {
|
||||
// `order: 'desc'` hands the padding day back first, so the first raw page of
|
||||
// a day whose neighbour was busy is entirely the wrong day. Answering that
|
||||
// page empty made the picker load nothing several times over, and made the
|
||||
// MCP tool report a day that has photos as having none (#2336).
|
||||
safeFetch
|
||||
.mockResolvedValueOnce(upstream({ json: { assets: { items: NEXT_DAY } } }))
|
||||
.mockResolvedValueOnce(upstream({ json: { assets: { items: DAY_HEAD } } }));
|
||||
|
||||
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1, 2);
|
||||
|
||||
expect(result.assets!.map(a => a.id)).toEqual(['day-1', 'day-2']);
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(safeFetch).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse((safeFetch.mock.calls[1][1] as { body: string }).body).page).toBe(2);
|
||||
});
|
||||
|
||||
it('IMMICH-050b: pages on without repeating an asset the page before already answered with', async () => {
|
||||
// The scan restarts at raw page 1 every call, so what it skips has to be
|
||||
// counted in assets that survived the filter rather than in raw rows.
|
||||
const page = (items: unknown[]) => upstream({ json: { assets: { items } } });
|
||||
safeFetch
|
||||
.mockResolvedValueOnce(page(NEXT_DAY))
|
||||
.mockResolvedValueOnce(page(DAY_HEAD))
|
||||
.mockResolvedValueOnce(page(NEXT_DAY))
|
||||
.mockResolvedValueOnce(page(DAY_HEAD))
|
||||
.mockResolvedValueOnce(page(DAY_TAIL));
|
||||
|
||||
const first = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1, 2);
|
||||
const second = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 2, 2);
|
||||
|
||||
expect(first.assets!.map(a => a.id)).toEqual(['day-1', 'day-2']);
|
||||
expect(second.assets!.map(a => a.id)).toEqual(['day-3', 'day-4']);
|
||||
});
|
||||
|
||||
it('IMMICH-050c: gives up after the page budget instead of scanning a library forever', async () => {
|
||||
// Every raw page is the padding day, so the answered page can never fill.
|
||||
// hasMore has to go false here: the next call would scan the same pages and
|
||||
// hand back the same empty page for ever.
|
||||
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: NEXT_DAY } } }));
|
||||
|
||||
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1, 2);
|
||||
|
||||
expect(result.assets).toEqual([]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
// SEARCH_MAX_RAW_PAGES.
|
||||
expect(safeFetch).toHaveBeenCalledTimes(20);
|
||||
});
|
||||
|
||||
it('IMMICH-050d: an unfiltered browse still costs one round trip, at the page it was asked for', async () => {
|
||||
// Nothing narrows those pages, so a raw page and an answered page are the
|
||||
// same page and the scan must not start over at page 1.
|
||||
safeFetch.mockResolvedValue(upstream({
|
||||
json: { assets: { items: [{ id: 'x', visibility: 'hidden' }, { id: 'y', visibility: 'hidden' }] } },
|
||||
}));
|
||||
|
||||
const result = await svc.searchPhotos(USER, undefined, undefined, 3, 2);
|
||||
|
||||
expect(safeFetch).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body).page).toBe(3);
|
||||
// Every row was hidden, and pagination still advances past that page.
|
||||
expect(result.assets).toEqual([]);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('IMMICH-032b: orders each page itself, so an unsorted page still lands chronological within itself', async () => {
|
||||
// Older builds drop the unknown property silently (whitelist: true without
|
||||
// forbidNonWhitelisted), which is exactly why the request cannot be trusted.
|
||||
@@ -416,7 +552,7 @@ describe('getAlbumPhotos', () => {
|
||||
// so this ordering is the only one an album on that version ever gets.
|
||||
safeFetch.mockResolvedValue(upstream({
|
||||
json: { assets: [
|
||||
{ id: 'older', fileCreatedAt: '2026-03-01T09:00:00Z', type: 'IMAGE', exifInfo: { latitude: 35.0, longitude: 135.7 } },
|
||||
{ id: 'older', fileCreatedAt: '2026-03-01T09:00:00Z', localDateTime: '2026-03-01T18:00:00.000Z', type: 'IMAGE', exifInfo: { latitude: 35.0, longitude: 135.7 } },
|
||||
{ id: 'clip', fileCreatedAt: '2026-03-31T09:00:00Z', type: 'VIDEO' },
|
||||
] },
|
||||
}));
|
||||
@@ -426,6 +562,12 @@ describe('getAlbumPhotos', () => {
|
||||
expect(assets.map(a => a.id)).toEqual(['clip', 'older']);
|
||||
expect(assets[0].mediaType).toBe('video');
|
||||
expect(assets[1]).toMatchObject({ lat: 35.0, lng: 135.7 });
|
||||
// The album path carries the same local capture stamp the search path does,
|
||||
// so a photo picked out of an album lands under the day heading a search
|
||||
// would have given it instead of under the UTC one (#2336). A server that
|
||||
// predates the field leaves it null, and the readers fall back to takenAt.
|
||||
expect(assets[1].localTakenAt).toBe('2026-03-01T18:00:00.000Z');
|
||||
expect(assets[0].localTakenAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -663,14 +663,14 @@ describe('SynologyMemoriesController (parity with /api/integrations/memories/syn
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, {}, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100);
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100, 0);
|
||||
});
|
||||
|
||||
it('forwards from/to and uses size as the limit when size > 0', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, { from: '2024-01-01', to: '2024-02-01', size: 30 }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, '2024-01-01', '2024-02-01', 0, 30);
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, '2024-01-01', '2024-02-01', 0, 30, 0);
|
||||
});
|
||||
|
||||
it('derives the offset from a 1-based page using the limit', async () => {
|
||||
@@ -678,21 +678,43 @@ describe('SynologyMemoriesController (parity with /api/integrations/memories/syn
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, { page: 3, limit: 20 }, makeRes());
|
||||
// page-1 = 2, offset = 2 * 20 = 40
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 40, 20);
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 40, 20, 0);
|
||||
});
|
||||
|
||||
it('keeps the explicit offset when page resolves to <= 0', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, { page: 1, offset: 5, limit: 10 }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 5, 10);
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 5, 10, 0);
|
||||
});
|
||||
|
||||
it('falls back to defaults when numeric fields are non-finite', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, { offset: 'x', limit: 'y', page: 'z', size: 'q' }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100);
|
||||
await new SynologyMemoriesController(svc).search(user, { offset: 'x', limit: 'y', page: 'z', size: 'q', utc_offset_minutes: 'q' }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100, 0);
|
||||
});
|
||||
|
||||
it('forwards the zone the dates are meant in, apart from the row offset', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
// A UTC+10 reader paging: 600 is the zone, 40 is the row offset. Sharing
|
||||
// one name would have made the NAS skip 600 photos instead (#2336).
|
||||
await new SynologyMemoriesController(svc).search(user, { page: 3, limit: 20, utc_offset_minutes: 600 }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 40, 20, 600);
|
||||
});
|
||||
|
||||
it('clamps an impossible zone to the range real ones live in', async () => {
|
||||
const synologySearchPhotos = vi.fn().mockResolvedValue({ success: true, data: { assets: [] } });
|
||||
const svc = makeService({ synologySearchPhotos });
|
||||
await new SynologyMemoriesController(svc).search(user, { utc_offset_minutes: 99999 }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenCalledWith(7, undefined, undefined, 0, 100, 840);
|
||||
|
||||
await new SynologyMemoriesController(svc).search(user, { utc_offset_minutes: '-99999' }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenLastCalledWith(7, undefined, undefined, 0, 100, -720);
|
||||
|
||||
await new SynologyMemoriesController(svc).search(user, { utc_offset_minutes: 90.7 }, makeRes());
|
||||
expect(synologySearchPhotos).toHaveBeenLastCalledWith(7, undefined, undefined, 0, 100, 90);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
* inside either provider suite.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sortAssetsByTakenAtDesc } from '../../../src/nest/memories/memories.helpers';
|
||||
import {
|
||||
dayStartEpochSeconds,
|
||||
isWithinLocalDayRange,
|
||||
shiftCalendarDay,
|
||||
sortAssetsByTakenAtDesc,
|
||||
} from '../../../src/nest/memories/memories.helpers';
|
||||
|
||||
const asset = (id: string, takenAt?: string | null) => ({ id, takenAt });
|
||||
|
||||
@@ -78,3 +83,69 @@ describe('sortAssetsByTakenAtDesc', () => {
|
||||
expect(ids(out)).toEqual(['utc', 'offset']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('shiftCalendarDay', () => {
|
||||
it('MEM-DAY-001: pads a day outwards, crossing month and year ends', () => {
|
||||
expect(shiftCalendarDay('2026-03-15', -1)).toBe('2026-03-14');
|
||||
expect(shiftCalendarDay('2026-03-15', 1)).toBe('2026-03-16');
|
||||
expect(shiftCalendarDay('2026-03-01', -1)).toBe('2026-02-28');
|
||||
expect(shiftCalendarDay('2026-12-31', 1)).toBe('2027-01-01');
|
||||
});
|
||||
|
||||
it('MEM-DAY-002: hands a bound it cannot read straight back, so it fails upstream as before', () => {
|
||||
expect(shiftCalendarDay('not-a-day', -1)).toBe('not-a-day');
|
||||
expect(shiftCalendarDay('', 1)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('isWithinLocalDayRange', () => {
|
||||
const at = (takenAt: string | null, localTakenAt?: string | null) => ({ takenAt, localTakenAt });
|
||||
|
||||
it('MEM-DAY-010: reads the day off the local capture stamp when the provider sends one', () => {
|
||||
// 21:00 UTC on the 14th is already the 15th in Sydney, and Immich says so.
|
||||
expect(isWithinLocalDayRange(at('2026-03-14T21:00:00Z', '2026-03-15T08:00:00.000Z'), '2026-03-15', '2026-03-15')).toBe(true);
|
||||
// And the mirror: 22:00 UTC on the 15th is already the 16th there, so it
|
||||
// does not belong to the 15th however the UTC day reads.
|
||||
expect(isWithinLocalDayRange(at('2026-03-15T22:00:00Z', '2026-03-16T09:00:00.000Z'), '2026-03-15', '2026-03-15')).toBe(false);
|
||||
});
|
||||
|
||||
it('MEM-DAY-011: falls back to the capture instant, which is the UTC day it always used', () => {
|
||||
expect(isWithinLocalDayRange(at('2026-03-15T23:30:00Z'), '2026-03-15', '2026-03-15')).toBe(true);
|
||||
expect(isWithinLocalDayRange(at('2026-03-16T00:30:00Z'), '2026-03-15', '2026-03-15')).toBe(false);
|
||||
});
|
||||
|
||||
it('MEM-DAY-012: narrows only the bound it was given', () => {
|
||||
expect(isWithinLocalDayRange(at('2026-03-10T09:00:00Z'), '2026-03-15', undefined)).toBe(false);
|
||||
expect(isWithinLocalDayRange(at('2026-03-20T09:00:00Z'), '2026-03-15', undefined)).toBe(true);
|
||||
expect(isWithinLocalDayRange(at('2026-03-10T09:00:00Z'), undefined, '2026-03-15')).toBe(true);
|
||||
expect(isWithinLocalDayRange(at('2026-03-20T09:00:00Z'), undefined, '2026-03-15')).toBe(false);
|
||||
expect(isWithinLocalDayRange(at('2026-03-20T09:00:00Z'))).toBe(true);
|
||||
});
|
||||
|
||||
it('MEM-DAY-013: keeps an asset with no usable timestamp rather than losing it', () => {
|
||||
expect(isWithinLocalDayRange(at(null), '2026-03-15', '2026-03-15')).toBe(true);
|
||||
expect(isWithinLocalDayRange(at('nope'), '2026-03-15', '2026-03-15')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('dayStartEpochSeconds', () => {
|
||||
it('MEM-DAY-020: with no offset it is the UTC midnight the window always used', () => {
|
||||
expect(dayStartEpochSeconds('2026-03-15')).toBe(Math.floor(new Date('2026-03-15').getTime() / 1000));
|
||||
});
|
||||
|
||||
it('MEM-DAY-021: an eastern offset starts the day earlier in UTC, a western one later', () => {
|
||||
const utc = dayStartEpochSeconds('2026-03-15');
|
||||
// UTC+10 reaches midnight ten hours before UTC does.
|
||||
expect(dayStartEpochSeconds('2026-03-15', 600)).toBe(utc - 600 * 60);
|
||||
expect(dayStartEpochSeconds('2026-03-15', -480)).toBe(utc + 480 * 60);
|
||||
});
|
||||
|
||||
it('MEM-DAY-022: a bound that is not a calendar day is parsed as before, offset ignored', () => {
|
||||
const instant = '2026-03-15T06:00:00Z';
|
||||
expect(dayStartEpochSeconds(instant, 600)).toBe(Math.floor(Date.parse(instant) / 1000));
|
||||
expect(Number.isNaN(dayStartEpochSeconds('nope'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,7 +177,12 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
|
||||
expect(unified.syncSynologyAlbum).toHaveBeenCalledWith(7, '5', 'l1', 'sock');
|
||||
|
||||
await svc.synologySearchPhotos(7, 'f', 't', 0, 100);
|
||||
expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(7, 'f', 't', 0, 100);
|
||||
expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(7, 'f', 't', 0, 100, 0);
|
||||
|
||||
// The zone the days are meant in has to reach the provider, and it is a
|
||||
// different thing from the pagination offset sitting next to it (#2336).
|
||||
await svc.synologySearchPhotos(7, 'f', 't', 0, 100, 600);
|
||||
expect(synology.searchSynologyPhotos).toHaveBeenLastCalledWith(7, 'f', 't', 0, 100, 600);
|
||||
});
|
||||
|
||||
it('synology album-photos forwards a passphrase when present and omits it when absent', async () => {
|
||||
|
||||
@@ -387,6 +387,49 @@ describe('the search order', () => {
|
||||
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user