mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(admin): photo providers require the journey addon
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user