From fa67a29e7627af8157890f5dd1245688a943c0b9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 13:45:31 +0200 Subject: [PATCH] feat(mcp): find provider photos and attach them to a journal entry The whole memories domain had no tools, so the Immich and Synology libraries were invisible even though the attach body is plain JSON and the photos never pass through the call. Search, album listing, album contents, and the attach. The routes that stream photo bytes stay out: those are img targets. --- server/src/nest/journey/journey.mcp.ts | 76 +++ server/src/nest/memories/memories.mcp.ts | 183 +++++++ server/src/nest/memories/memories.module.ts | 2 + server/tests/unit/mcp/tools-memories.test.ts | 539 +++++++++++++++++++ 4 files changed, 800 insertions(+) create mode 100644 server/src/nest/memories/memories.mcp.ts create mode 100644 server/tests/unit/mcp/tools-memories.test.ts diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index a62c11c68..49222cdbb 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -12,6 +12,7 @@ import type { JourneyContributor } from '../../types'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; import { AuthService } from '../auth/auth.service'; +import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service'; /** Legacy registrar gate: the whole journey surface rode the journey addon. */ const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY); @@ -67,6 +68,20 @@ const PROS_CONS = z.object({ cons: z.array(z.string()).default([]), }); +/** + * The photo backends a journey can reference. The REST route forwards whatever + * `provider` string the body carried straight into `trek_photos.provider`, which + * would let a model persist a row no resolver can ever match to a backend. + */ +const PHOTO_PROVIDER = z.enum(['immich', 'synologyphotos']); + +/** + * How many provider assets one call may attach. The REST route takes an + * unbounded array, but it is driven by a picker where every id was clicked; + * a tool call is not, and each id is a row plus a detached provider lookup. + */ +const MAX_PROVIDER_PHOTOS_PER_CALL = 100; + /** * Journey MCP surface — ported 1:1 from the legacy registrar * src/mcp/tools/journey.ts (23 tools): identical names, descriptions, zod input @@ -82,6 +97,11 @@ const PROS_CONS = z.object({ * take the rest of the columns their REST routes always accepted (a place, its * coordinates, weather, tags, the verdict), and get_journey_stats answers what * GET /api/journeys/:id/stats answers. + * + * So does add_journey_provider_photos, which brings across + * POST /api/journeys/entries/:entryId/provider-photos and its gallery twin + * POST /api/journeys/:id/gallery/provider-photos. Finding the asset ids it takes + * is the memories domain's half of the same job, in memories/memories.mcp.ts. */ @McpController() export class JourneyMcp { @@ -90,6 +110,7 @@ export class JourneyMcp { private readonly share: JourneyShareService, readonly addons: AddonsService, private readonly auth: AuthService, + private readonly captureBackfill: PhotoCaptureBackfillService, ) {} // ── Read ──────────────────────────────────────────────────────────────── @@ -480,6 +501,61 @@ export class JourneyMcp { return ok(result); } + @Tool({ + name: 'add_journey_provider_photos', + description: 'Attach photos from a connected library (Immich or Synology Photos) to a journey: to one entry when entryId is given, otherwise to the journey gallery only. Find the asset ids first with search_provider_photos or list_provider_album_photos. No image data passes through here, the journey stores a reference and the app fetches the picture, so this also works for photos far too large to hand to a model. An asset already attached is skipped rather than duplicated, which makes re-running the same call safe.', + inputSchema: { + journeyId: z.number().int().positive(), + entryId: z.number().int().positive().optional().describe('Attach to this entry, which must belong to journeyId. The photo lands in the journey gallery either way; omitting this adds it to the gallery alone'), + provider: PHOTO_PROVIDER.describe('The library the asset ids came from'), + asset_ids: z.array(z.string().min(1)).min(1).max(MAX_PROVIDER_PHOTOS_PER_CALL).describe('Provider asset ids, as returned by the search and album tools'), + media_types: z.array(z.enum(['image', 'video'])).optional().describe('Parallel to asset_ids; anything not named here counts as an image'), + caption: z.string().max(500).optional().describe('Stored only when entryId is given, matching the REST routes: the gallery add records no caption'), + passphrase: z.string().min(1).optional().describe('Only for a Synology Photos album shared with the user: the passphrase list_provider_albums returned for that album'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + when: journeyAddonOn, + access: { group: 'journey', mode: 'write' }, + }) + addJourneyProviderPhotos( + { journeyId, entryId, provider, asset_ids, media_types, caption, passphrase }: { + journeyId: number; entryId?: number; provider: z.infer; + asset_ids: string[]; media_types?: Array<'image' | 'video'>; caption?: string; passphrase?: string; + }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + // The REST routes derive the journey from the entry and let each per-asset + // add answer for itself, which in the array branch means a caller with no + // access gets an empty 200. Checking up front instead turns that silence + // into a sentence, and is what makes journeyId worth asking for: the entry + // has to be shown to belong to it before anything is written. + if (!this.journey.canEdit(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); + if (entryId !== undefined && !this.journey.listEntries(journeyId, ctx.userId)?.some(e => e.id === entryId)) { + return notFound('Entry not found in this journey.'); + } + + const photos: unknown[] = []; + asset_ids.forEach((assetId, i) => { + const mediaType = media_types?.[i] === 'video' ? 'video' : 'image'; + const photo = entryId === undefined + ? this.journey.addProviderPhotoToGallery(journeyId, ctx.userId, provider, assetId, undefined, passphrase, mediaType) + : this.journey.addProviderPhoto(entryId, ctx.userId, provider, assetId, caption, passphrase, mediaType); + if (photo) photos.push(photo); + }); + + // Detached, exactly as the REST routes schedule it: the provider is asked + // when and where each photo was taken, and without that answer an attached + // photo can never appear on the journey map (#1614). + this.captureBackfill.schedule( + photos.map(p => (p as { photo_id?: number }).photo_id).filter((id): id is number => typeof id === 'number'), + ctx.userId, + ); + // `skipped` is what tells a caller that a shortfall was duplicates rather + // than a failure; the REST body carries only photos and added. + return ok({ photos, added: photos.length, skipped: asset_ids.length - photos.length }); + } + // ── Share links (journey:share, not implied by journey:write) ──────────── @Tool({ diff --git a/server/src/nest/memories/memories.mcp.ts b/server/src/nest/memories/memories.mcp.ts new file mode 100644 index 000000000..fdc43d79b --- /dev/null +++ b/server/src/nest/memories/memories.mcp.ts @@ -0,0 +1,183 @@ +import { + McpController, Tool, TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + errorResult, ok, type McpContext, +} from '../../nest-mcp'; +import { z } from 'zod'; +import { DatabaseService } from '../database/database.service'; +import { ImmichService } from './immich.service'; +import { SynologyService } from './synology.service'; + +/** + * The photo backends TREK can talk to, named rather than taken as a free + * string. `trek_photos.provider` stores whatever it is handed, so a typo would + * persist a row no resolver can ever match to a backend. + */ +const PROVIDER = z.enum(['immich', 'synologyphotos']); +type ProviderId = z.infer; + +const ISO_DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + +/** + * Immich's REST route already clamps a search page to 200, so this only newly + * binds Synology, whose route takes an unbounded `limit`. A tool answers into a + * context window rather than into a scrolling grid, so both get the ceiling. + */ +const MAX_PAGE_SIZE = 200; + +/** The two provider defaults, kept apart because the REST routes differ. */ +const IMMICH_DEFAULT_SIZE = 50; +const SYNOLOGY_DEFAULT_LIMIT = 100; + +/** + * Availability gate. With every `photo_providers` row switched off the admin has + * turned the whole integration off, and the settings page stops offering it at + * all: nothing here has anything left to browse. Which of the two providers is + * on is a per-call question, answered by providerRefusal(). + */ +const anyPhotoProviderEnabled = (_ctx: McpContext, self: MemoriesMcp): boolean => + self.enabledProviderIds().length > 0; + +/** + * Memories MCP surface: finding photos in a connected Immich or Synology Photos + * library so their asset ids can be handed to add_journey_provider_photos. + * + * Read-only and metadata-only by design. The routes that stream thumbnails and + * originals stay off this surface: they are `` targets that answer with + * bytes, and a tool result carries text. + * + * The three tools mirror POST /search, GET /albums and GET /albums/:id/photos + * under /api/integrations/memories/{immich,synologyphotos}. Those routes carry + * no trip scoping to mirror: a provider library belongs to the calling user and + * is reached with that user's own stored credentials, so the session's userId is + * the whole of the scoping, exactly as the JwtAuthGuard-only controllers have it. + * + * They ride journey:read because that is the consent this surface exists to + * serve: a model searches a library in order to attach what it finds to a + * journey entry or gallery, which is the write half in journey.mcp.ts. There is + * no memories scope group and adding one is a central decision, not a domain one. + */ +@McpController() +export class MemoriesMcp { + constructor( + private readonly immich: ImmichService, + private readonly synology: SynologyService, + private readonly db: DatabaseService, + ) {} + + /** + * Public because the `when:` gate above is a module-level function rather than + * a method, the same reason the addon gates need a public `addons`. + */ + enabledProviderIds(): string[] { + return this.db.all<{ id: string }>('SELECT id FROM photo_providers WHERE enabled = 1').map((row) => row.id); + } + + /** + * The admin toggle the trip-side add already enforces (the `_validProvider` + * check in UnifiedMemoriesService), reused down to its two sentences so a + * disabled provider refuses the same way on both surfaces. The browse routes + * themselves never checked it, so this only ever narrows what REST allows. + */ + private providerRefusal(provider: ProviderId) { + const row = this.db.get<{ enabled: number }>('SELECT enabled FROM photo_providers WHERE id = ?', provider); + if (!row) return errorResult(`Provider: "${provider}" is not supported`); + if (row.enabled !== 1) return errorResult(`Provider: "${provider}" is not enabled, contact server administrator`); + return null; + } + + @Tool({ + name: 'search_provider_photos', + description: 'Search a connected photo library (Immich or Synology Photos) by capture date and return the matching asset ids with their capture time, place and coordinates. Start here when the user asks for the photos of a trip, a journey or a single day, then pass the ids on to add_journey_provider_photos. Prefer list_provider_albums when the user names an album instead of a date range. Metadata only: no image data crosses the wire, and the pictures themselves are fetched by the app, not by this tool.', + inputSchema: { + provider: PROVIDER.describe('Which connected library to search'), + from: ISO_DATE.optional().describe('Earliest capture date, YYYY-MM-DD, inclusive'), + to: ISO_DATE.optional().describe('Latest capture date, YYYY-MM-DD, inclusive'), + page: z.number().int().min(1).optional().describe('1-based page number, defaults to 1. Page forward while hasMore is true'), + size: z.number().int().min(1).max(MAX_PAGE_SIZE).optional().describe(`Photos per page, at most ${MAX_PAGE_SIZE}. Defaults to ${IMMICH_DEFAULT_SIZE} for Immich and ${SYNOLOGY_DEFAULT_LIMIT} for Synology Photos, as the REST routes do`), + }, + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + when: anyPhotoProviderEnabled, + access: { group: 'journey', mode: 'read' }, + }) + async searchProviderPhotos( + { provider, from, to, page, size }: { provider: ProviderId; from?: string; to?: string; page?: number; size?: number }, + ctx: McpContext, + ) { + const refused = this.providerRefusal(provider); + if (refused) return refused; + + if (provider === 'immich') { + // Same coercion the REST route performs on the body before calling. + const result = await this.immich.searchPhotos(ctx.userId, from, to, Math.max(1, page ?? 1), Math.min(size ?? IMMICH_DEFAULT_SIZE, MAX_PAGE_SIZE)); + if (result.error) return errorResult(result.error); + return ok({ provider, assets: result.assets ?? [], hasMore: !!result.hasMore }); + } + + // Synology paginates by offset. Its route derives one from a 1-based page + // and the effective limit, which is what keeps a single `page` argument + // meaning the same thing for both providers. + const limit = size && size > 0 ? size : SYNOLOGY_DEFAULT_LIMIT; + const pageIndex = (page ?? 1) - 1; + const result = await this.synology.searchSynologyPhotos(ctx.userId, from, to, pageIndex > 0 ? pageIndex * limit : 0, limit); + if ('error' in result) return errorResult(result.error.message); + return ok({ provider, assets: result.data.assets, total: result.data.total, hasMore: result.data.hasMore }); + } + + @Tool({ + name: 'list_provider_albums', + description: 'List the albums of a connected photo library (Immich or Synology Photos), with each album id, name and photo count. Use this when the user names an album ("the Rome album") rather than a date range, then read its photos with list_provider_album_photos. For "photos from that week" use search_provider_photos instead.', + inputSchema: { + provider: PROVIDER.describe('Which connected library to list albums from'), + }, + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + when: anyPhotoProviderEnabled, + access: { group: 'journey', mode: 'read' }, + }) + async listProviderAlbums({ provider }: { provider: ProviderId }, ctx: McpContext) { + const refused = this.providerRefusal(provider); + if (refused) return refused; + + if (provider === 'immich') { + const result = await this.immich.listAlbums(ctx.userId); + if (result.error) return errorResult(result.error); + return ok({ provider, albums: result.albums ?? [] }); + } + + const result = await this.synology.listSynologyAlbums(ctx.userId); + if ('error' in result) return errorResult(result.error.message); + // A Synology album that was shared with the user carries a passphrase, and + // list_provider_album_photos cannot open it without one. The REST route + // hands it to the picker for the same reason. + return ok({ provider, albums: result.data.albums }); + } + + @Tool({ + name: 'list_provider_album_photos', + description: 'List every photo in one album of a connected library, with asset ids, capture times and coordinates. Follow list_provider_albums with this once the right album is known, then hand the asset ids to add_journey_provider_photos. Metadata only, and unpaginated: a large album comes back whole.', + inputSchema: { + provider: PROVIDER.describe('Which connected library the album lives in'), + album_id: z.string().min(1).describe('Album id as returned by list_provider_albums'), + passphrase: z.string().min(1).optional().describe('Only for a Synology Photos album that was shared with the user: pass back the passphrase list_provider_albums returned for it, otherwise the album cannot be opened'), + }, + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + when: anyPhotoProviderEnabled, + access: { group: 'journey', mode: 'read' }, + }) + async listProviderAlbumPhotos( + { provider, album_id, passphrase }: { provider: ProviderId; album_id: string; passphrase?: string }, + ctx: McpContext, + ) { + const refused = this.providerRefusal(provider); + if (refused) return refused; + + if (provider === 'immich') { + const result = await this.immich.getAlbumPhotos(ctx.userId, album_id); + if (result.error) return errorResult(result.error); + return ok({ provider, album_id, assets: result.assets ?? [] }); + } + + const result = await this.synology.getSynologyAlbumPhotos(ctx.userId, album_id, passphrase); + if ('error' in result) return errorResult(result.error.message); + return ok({ provider, album_id, assets: result.data.assets, total: result.data.total }); + } +} diff --git a/server/src/nest/memories/memories.module.ts b/server/src/nest/memories/memories.module.ts index 83d2c4c91..066abe471 100644 --- a/server/src/nest/memories/memories.module.ts +++ b/server/src/nest/memories/memories.module.ts @@ -14,6 +14,7 @@ import { SchedulingModule } from '../scheduling/scheduling.module'; import { UnifiedMemoriesController } from './unified.controller'; import { ImmichMemoriesController } from './immich.controller'; import { SynologyMemoriesController } from './synology.controller'; +import { MemoriesMcp } from './memories.mcp'; import { AddonsModule } from '../addons/addons.module'; import { AuditModule } from '../audit/audit.module'; import { TrekPhotosModule } from '../photos/trek-photos.module'; @@ -63,6 +64,7 @@ import { StorageModule } from '../storage/storage.module'; ImmichPhotoProvider, SynologyPhotoProvider, PhotoProviderRegistry, + MemoriesMcp, { provide: PHOTO_PROVIDERS, useFactory: (immich: ImmichPhotoProvider, synology: SynologyPhotoProvider) => [immich, synology], diff --git a/server/tests/unit/mcp/tools-memories.test.ts b/server/tests/unit/mcp/tools-memories.test.ts new file mode 100644 index 000000000..e3600d8d1 --- /dev/null +++ b/server/tests/unit/mcp/tools-memories.test.ts @@ -0,0 +1,539 @@ +/** + * Unit tests for the memories (photo provider) MCP tools and the journey + * provider-photo attach they feed. + * + * The provider calls themselves are spied on the service prototypes: the real + * methods talk HTTP to an Immich or Synology box, and what these cases are about + * is the layer above that, the provider gate, the argument coercion each REST + * route performs, and what lands in the DB. + */ +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'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock, broadcastToUser: broadcastMock })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; +import { createUser, createJourney, createJourneyEntry, addJourneyContributor } from '../../helpers/factories'; +import { ADDON_IDS } from '../../../src/addons'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { ImmichService } from '../../../src/nest/memories/immich.service'; +import { SynologyService } from '../../../src/nest/memories/synology.service'; +import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service'; + +const immichSearch = vi.spyOn(ImmichService.prototype, 'searchPhotos'); +const immichAlbums = vi.spyOn(ImmichService.prototype, 'listAlbums'); +const immichAlbumPhotos = vi.spyOn(ImmichService.prototype, 'getAlbumPhotos'); +const synologySearch = vi.spyOn(SynologyService.prototype, 'searchSynologyPhotos'); +const synologyAlbums = vi.spyOn(SynologyService.prototype, 'listSynologyAlbums'); +const synologyAlbumPhotos = vi.spyOn(SynologyService.prototype, 'getSynologyAlbumPhotos'); +// Detached in production; held still here so a case can assert what was queued +// without the provider lookup it would otherwise fire. +const backfillSchedule = vi.spyOn(PhotoCaptureBackfillService.prototype, 'schedule').mockImplementation(() => {}); + +const IMMICH_ASSET = { id: 'a1', takenAt: '2026-07-01T10:00:00.000Z', city: 'Rome', country: 'IT', lat: 41.9, lng: 12.5, mediaType: 'image' }; +const SYNOLOGY_ASSET = { id: 's1', takenAt: '2026-07-02T10:00:00.000Z', lat: 48.1, lng: 11.6 }; + +/** + * photo_providers is seed data, so resetTestDb leaves it alone and a toggle + * leaks into the next case. Every case states what it needs. + */ +function setProviderEnabled(id: string, enabled: boolean): void { + testDb.prepare( + 'INSERT INTO photo_providers (id, name, enabled) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled', + ).run(id, id, enabled ? 1 : 0); +} + +function removeProvider(id: string): void { + testDb.prepare('DELETE FROM photo_providers WHERE id = ?').run(id); +} + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true); + setProviderEnabled('immich', true); + setProviderEnabled('synologyphotos', true); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; + + immichSearch.mockReset().mockResolvedValue({ assets: [IMMICH_ASSET], hasMore: false }); + immichAlbums.mockReset().mockResolvedValue({ albums: [{ id: 'alb-1', albumName: 'Rome', assetCount: 2 }] }); + immichAlbumPhotos.mockReset().mockResolvedValue({ assets: [IMMICH_ASSET] }); + synologySearch.mockReset().mockResolvedValue({ success: true, data: { assets: [SYNOLOGY_ASSET], total: 1, hasMore: false } }); + synologyAlbums.mockReset().mockResolvedValue({ success: true, data: { albums: [{ id: '7', albumName: 'Munich', assetCount: 3, passphrase: 'pp' }] } }); + synologyAlbumPhotos.mockReset().mockResolvedValue({ success: true, data: { assets: [SYNOLOGY_ASSET], total: 1, hasMore: false } }); + backfillSchedule.mockClear(); +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise, scopes?: string[] | null) { + const h = await createMcpHarness({ userId, withResources: false, scopes: scopes ?? null }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// search_provider_photos +// --------------------------------------------------------------------------- + +describe('Tool: search_provider_photos', () => { + it('searches Immich with the page and size coercion the REST route applies', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'search_provider_photos', + arguments: { provider: 'immich', from: '2026-07-01', to: '2026-07-31' }, + }); + expect(result.isError).toBeFalsy(); + const data = parseToolResult(result) as any; + expect(data.provider).toBe('immich'); + expect(data.assets).toEqual([IMMICH_ASSET]); + expect(data.hasMore).toBe(false); + expect(immichSearch).toHaveBeenCalledWith(user.id, '2026-07-01', '2026-07-31', 1, 50); + }); + }); + + it('turns a 1-based page into the offset Synology paginates by', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'search_provider_photos', + arguments: { provider: 'synologyphotos', page: 3, size: 20 }, + }); + expect(result.isError).toBeFalsy(); + 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); + }); + }); + + it('defaults Synology to its own page size, not Immich\'s', async () => { + 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); + }); + }); + + it('refuses a provider the admin has switched off, without calling it', async () => { + const { user } = createUser(testDb); + setProviderEnabled('synologyphotos', false); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'search_provider_photos', + arguments: { provider: 'synologyphotos' }, + }); + expect(result.isError).toBe(true); + expect((result as any).content[0].text).toContain('is not enabled, contact server administrator'); + expect(synologySearch).not.toHaveBeenCalled(); + }); + }); + + it('refuses a provider that is not in the provider table at all', async () => { + const { user } = createUser(testDb); + removeProvider('synologyphotos'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'search_provider_photos', + arguments: { provider: 'synologyphotos' }, + }); + expect(result.isError).toBe(true); + expect((result as any).content[0].text).toContain('is not supported'); + expect(synologySearch).not.toHaveBeenCalled(); + }); + }); + + it('passes the upstream refusal through verbatim', async () => { + const { user } = createUser(testDb); + immichSearch.mockResolvedValue({ error: 'Immich not configured', status: 400 }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'search_provider_photos', arguments: { provider: 'immich' } }); + expect(result.isError).toBe(true); + expect((result as any).content[0].text).toBe('Immich not configured'); + }); + }); + + it('rejects a page size past the cap instead of quietly clamping it', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'search_provider_photos', + arguments: { provider: 'immich', size: 500 }, + }); + expect(result.isError).toBe(true); + expect(immichSearch).not.toHaveBeenCalled(); + }); + }); + + it('rejects an unknown provider name at the schema', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'search_provider_photos', + arguments: { provider: 'google-photos' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('is not registered when every photo provider is switched off', async () => { + const { user } = createUser(testDb); + setProviderEnabled('immich', false); + setProviderEnabled('synologyphotos', false); + await withHarness(user.id, async (h) => { + const names = (await h.client.listTools()).tools.map(t => t.name); + expect(names).not.toContain('search_provider_photos'); + expect(names).not.toContain('list_provider_albums'); + expect(names).not.toContain('list_provider_album_photos'); + }); + }); + + it('is registered again as soon as one provider is on', async () => { + const { user } = createUser(testDb); + setProviderEnabled('immich', true); + setProviderEnabled('synologyphotos', false); + await withHarness(user.id, async (h) => { + expect((await h.client.listTools()).tools.map(t => t.name)).toContain('search_provider_photos'); + }); + }); + + it('is not registered for a token without journey read access', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + expect((await h.client.listTools()).tools.map(t => t.name)).not.toContain('search_provider_photos'); + }, ['trips:read']); + }); +}); + +// --------------------------------------------------------------------------- +// list_provider_albums +// --------------------------------------------------------------------------- + +describe('Tool: list_provider_albums', () => { + it('lists the Immich albums', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'list_provider_albums', arguments: { provider: 'immich' }, + })) as any; + expect(data.albums).toEqual([{ id: 'alb-1', albumName: 'Rome', assetCount: 2 }]); + expect(immichAlbums).toHaveBeenCalledWith(user.id); + }); + }); + + it('keeps the passphrase a shared Synology album needs to be opened again', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'list_provider_albums', arguments: { provider: 'synologyphotos' }, + })) as any; + expect(data.albums[0].passphrase).toBe('pp'); + }); + }); + + it('refuses a disabled provider', async () => { + const { user } = createUser(testDb); + setProviderEnabled('immich', false); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_provider_albums', arguments: { provider: 'immich' } }); + expect(result.isError).toBe(true); + expect(immichAlbums).not.toHaveBeenCalled(); + }); + }); + + it('passes the upstream refusal through', async () => { + const { user } = createUser(testDb); + synologyAlbums.mockResolvedValue({ success: false, error: { message: 'Synology not configured', status: 400 } }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_provider_albums', arguments: { provider: 'synologyphotos' } }); + expect(result.isError).toBe(true); + expect((result as any).content[0].text).toBe('Synology not configured'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_provider_album_photos +// --------------------------------------------------------------------------- + +describe('Tool: list_provider_album_photos', () => { + it('reads one Immich album', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'list_provider_album_photos', arguments: { provider: 'immich', album_id: 'alb-1' }, + })) as any; + expect(data.album_id).toBe('alb-1'); + expect(data.assets).toEqual([IMMICH_ASSET]); + expect(immichAlbumPhotos).toHaveBeenCalledWith(user.id, 'alb-1'); + }); + }); + + it('forwards the passphrase to Synology', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'list_provider_album_photos', + arguments: { provider: 'synologyphotos', album_id: '7', passphrase: 'pp' }, + }); + expect(synologyAlbumPhotos).toHaveBeenCalledWith(user.id, '7', 'pp'); + }); + }); + + it('refuses a disabled provider', async () => { + const { user } = createUser(testDb); + setProviderEnabled('synologyphotos', false); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'list_provider_album_photos', arguments: { provider: 'synologyphotos', album_id: '7' }, + }); + expect(result.isError).toBe(true); + expect(synologyAlbumPhotos).not.toHaveBeenCalled(); + }); + }); + + it('passes an album that could not be fetched through as an error', async () => { + const { user } = createUser(testDb); + immichAlbumPhotos.mockResolvedValue({ error: 'Failed to fetch album', status: 404 }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'list_provider_album_photos', arguments: { provider: 'immich', album_id: 'nope' }, + }); + expect(result.isError).toBe(true); + expect((result as any).content[0].text).toBe('Failed to fetch album'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// add_journey_provider_photos +// --------------------------------------------------------------------------- + +function entryPhotoRows(entryId: number) { + return testDb.prepare(` + SELECT tp.provider, tp.asset_id, tp.owner_id, tp.media_type, gp.journey_id, gp.caption + FROM journey_entry_photos jep + JOIN journey_photos gp ON gp.id = jep.journey_photo_id + JOIN trek_photos tp ON tp.id = gp.photo_id + WHERE jep.entry_id = ? + ORDER BY tp.asset_id + `).all(entryId) as Array>; +} + +describe('Tool: add_journey_provider_photos', () => { + it('attaches provider assets to an entry and records them against the journey', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_journey_provider_photos', + arguments: { + journeyId: journey.id, entryId: entry.id, provider: 'immich', + asset_ids: ['a1', 'a2'], media_types: ['image', 'video'], caption: 'Rome day one', + }, + }); + expect(result.isError).toBeFalsy(); + const data = parseToolResult(result) as any; + expect(data.added).toBe(2); + expect(data.skipped).toBe(0); + + const rows = entryPhotoRows(entry.id); + expect(rows).toHaveLength(2); + expect(rows.map(r => r.asset_id)).toEqual(['a1', 'a2']); + expect(rows.every(r => r.provider === 'immich' && r.owner_id === user.id)).toBe(true); + expect(rows.map(r => r.media_type)).toEqual(['image', 'video']); + expect(rows[0].caption).toBe('Rome day one'); + expect(rows[0].journey_id).toBe(journey.id); + }); + }); + + it('adds to the gallery alone when no entry is named', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id); + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'add_journey_provider_photos', + arguments: { journeyId: journey.id, provider: 'synologyphotos', asset_ids: ['g1'] }, + })) as any; + expect(data.added).toBe(1); + + const gallery = testDb.prepare( + 'SELECT tp.asset_id, tp.provider FROM journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id WHERE gp.journey_id = ?', + ).all(journey.id) as Array>; + expect(gallery).toEqual([{ asset_id: 'g1', provider: 'synologyphotos' }]); + expect(entryPhotoRows(entry.id)).toHaveLength(0); + }); + }); + + it('skips an asset that is already on the entry rather than duplicating it', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id); + await withHarness(user.id, async (h) => { + const args = { journeyId: journey.id, entryId: entry.id, provider: 'immich', asset_ids: ['dup-1'] }; + await h.client.callTool({ name: 'add_journey_provider_photos', arguments: args }); + const second = parseToolResult(await h.client.callTool({ name: 'add_journey_provider_photos', arguments: args })) as any; + expect(second.added).toBe(0); + expect(second.skipped).toBe(1); + expect(entryPhotoRows(entry.id)).toHaveLength(1); + }); + }); + + it('queues the capture backfill for what it actually attached', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'add_journey_provider_photos', + arguments: { journeyId: journey.id, entryId: entry.id, provider: 'immich', asset_ids: ['bf-1'] }, + }); + const photoId = (testDb.prepare('SELECT id FROM trek_photos WHERE asset_id = ? AND owner_id = ?').get('bf-1', user.id) as { id: number }).id; + expect(backfillSchedule).toHaveBeenCalledWith([photoId], user.id); + }); + }); + + it('refuses a contributor who may only read the journey', async () => { + const { user: owner } = createUser(testDb); + const { user: viewer } = createUser(testDb); + const journey = createJourney(testDb, owner.id); + const entry = createJourneyEntry(testDb, journey.id, owner.id); + addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); + await withHarness(viewer.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_journey_provider_photos', + arguments: { journeyId: journey.id, entryId: entry.id, provider: 'immich', asset_ids: ['x1'] }, + }); + expect(result.isError).toBe(true); + expect((result as any).content[0].text).toBe('Journey not found or access denied.'); + expect(entryPhotoRows(entry.id)).toHaveLength(0); + }); + }); + + it('refuses a journey that does not exist', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_journey_provider_photos', + arguments: { journeyId: 999999, provider: 'immich', asset_ids: ['x1'] }, + }); + expect(result.isError).toBe(true); + expect((result as any).content[0].text).toBe('Journey not found or access denied.'); + }); + }); + + it('refuses an entry that belongs to another journey', async () => { + const { user } = createUser(testDb); + const mine = createJourney(testDb, user.id); + const other = createJourney(testDb, user.id); + const foreignEntry = createJourneyEntry(testDb, other.id, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_journey_provider_photos', + arguments: { journeyId: mine.id, entryId: foreignEntry.id, provider: 'immich', asset_ids: ['x1'] }, + }); + expect(result.isError).toBe(true); + expect((result as any).content[0].text).toBe('Entry not found in this journey.'); + expect(entryPhotoRows(foreignEntry.id)).toHaveLength(0); + }); + }); + + it('blocks the demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_journey_provider_photos', + arguments: { journeyId: journey.id, entryId: entry.id, provider: 'immich', asset_ids: ['x1'] }, + }); + expect(result.isError).toBe(true); + expect(entryPhotoRows(entry.id)).toHaveLength(0); + }); + }); + + it('refuses a batch past the per-call cap and writes nothing', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_journey_provider_photos', + arguments: { + journeyId: journey.id, entryId: entry.id, provider: 'immich', + asset_ids: Array.from({ length: 101 }, (_, i) => `cap-${i}`), + }, + }); + expect(result.isError).toBe(true); + expect(entryPhotoRows(entry.id)).toHaveLength(0); + }); + }); + + it('rejects a provider the journey could never resolve', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_journey_provider_photos', + arguments: { journeyId: journey.id, provider: 'local', asset_ids: ['x1'] }, + }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT COUNT(*) c FROM journey_photos').get()).toEqual({ c: 0 }); + }); + }); + + it('is not registered while the journey addon is off', async () => { + const { user } = createUser(testDb); + setAddonEnabled(testDb, ADDON_IDS.JOURNEY, false); + await withHarness(user.id, async (h) => { + expect((await h.client.listTools()).tools.map(t => t.name)).not.toContain('add_journey_provider_photos'); + }); + }); + + it('is not registered for a read-only journey token', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const names = (await h.client.listTools()).tools.map(t => t.name); + expect(names).not.toContain('add_journey_provider_photos'); + expect(names).toContain('search_provider_photos'); + }, ['journey:read']); + }); +});