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 ────────────────────────────────────────────────────────