Files
TREK/server/tests/unit/nest/immich.service.test.ts
T
Maurice 1e35a99c11 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
2026-09-20 12:48:44 +02:00

728 lines
33 KiB
TypeScript

/**
* ImmichService — credentials, settings, connection, browsing and the asset
* proxy.
*
* The provider code came out of services/memories/ at ~61% branch coverage,
* because that tree is outside the gate: the integration suites drive it over
* HTTP and never reach most of the failure paths. These cases go at the service
* directly with safeFetch stubbed, so every "upstream said no" branch is pinned.
*/
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 } = vi.hoisted(() => ({ safeFetch: vi.fn(), checkSsrf: vi.fn() }));
vi.mock('../../../src/utils/ssrfGuard', () => ({
safeFetch,
checkSsrf,
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError: class extends Error {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { ImmichService } from '../../../src/nest/memories/immich.service';
import type { AuditService } from '../../../src/nest/audit/audit.service';
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import fs from 'node:fs';
import path from 'node:path';
import { makeStorageFixture } from '../../helpers/storage-fixture';
const audit = { writeAudit: vi.fn() };
const access = { getAlbumIdFromLink: vi.fn() };
const dbs = new DatabaseService(testDb);
const journeyFx = makeStorageFixture('journey/');
const svc = new ImmichService(dbs, audit as unknown as AuditService, access as unknown as MemoriesAccessService, journeyFx.storage);
const USER = 1;
function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0): void {
testDb.prepare("INSERT OR REPLACE INTO users (id, username, email, password_hash, immich_url, immich_api_key, immich_auto_upload) VALUES (?, ?, ?, 'x', ?, ?, ?)")
.run(id, `u${id}`, `u${id}@example.test`, url, key, autoUpload);
}
/** A Response-ish object with only what the service reads. */
function upstream(opts: { ok?: boolean; status?: number; json?: unknown; url?: string; contentType?: string | null; body?: string }) {
return {
ok: opts.ok ?? true,
status: opts.status ?? 200,
url: opts.url ?? '',
headers: { get: (h: string) => (h === 'content-type' ? (opts.contentType ?? 'image/jpeg') : null) },
json: async () => opts.json ?? {},
arrayBuffer: async () => Buffer.from(opts.body ?? 'bytes'),
};
}
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, 'https://immich.test', 'key-1');
});
afterAll(() => testDb.close());
describe('getImmichCredentials', () => {
it('IMMICH-001: returns null when the user row is missing', () => {
expect(svc.getImmichCredentials(999)).toBeNull();
});
it('IMMICH-002: returns null without a URL', () => {
seedUser(2, null, 'key');
expect(svc.getImmichCredentials(2)).toBeNull();
});
it('IMMICH-003: returns null without an API key', () => {
seedUser(3, 'https://immich.test', null);
expect(svc.getImmichCredentials(3)).toBeNull();
});
it('IMMICH-004: returns null when the stored key cannot be decrypted', () => {
decryptMock.mockReturnValue(null);
expect(svc.getImmichCredentials(USER)).toBeNull();
});
it('IMMICH-005: returns the decrypted pair otherwise', () => {
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' });
});
});
describe('isValidAssetId', () => {
it('IMMICH-006: accepts UUID-ish ids and rejects traversal or over-long input', () => {
expect(svc.isValidAssetId('abc-123_XYZ')).toBe(true);
expect(svc.isValidAssetId('../etc/passwd')).toBe(false);
expect(svc.isValidAssetId('a'.repeat(101))).toBe(false);
});
});
describe('getConnectionSettings / setImmichAutoUpload', () => {
it('IMMICH-007: reports connected with the URL when configured', () => {
expect(svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false });
});
it('IMMICH-008: reports an empty URL and not connected when it is not', () => {
seedUser(4, null, null);
expect(svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false });
});
it('IMMICH-009: surfaces the auto-upload flag both ways', () => {
svc.setImmichAutoUpload(USER, true);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(true);
svc.setImmichAutoUpload(USER, false);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(false);
});
});
describe('saveImmichSettings', () => {
it('IMMICH-010: refuses a URL the SSRF guard blocks, and writes nothing', async () => {
checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' });
const result = await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null);
expect(result).toEqual({ success: false, error: 'Invalid Immich URL: blocked host' });
expect(svc.getImmichCredentials(USER)!.immich_url).toBe('https://immich.test');
});
it('IMMICH-011: stores a trimmed URL and reports plain success', async () => {
const result = await svc.saveImmichSettings(USER, ' https://new.test ', 'k2', null);
expect(result).toEqual({ success: true });
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' });
});
it('IMMICH-012: warns and audits when the URL resolves to a private IP', async () => {
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: true, resolvedIp: '192.168.1.9' });
const result = await svc.saveImmichSettings(USER, 'http://nas.local', 'k', '5.6.7.8');
expect(result.success).toBe(true);
expect(result.warning).toContain('192.168.1.9');
expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'immich.private_ip_configured', ip: '5.6.7.8' }));
});
it('IMMICH-013: an empty URL clears the connection without an SSRF check', async () => {
const result = await svc.saveImmichSettings(USER, undefined, undefined, null);
expect(result).toEqual({ success: true });
expect(checkSsrf).not.toHaveBeenCalled();
expect(svc.getImmichCredentials(USER)).toBeNull();
});
});
describe('testConnection', () => {
it('IMMICH-014: refuses a blocked URL before any request', async () => {
checkSsrf.mockResolvedValue({ allowed: false, error: 'nope' });
expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'nope' });
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-015: falls back to a generic message when the guard gives no reason', async () => {
checkSsrf.mockResolvedValue({ allowed: false });
expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'Invalid Immich URL' });
});
it('IMMICH-016: reports the HTTP status when the server rejects the key', async () => {
safeFetch.mockResolvedValue(upstream({ ok: false, status: 401 }));
expect(await svc.testConnection('https://immich.test', 'bad')).toEqual({ connected: false, error: 'HTTP 401' });
});
it('IMMICH-017: returns the user on success', async () => {
safeFetch.mockResolvedValue(upstream({ json: { name: 'Ada', email: 'ada@example.test' } }));
const result = await svc.testConnection('https://immich.test', 'k');
expect(result.connected).toBe(true);
expect(result.user).toEqual({ name: 'Ada', email: 'ada@example.test' });
});
it('IMMICH-018: reports the canonical URL after an http → https upgrade', async () => {
safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://immich.test/api/users/me' }));
const result = await svc.testConnection('http://immich.test', 'k');
expect(result.canonicalUrl).toBe('https://immich.test');
});
it('IMMICH-019: does NOT rewrite the URL when the host changed — that is a redirect elsewhere', async () => {
safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://evil.test/api/users/me' }));
const result = await svc.testConnection('http://immich.test', 'k');
expect(result.canonicalUrl).toBeUndefined();
});
it('IMMICH-020: reports the thrown message when the request fails', async () => {
safeFetch.mockRejectedValue(new Error('ECONNREFUSED'));
expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'ECONNREFUSED' });
});
it('IMMICH-021: falls back to "Connection failed" for a non-Error rejection', async () => {
safeFetch.mockRejectedValue('boom');
expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'Connection failed' });
});
});
describe('getConnectionStatus', () => {
it('IMMICH-022: says "Not configured" without credentials', async () => {
seedUser(5, null, null);
expect(await svc.getConnectionStatus(5)).toEqual({ connected: false, error: 'Not configured' });
});
it('IMMICH-023: reports the HTTP status, the user, and a thrown failure', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 500 }));
expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'HTTP 500' });
safeFetch.mockResolvedValueOnce(upstream({ json: { name: 'Ada' } }));
expect((await svc.getConnectionStatus(USER)).user).toEqual({ name: 'Ada', email: undefined });
safeFetch.mockRejectedValueOnce(new Error('timeout'));
expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'timeout' });
});
});
describe('browseTimeline', () => {
it('IMMICH-024: 400s without credentials', async () => {
seedUser(6, null, null);
expect(await svc.browseTimeline(6)).toEqual({ error: 'Immich not configured', status: 400 });
});
it('IMMICH-025: forwards the upstream status on failure and the buckets on success', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 503 }));
expect(await svc.browseTimeline(USER)).toEqual({ error: 'Failed to fetch from Immich', status: 503 });
safeFetch.mockResolvedValueOnce(upstream({ json: [{ count: 3 }] }));
expect(await svc.browseTimeline(USER)).toEqual({ buckets: [{ count: 3 }] });
});
it('IMMICH-026: maps an unreachable server to 502', async () => {
safeFetch.mockRejectedValue(new Error('down'));
expect(await svc.browseTimeline(USER)).toEqual({ error: 'Could not reach Immich', status: 502 });
});
});
/**
* 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);
expect(await svc.searchPhotos(7)).toEqual({ error: 'Immich not configured', status: 400 });
safeFetch.mockRejectedValue(new Error('down'));
expect(await svc.searchPhotos(USER)).toEqual({ error: 'Could not reach Immich', status: 502 });
});
it('IMMICH-028: forwards the upstream status on a failed search', async () => {
safeFetch.mockResolvedValue(upstream({ ok: false, status: 400 }));
expect(await svc.searchPhotos(USER)).toEqual({ error: 'Search failed', status: 400 });
});
it('IMMICH-029: drops hidden assets an older server still returns (#1474)', async () => {
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [
{ id: 'ok', fileCreatedAt: '2026-01-01' },
{ id: 'hidden-flag', visibility: 'hidden' },
{ id: 'legacy-flag', isVisible: false },
] } },
}));
const result = await svc.searchPhotos(USER);
expect(result.assets!.map(a => a.id)).toEqual(['ok']);
});
it('IMMICH-030: maps exif fields, falls back to createdAt, and marks videos', async () => {
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [
{ id: 'a', createdAt: '2026-02-02', type: 'VIDEO', exifInfo: { city: 'Kyoto', country: 'JP', latitude: 35.0, longitude: 135.7 } },
{ id: 'b', fileCreatedAt: '2026-03-03', type: 'IMAGE', exifInfo: { latitude: 'nope' } },
] } },
}));
// By id, not by position: the result is ordered newest first, so 'b' leads.
const assets = (await svc.searchPhotos(USER)).assets!;
const a = assets.find(x => x.id === 'a');
const b = assets.find(x => x.id === 'b');
expect(a).toMatchObject({ takenAt: '2026-02-02', city: 'Kyoto', country: 'JP', lat: 35.0, lng: 135.7, mediaType: 'video' });
// A non-numeric coordinate becomes null rather than reaching the client as a string.
expect(b).toMatchObject({ takenAt: '2026-03-03', city: null, country: null, lat: null, lng: null, mediaType: 'image' });
});
it('IMMICH-031: hasMore counts the raw page, not the filtered one', async () => {
// Otherwise a page that is entirely hidden assets would stop pagination dead.
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [{ id: 'x', visibility: 'hidden' }, { id: 'y', visibility: 'hidden' }] } },
}));
const result = await svc.searchPhotos(USER, undefined, undefined, 1, 2);
expect(result.assets).toEqual([]);
expect(result.hasMore).toBe(true);
});
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('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.
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [
{ id: 'older', fileCreatedAt: '2026-03-01T09:00:00Z' },
{ id: 'newest', fileCreatedAt: '2026-03-31T09:00:00Z' },
{ id: 'middle', fileCreatedAt: '2026-03-15T09:00:00Z' },
] } },
}));
const result = await svc.searchPhotos(USER);
expect(result.assets!.map(a => a.id)).toEqual(['newest', 'middle', 'older']);
});
});
describe('getAssetInfo', () => {
it('IMMICH-033: 404s when the owner has no credentials', async () => {
seedUser(8, null, null);
expect(await svc.getAssetInfo(USER, 'a1', 8)).toEqual({ error: 'Not found', status: 404 });
});
it('IMMICH-034: reads the OWNER\'s credentials, not the requester\'s', async () => {
seedUser(9, 'https://owner.test', 'owner-key');
safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } }));
await svc.getAssetInfo(USER, 'a1', 9);
expect(safeFetch.mock.calls[0][0]).toContain('https://owner.test');
expect((safeFetch.mock.calls[0][1] as { headers: Record<string, string> }).headers['x-api-key']).toBe('owner-key');
});
it('IMMICH-035: forwards the upstream status and maps a proxy failure to 502', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 404 }));
expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Failed', status: 404 });
safeFetch.mockRejectedValueOnce(new Error('down'));
expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 });
});
it('IMMICH-036: composes camera/focal/aperture only when both halves are present', async () => {
safeFetch.mockResolvedValueOnce(upstream({
json: { id: 'a1', fileCreatedAt: 'x', originalFileName: 'IMG.jpg', exifInfo: { make: 'Fuji', model: 'X100V', focalLength: 23, fNumber: 2, iso: 200 } },
}));
const full = (await svc.getAssetInfo(USER, 'a1')).data;
expect(full).toMatchObject({ camera: 'Fuji X100V', focalLength: '23mm', aperture: 'f/2', iso: 200, fileName: 'IMG.jpg' });
safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'a2', exifInfo: { make: 'Fuji' } } }));
const partial = (await svc.getAssetInfo(USER, 'a2')).data;
expect(partial.camera).toBeNull();
expect(partial.focalLength).toBeNull();
});
it('IMMICH-037: survives an asset with no exif block at all', async () => {
safeFetch.mockResolvedValue(upstream({ json: { id: 'bare' } }));
const data = (await svc.getAssetInfo(USER, 'bare')).data;
expect(data).toMatchObject({ id: 'bare', width: null, height: null, city: null, fileName: null });
});
it('IMMICH-037b: reports the media type here too, not only in the listing', async () => {
// Same mapping as the listing, so the detail route and the picker agree on
// what an asset is. Nothing persists it from here yet — trek_photos.media_type
// comes from the add call's media_types.
safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'clip', type: 'VIDEO' } }));
expect((await svc.getAssetInfo(USER, 'clip')).data.mediaType).toBe('video');
safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'still', type: 'IMAGE' } }));
expect((await svc.getAssetInfo(USER, 'still')).data.mediaType).toBe('image');
});
});
describe('getAlbumPhotos', () => {
it('IMMICH-037c: 400s without credentials', async () => {
seedUser(11, null, null);
expect(await svc.getAlbumPhotos(11, 'alb-1')).toEqual({ error: 'Immich not configured', status: 400 });
});
it('IMMICH-037d: orders an album newest first and keeps its coordinates and media type', async () => {
// The v2 branch reads /api/albums/{id} directly and never passes a search,
// 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', 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' },
] },
}));
const assets = (await svc.getAlbumPhotos(USER, 'alb-1')).assets!;
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();
});
});
describe('fetchImmichThumbnailBytes', () => {
it('IMMICH-038: 404s without credentials for the owner', async () => {
seedUser(10, null, null);
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1', 10)).toEqual({ error: 'Not found', status: 404 });
});
it('IMMICH-039: returns the bytes with the upstream content type', async () => {
safeFetch.mockResolvedValue(upstream({ contentType: 'image/webp', body: 'thumb' }));
const result = await svc.fetchImmichThumbnailBytes(USER, 'a1');
expect(result).toEqual({ bytes: Buffer.from('thumb'), contentType: 'image/webp' });
});
it('IMMICH-040: defaults the content type when the server omits it', async () => {
safeFetch.mockResolvedValue(upstream({ contentType: null, body: 'thumb' }));
const result = await svc.fetchImmichThumbnailBytes(USER, 'a1') as { contentType: string };
expect(result.contentType).toBe('image/jpeg');
});
it('IMMICH-041: forwards the upstream status and maps a throw to 502', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 403 }));
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Upstream error', status: 403 });
safeFetch.mockRejectedValueOnce(new Error('down'));
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 });
});
});
describe('streamImmichAsset', () => {
function makeRes() {
return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), end: vi.fn() };
}
it('IMMICH-041b: answers 404 when the owner has no connection, instead of leaving the request open', async () => {
// It used to return { error, status } and touch nothing. All four callers
// ignored that return, so the client got no status line at all and waited
// out its own timeout. Synology's counterpart always wrote; this matches.
seedUser(11, null, null);
const res = makeRes();
await svc.streamImmichAsset(res as never, USER, 'a1', 'thumbnail', 11);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-041c: reads the connection of the OWNER, not of the viewer', async () => {
// A shared album is proxied with the owner's key on the viewer's behalf, so
// a viewer who happens to have Immich configured must not paper over an
// owner who does not.
seedUser(USER, 'https://immich.example', 'viewer-key');
seedUser(12, null, null);
const res = makeRes();
await svc.streamImmichAsset(res as never, USER, 'a1', 'original', 12);
expect(res.status).toHaveBeenCalledWith(404);
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-041d: proxies with the owner key once a connection exists', async () => {
seedUser(13, 'https://immich.example', 'owner-key');
safeFetch.mockResolvedValue(upstream({ contentType: 'image/jpeg', body: 'bytes' }));
const res = makeRes();
await svc.streamImmichAsset(res as never, USER, 'a1', 'thumbnail', 13);
expect(safeFetch).toHaveBeenCalledTimes(1);
const [url, init] = safeFetch.mock.calls[0];
expect(url).toContain('/api/assets/a1/thumbnail');
expect((init.headers as Record<string, string>)['x-api-key']).toBe('owner-key');
});
});
describe('collectAlbumSelection', () => {
it('IMMICH-042: 404s when the album link does not resolve', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: false });
expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Album link not found', status: 404 });
});
it('IMMICH-043: 400s when the user has no Immich credentials', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
seedUser(11, null, null);
expect(await svc.collectAlbumSelection('1', 'l1', 11)).toEqual({ error: 'Immich not configured', status: 400 });
});
it('IMMICH-044: returns only visible image assets, with the raw total', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
safeFetch.mockResolvedValue(upstream({
json: { assets: [
{ id: 'img', type: 'IMAGE' },
{ id: 'vid', type: 'VIDEO' },
{ id: 'hidden', type: 'IMAGE', visibility: 'hidden' },
] },
}));
const result = await svc.collectAlbumSelection('1', 'l1', USER) as { selection: { asset_ids: string[] }; total: number };
expect(result.selection.asset_ids).toEqual(['img']);
expect(result.total).toBe(1);
});
it('IMMICH-045: maps an unreachable server to 502', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
safeFetch.mockRejectedValue(new Error('down'));
expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Could not reach Immich', status: 502 });
});
});
describe('uploadToImmich', () => {
function writeJourneyObject(name: string, bytes: string): void {
const dir = path.join(journeyFx.root, 'journey');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, name), bytes);
}
it('IMMICH-UP-001: returns null without credentials and never fetches', async () => {
expect(await svc.uploadToImmich(999, 'journey/pic.jpg', 'pic.jpg')).toBeNull();
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-UP-002: returns null for a path outside the journey category', async () => {
seedUser(USER, 'https://immich.test', 'key-1');
expect(await svc.uploadToImmich(USER, 'files/doc.pdf', 'doc.pdf')).toBeNull();
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-UP-003: returns null when the object is missing (old existsSync guard)', async () => {
seedUser(USER, 'https://immich.test', 'key-1');
expect(await svc.uploadToImmich(USER, 'journey/gone.jpg', 'gone.jpg')).toBeNull();
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-UP-004: posts the bytes as multipart and answers the created asset id', async () => {
seedUser(USER, 'https://immich.test', 'key-1');
writeJourneyObject('up.jpg', 'jpeg-bytes-here');
safeFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'immich-42' }) });
expect(await svc.uploadToImmich(USER, 'journey/up.jpg', 'up.jpg')).toBe('immich-42');
const [url, init] = safeFetch.mock.calls[0] as [string, { method: string; body: Buffer; headers: Record<string, string> }];
expect(url).toBe('https://immich.test/api/assets');
expect(init.method).toBe('POST');
expect(init.headers['x-api-key']).toBe('key-1');
expect(init.body.includes(Buffer.from('jpeg-bytes-here'))).toBe(true);
});
it('IMMICH-UP-005: a rejected upstream answers null, not a throw', async () => {
seedUser(USER, 'https://immich.test', 'key-1');
writeJourneyObject('rej.jpg', 'bytes');
safeFetch.mockResolvedValueOnce({ ok: false, status: 500 });
expect(await svc.uploadToImmich(USER, 'journey/rej.jpg', 'rej.jpg')).toBeNull();
});
});