Files
TREK/server/tests/unit/nest/immich.service.test.ts
T
Maurice 2d7889c237 fix(memories): give the photo picker a chronological order it can rely on
The picker got its photos back in whatever order the provider happened to
return them. It groups by day and appends pages lazily, so an unordered page
lands under the wrong heading and pushes photos out of the fold the reader
is looking at.

Immich now asks for order: desc on both the search and the album path. An
older server that does not know the property drops it silently rather than
failing, so each page is sorted again here; the album paths drain every page
before sorting and are covered outright. getAssetInfo reports mediaType, the
same mapping the listing already used, so the detail route and the picker
agree on what an asset is.

Synology gets no sort parameter upstream on either path. Search.Search
documents none, and _fetchSynologyJson maps every app code except 106/107/119
onto a 400, so a parameter one DSM build rejects would take listing down
entirely for that user. Both paths read every page anyway, so ordering the
mapped result costs nothing and cannot fail.

No media type for Synology yet, deliberately. streamSynologyAsset serves
every asset as a JPEG out of SYNO.Foto.Thumbnail, original included; there is
no video byte path. Labelling an asset a video would make the lightbox mount
a player on a still image, which is worse than the missing badge. The badge,
the byte path and the persisted media_type belong together in their own
change.

Five album cases in the Immich integration suite pinned the old arrival
order. Where the position was incidental they now look the asset up by id;
where the order is the point they state the chronological one.
2026-09-08 15:24:21 +02:00

586 lines
26 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 });
});
});
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: turns a from/to pair into the upstream date bounds', async () => {
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');
// 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-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', 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 });
});
});
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();
});
});