feat(admin): photo providers require the journey addon

This commit is contained in:
Maurice
2026-09-01 15:17:24 +02:00
committed by Maurice
parent 2834967919
commit fef5c39581
17 changed files with 214 additions and 23 deletions
@@ -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(<AddonManager />);
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();
+3 -3
View File
@@ -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 (
@@ -220,8 +220,8 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
{globalAddons.map((addon, i) => (
<div key={addon.id}>
<MAddonRow addon={addon} onToggle={handleToggle} t={t} first={i === 0} />
{/* 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]
+16 -8
View File
@@ -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<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
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
+13 -1
View File
@@ -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).
+9 -2
View File
@@ -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;
}
@@ -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<string> {
const providers = this._providers();
+18
View File
@@ -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();
}
});
});
+1 -1
View File
@@ -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),
@@ -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';
@@ -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 ───────────────────────────────────────────────────────────────
@@ -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;
};
@@ -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);
+18 -1
View File
@@ -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
@@ -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<AdminService>));
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<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
@@ -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 ────────────────────────────────────────────────────────
@@ -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 ────────────────────────────────────────────────────────