From fef5c39581a18cb2f4b485d2e3c7d8bb10c013a4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 1 Sep 2026 15:17:24 +0200 Subject: [PATCH] feat(admin): photo providers require the journey addon --- .../components/Admin/AddonManager.test.tsx | 13 ++++++++ client/src/components/Admin/AddonManager.tsx | 6 ++-- .../screens/admin/MAdminAddonManager.tsx | 4 +-- server/src/nest/addons/addons.service.ts | 24 +++++++++----- server/src/nest/admin/admin.service.ts | 14 ++++++++- server/src/nest/memories/memories.mcp.ts | 11 +++++-- .../nest/memories/unified-memories.service.ts | 11 +++++-- server/tests/e2e/addons.e2e.test.ts | 18 +++++++++++ server/tests/helpers/mcp-test-controllers.ts | 2 +- .../integration/memories-synology.test.ts | 5 ++- .../integration/memories-unified.test.ts | 20 +++++++++++- .../tests/unit/mcp/tools-help-addons.test.ts | 2 ++ server/tests/unit/mcp/tools-memories.test.ts | 26 ++++++++++++++++ server/tests/unit/nest/addons.service.test.ts | 19 +++++++++++- .../tests/unit/nest/admin.controller.test.ts | 12 +++++++ server/tests/unit/nest/admin.service.test.ts | 31 +++++++++++++++++++ .../nest/unified-memories.service.test.ts | 19 +++++++++++- 17 files changed, 214 insertions(+), 23 deletions(-) diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index e97dc008a..227ea731e 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -348,6 +348,19 @@ describe('AddonManager', () => { await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true)); }); + it('FE-ADMIN-ADDON-030: provider sub-rows stay hidden while Journey itself is off', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }), + buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }), + ])); + render(); + + await screen.findByText('Journey'); + expect(screen.queryByText('Immich')).not.toBeInTheDocument(); + expect(screen.queryByText('Synology Photos')).not.toBeInTheDocument(); + }); + it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => { const user = userEvent.setup(); const onToggleCollabFeature = vi.fn(); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 9a3c39318..038a4d406 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -152,8 +152,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, } /** Bag tracking, the collab features and the photo providers all hang off their - * parent as shelf rows. The conditions are deliberately the ones that were here - * before — note that the providers render even when Journey itself is off. */ + * parent as shelf rows, and each shelf only shows while its parent is on — + * a provider toggled under a disabled Journey would hit the server's 409. */ const shelfFor = (addon: Addon) => { if (addon.id === 'packing' && addon.enabled && onToggleBagTracking) { return ( @@ -181,7 +181,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, ) }) } - if (addon.id === 'journey' && providerOptions.length > 0) { + if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) { return providerOptions.map(provider => { const ProviderIcon = PROVIDER_ICONS[provider.key] return ( diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index 9aac2ab9a..47770b326 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -220,8 +220,8 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac {globalAddons.map((addon, i) => (
- {/* Memories providers as sub-items under Journey addon */} - {addon.id === 'journey' && providerOptions.length > 0 && ( + {/* Memories providers as sub-items under Journey addon — only while it is on */} + {addon.id === 'journey' && addon.enabled && providerOptions.length > 0 && ( <> {providerOptions.map(provider => { const ProviderIcon = PROVIDER_ICONS[provider.key] diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index 393ef1bfe..173950cbe 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ADDON_IDS } from '../../addons'; import { DatabaseService } from '../database/database.service'; import type { Addon } from '../../types'; import { getPhotoProviderConfig } from '../memories/memories.helpers'; @@ -85,14 +86,21 @@ export class AddonsService { const addons = this.db .prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order') .all() as Pick[]; - const providers = this.db - .prepare( - `SELECT id, name, icon, enabled, sort_order - FROM photo_providers - WHERE enabled = 1 - ORDER BY sort_order, id`, - ) - .all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>; + // Photo providers surface only inside journeys, so with the journey addon + // off they are unavailable no matter what their own rows say. Deriving that + // here (instead of a migration) also covers installs that still hold an + // enabled provider under a disabled journey from before updateAddon + // cascaded the disable. + const providers = !this.isAddonEnabled(ADDON_IDS.JOURNEY) + ? [] + : (this.db + .prepare( + `SELECT id, name, icon, enabled, sort_order + FROM photo_providers + WHERE enabled = 1 + ORDER BY sort_order, id`, + ) + .all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>); const fields = this.db .prepare( `SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 5a50ab991..eca6024d9 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -643,10 +643,22 @@ export class AdminService { return { error: MANAGED_FORBIDDEN_ERROR.error, status: 403 }; } + // Photo providers are Journey's shelf rows — their whole UI lives inside + // journeys, so enabling one under a disabled journey addon would only + // advertise an integration nothing can reach. + if (provider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) { + return { error: 'Enable the Journey addon first', status: 409 }; + } + this.db.transaction(() => { if (addon) { - if (data.enabled !== undefined) + if (data.enabled !== undefined) { this.db.run('UPDATE addons SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id); + // Journey off takes its providers with it: a row left enabled would + // resurface the moment journey returns, which nobody switched on. + if (id === ADDON_IDS.JOURNEY && !data.enabled) + this.db.run('UPDATE photo_providers SET enabled = 0'); + } if (data.config !== undefined) { // The AI-parsing addon holds an API key — encrypt it at rest and preserve // the stored key when the client echoes the mask sentinel (see llmConfig.ts). diff --git a/server/src/nest/memories/memories.mcp.ts b/server/src/nest/memories/memories.mcp.ts index fdc43d79b..4d2725800 100644 --- a/server/src/nest/memories/memories.mcp.ts +++ b/server/src/nest/memories/memories.mcp.ts @@ -3,6 +3,8 @@ import { errorResult, ok, type McpContext, } from '../../nest-mcp'; import { z } from 'zod'; +import { ADDON_IDS } from '../../addons'; +import { AddonsService } from '../addons/addons.service'; import { DatabaseService } from '../database/database.service'; import { ImmichService } from './immich.service'; import { SynologyService } from './synology.service'; @@ -31,7 +33,9 @@ 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 + * all: nothing here has anything left to browse. The journey addon rides along + * because the providers exist to feed journey entries — journey off means no + * provider counts as on, whatever its row says. Which of the two providers is * on is a per-call question, answered by providerRefusal(). */ const anyPhotoProviderEnabled = (_ctx: McpContext, self: MemoriesMcp): boolean => @@ -62,6 +66,7 @@ export class MemoriesMcp { private readonly immich: ImmichService, private readonly synology: SynologyService, private readonly db: DatabaseService, + private readonly addons: AddonsService, ) {} /** @@ -69,6 +74,7 @@ export class MemoriesMcp { * a method, the same reason the addon gates need a public `addons`. */ enabledProviderIds(): string[] { + if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return []; return this.db.all<{ id: string }>('SELECT id FROM photo_providers WHERE enabled = 1').map((row) => row.id); } @@ -81,7 +87,8 @@ export class MemoriesMcp { 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`); + if (row.enabled !== 1 || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) + return errorResult(`Provider: "${provider}" is not enabled, contact server administrator`); return null; } diff --git a/server/src/nest/memories/unified-memories.service.ts b/server/src/nest/memories/unified-memories.service.ts index 4a268b8d8..46ad05639 100644 --- a/server/src/nest/memories/unified-memories.service.ts +++ b/server/src/nest/memories/unified-memories.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; +import { ADDON_IDS } from '../../addons'; import { broadcast } from '../../websocket'; import { encrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { AddonsService } from '../addons/addons.service'; import { DatabaseService } from '../database/database.service'; import { TrekPhotosRepository } from '../photos/trek-photos.repository'; import { ImmichService } from './immich.service'; @@ -31,12 +33,17 @@ export class UnifiedMemoriesService { private readonly synology: SynologyService, private readonly access: MemoriesAccessService, private readonly notifications: NotificationsService, + private readonly addons: AddonsService, ) {} private _providers(): Array<{id: string; enabled: boolean}> { + // A provider only counts as enabled while the journey addon is — its whole + // surface lives inside journeys. Covers rows left enabled from before + // updateAddon cascaded the journey disable. + const journeyOn = this.addons.isAddonEnabled(ADDON_IDS.JOURNEY); const rows = this.db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>; - return rows.map(r => ({ id: r.id, enabled: r.enabled === 1 })); - } + return rows.map(r => ({ id: r.id, enabled: journeyOn && r.enabled === 1 })); + } private _validProvider(provider: string): ServiceResult { const providers = this._providers(); diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts index ae4610ace..af47f87cc 100644 --- a/server/tests/e2e/addons.e2e.test.ts +++ b/server/tests/e2e/addons.e2e.test.ts @@ -60,6 +60,8 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { db.prepare("INSERT INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')").run(); db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run(); db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('disabled','Disabled','trip','X',0,2)").run(); + // The providers ride the journey addon — without this row they are dropped from the listing. + db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('journey','Journey','global','Compass',1,3)").run(); db.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES ('immich','Immich','Image',1,1)").run(); db.prepare(`INSERT INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) VALUES ('immich','base_url','Base URL','text','https://...',NULL,1,0,'immich_url',NULL,1)`).run(); @@ -86,6 +88,7 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { bagTracking: true, addons: [ { id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true }, + { id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }, { id: 'immich', name: 'Immich', @@ -111,4 +114,19 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { ], }); }); + + it('200 drops the photo providers while the journey addon is off', async () => { + db.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run(); + try { + const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + // journey leaves the enabled list AND takes immich with it, its row untouched + expect(res.body.addons).toEqual([ + { id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true }, + ]); + expect(db.prepare("SELECT enabled FROM photo_providers WHERE id = 'immich'").get()).toEqual({ enabled: 1 }); + } finally { + db.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run(); + } + }); }); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 9e130b48e..9da06000a 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -227,7 +227,7 @@ export function createMcpTestRegistry(): McpRegistry { new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService, guards), new AtlasMcp(new AtlasService(dbService), addonsService, authService), new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService)), addonsService, authService, captureBackfill), - new MemoriesMcp(immichService, synologyService, dbService), + new MemoriesMcp(immichService, synologyService, dbService, addonsService), new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService), new AirtrailMcp(new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient()), addonsService), new ReservationImportMcp(new AirtrailImportService(dbService, realtimeService, reservationsService, new AirtrailClient(), new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient())), dbService, authService, guards, addonsService), diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index 436a0f826..d3cd3c038 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -199,7 +199,7 @@ vi.mock('../../src/utils/ssrfGuard', async () => { import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db'; import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { safeFetch } from '../../src/utils/ssrfGuard'; @@ -712,7 +712,9 @@ describe('Synology syncSynologyAlbumLink', () => { const trip = createTrip(testDb, user.id); setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); // The migration inserts synologyphotos with enabled=0; ensure it is enabled for this test. + // A provider only counts as enabled under an enabled journey addon (also seeded off). testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run(); + setAddonEnabled(testDb, 'journey', true); // album_id must be a numeric string so getAlbumIdFromLink returns it and // syncSynologyAlbumLink passes Number(album_id) to the API. const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip'); @@ -772,6 +774,7 @@ describe('Synology syncSynologyAlbumLink', () => { const trip = createTrip(testDb, user.id); setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run(); + setAddonEnabled(testDb, 'journey', true); // Insert a link with an encrypted passphrase directly into the DB. const rawPassphrase = 'syno-share-pass-abc'; diff --git a/server/tests/integration/memories-unified.test.ts b/server/tests/integration/memories-unified.test.ts index 3a06a4d9e..f954011d0 100644 --- a/server/tests/integration/memories-unified.test.ts +++ b/server/tests/integration/memories-unified.test.ts @@ -55,7 +55,7 @@ vi.mock('../../src/utils/ssrfGuard', async () => { import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db'; import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; @@ -74,6 +74,8 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); resetRateLimits(nestApp); + // Providers only count as enabled under an enabled journey addon (migration 84 seeds it off). + setAddonEnabled(testDb, 'journey', true); }); afterAll(async () => { @@ -359,6 +361,22 @@ describe('Unified album-link management', () => { expect(res.status).toBe(400); // no providers enabled → error }); + + it('UNIFIED-021 — GET album-links reports no providers while the journey addon is off', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-x'); + + setAddonEnabled(testDb, 'journey', false); + + const res = await request(app) + .get(albumLinksUrl(trip.id)) + .set('Cookie', authCookie(user.id)); + + setAddonEnabled(testDb, 'journey', true); + + expect(res.status).toBe(400); // provider rows still say enabled, journey off wins + }); }); // ── Auth checks ─────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/mcp/tools-help-addons.test.ts b/server/tests/unit/mcp/tools-help-addons.test.ts index 8cc313a33..57ff8e17a 100644 --- a/server/tests/unit/mcp/tools-help-addons.test.ts +++ b/server/tests/unit/mcp/tools-help-addons.test.ts @@ -327,6 +327,8 @@ describe('Tool: list_addons', () => { it('lists the enabled photo providers alongside the addons', async () => { const { user } = createUser(testDb); + // Providers ride the journey addon now; migration 84 seeds it off. + setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true); const row = testDb.prepare('SELECT name FROM photo_providers WHERE id = ?').get('immich') as { name: string; }; diff --git a/server/tests/unit/mcp/tools-memories.test.ts b/server/tests/unit/mcp/tools-memories.test.ts index e3600d8d1..fb77c0ce5 100644 --- a/server/tests/unit/mcp/tools-memories.test.ts +++ b/server/tests/unit/mcp/tools-memories.test.ts @@ -223,6 +223,32 @@ describe('Tool: search_provider_photos', () => { }); }); + it('is not registered while the journey addon is off, however the provider rows read', async () => { + const { user } = createUser(testDb); + setAddonEnabled(testDb, ADDON_IDS.JOURNEY, 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('refuses mid-session once the journey addon goes off, without calling the provider', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + // Registered while journey was on; the flip lands after session start. + setAddonEnabled(testDb, ADDON_IDS.JOURNEY, false); + 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).toContain('is not enabled, contact server administrator'); + expect(immichSearch).not.toHaveBeenCalled(); + }); + }); + it('is registered again as soon as one provider is on', async () => { const { user } = createUser(testDb); setProviderEnabled('immich', true); diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index ab7b4e273..e1b145641 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -45,6 +45,8 @@ function providerFields(addon: ListAddon): PhotoProviderField[] { // Feed list()'s reads in call order: addons, providers, fields (.all), then the // collab-features rows (.all) and the bag-tracking row (.get) from app_settings. +// The provider query now sits behind an isAddonEnabled(journey) read (.get), fed +// first as enabled so every case below sees its providers. function feedReads( addons: unknown[], providers: unknown[], @@ -57,7 +59,7 @@ function feedReads( .mockReturnValueOnce(providers) .mockReturnValueOnce(fields) .mockReturnValueOnce(collabRows); - dbMock._stmt.get.mockReturnValueOnce(bagRow); + dbMock._stmt.get.mockReturnValueOnce({ enabled: 1 }).mockReturnValueOnce(bagRow); } beforeEach(() => { @@ -254,6 +256,21 @@ describe('AddonsService.list', () => { expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['atlas', 'immich']); expect((res.addons[1] as { type: string }).type).toBe('photo_provider'); }); + + it('drops the photo providers while the journey addon is off, whatever their rows say', () => { + // The journey gate (.get) answers disabled, so the provider query is never + // made and the .all chain shortens to addons, fields, collab. + dbMock._stmt.get.mockReturnValueOnce({ enabled: 0 }); + dbMock._stmt.all + .mockReturnValueOnce([{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([]); + + const res = svc().list(); + expect(res.addons).toEqual([{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }]); + const providerReads = (dbMock.prepare.mock.calls as unknown[][]).filter((call) => String(call[0]).includes('FROM photo_providers')); + expect(providerReads).toEqual([]); + }); }); // Direct coverage of the enablement reads/writers relocated from diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index b8f718221..9ff9d8d09 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -136,6 +136,18 @@ describe('AdminController addons + sessions + jwt + defaults', () => { expect(runtime2.deactivateForDisabledAddon).toHaveBeenCalledWith('budget'); }); + it('addon update maps the journey-dependency refusal to its status, before any audit', async () => { + const c = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ error: 'Enable the Journey addon first', status: 409 }) } as Partial)); + const err = await c.updateAddon(user, 'immich', { enabled: true }, req).then( + () => null, + (e: unknown) => e as HttpException, + ); + expect(err).toBeInstanceOf(HttpException); + expect(err!.getStatus()).toBe(409); + expect(err!.getResponse()).toEqual({ error: 'Enable the Journey addon first' }); + expect(writeAudit).not.toHaveBeenCalled(); + }); + it('oauth-sessions revoke audits; rotate-jwt maps error', () => { expect(adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({}) }).revokeOAuthSession(user, '3', req)).toEqual({ success: true }); expect(thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 631f6a08f..f75cceae0 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -506,6 +506,37 @@ describe('updateAddon', () => { const docsFlip = updateAddon('documents', { enabled: false }) as any; if (!docsFlip.error) expect(docsFlip.mcpAffected).toBe(false); }); + + it('ADMIN-SVC-087 — refuses to enable a photo provider while journey is off', () => { + testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run(); + testDb.prepare("UPDATE photo_providers SET enabled = 0 WHERE id = 'immich'").run(); + + const result = updateAddon('immich', { enabled: true }) as any; + expect(result).toEqual({ error: 'Enable the Journey addon first', status: 409 }); + expect(testDb.prepare("SELECT enabled FROM photo_providers WHERE id = 'immich'").get()).toEqual({ enabled: 0 }); + }); + + it('ADMIN-SVC-088 — enables a provider under an enabled journey; disabling never needs journey', () => { + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run(); + const enabled = updateAddon('immich', { enabled: true }) as any; + expect(enabled.addon).toMatchObject({ id: 'immich', type: 'photo_provider', enabled: true }); + + // Switching a provider OFF stays possible with journey off — cleanup must not dead-end. + testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run(); + const disabled = updateAddon('immich', { enabled: false }) as any; + expect(disabled.addon.enabled).toBe(false); + }); + + it('ADMIN-SVC-089 — disabling journey cascades every photo provider off', () => { + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run(); + testDb.prepare('UPDATE photo_providers SET enabled = 1').run(); + + const result = updateAddon('journey', { enabled: false }) as any; + expect(result.addon.enabled).toBe(false); + const rows = testDb.prepare('SELECT enabled FROM photo_providers').all() as Array<{ enabled: number }>; + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.enabled === 0)).toBe(true); + }); }); // ── version-check cron ──────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/unified-memories.service.test.ts b/server/tests/unit/nest/unified-memories.service.test.ts index bf1bc6e34..e0d4fe99c 100644 --- a/server/tests/unit/nest/unified-memories.service.test.ts +++ b/server/tests/unit/nest/unified-memories.service.test.ts @@ -40,8 +40,10 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; -import { resetTestDb } from '../../helpers/test-db'; +import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; +import { ADDON_IDS } from '../../../src/addons'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; import { UnifiedMemoriesService } from '../../../src/nest/memories/unified-memories.service'; import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service'; import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; @@ -60,6 +62,7 @@ const svc = new UnifiedMemoriesService( {} as SynologyService, new MemoriesAccessService(dbs), notificationsStub(), + new AddonsService(dbs), ); // Legacy free-function names bound to the service, so the moved cases read as before. @@ -80,6 +83,8 @@ beforeEach(() => { resetTestDb(testDb); // Ensure default providers are enabled (resetTestDb seeds them but doesn't reset enabled flag) testDb.prepare('UPDATE photo_providers SET enabled = 1').run(); + // Providers only count as enabled under an enabled journey addon (migration 84 seeds it off). + setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true); }); afterAll(() => { @@ -107,6 +112,18 @@ describe('listTripPhotos', () => { expect((result as any).error.status).toBe(400); expect((result as any).error.message).toMatch(/no photo providers enabled/i); }); + + it('MEM-UNIFIED-013: treats enabled providers as disabled while the journey addon is off', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + setAddonEnabled(testDb, ADDON_IDS.JOURNEY, false); + + const result = listTripPhotos(String(trip.id), user.id); + expect(result.success).toBe(false); + expect((result as any).error.status).toBe(400); + expect((result as any).error.message).toMatch(/no photo providers enabled/i); + }); }); // ── listTripAlbumLinks ────────────────────────────────────────────────────────