import { describe, it, expect, vi, beforeEach } from 'vitest'; // Three distinct prepare(...).all() reads (addons, photo_providers, photo_provider_fields). // A single shared statement is reused, so .all() is fed result sets in call order. const { dbMock } = vi.hoisted(() => { const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }; return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } }; }); vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} })); import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) })); vi.mock('../../../src/nest/memories/memories.helpers', () => ({ getPhotoProviderConfig })); import { AddonsService } from '../../../src/nest/addons/addons.service'; function svc() { return new AddonsService(new DatabaseService(dbConn)); } // 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. function feedReads( addons: unknown[], providers: unknown[], fields: unknown[], collabRows: unknown[] = [], bagRow: { value: string } | undefined = undefined, ) { dbMock._stmt.all .mockReturnValueOnce(addons) .mockReturnValueOnce(providers) .mockReturnValueOnce(fields) .mockReturnValueOnce(collabRows); dbMock._stmt.get.mockReturnValueOnce(bagRow); } beforeEach(() => { vi.clearAllMocks(); dbMock._stmt.all.mockReturnValue([]); dbMock._stmt.get.mockReturnValue(undefined); getPhotoProviderConfig.mockReturnValue({}); }); describe('AddonsService.list', () => { it('returns the collab features and the bag-tracking flag from app_settings', () => { feedReads([], [], [], [{ key: 'collab_chat_enabled', value: 'false' }], { value: 'true' }); const res = svc().list(); expect(res.collabFeatures).toEqual({ chat: false, notes: true, polls: true, whatsnext: true }); expect(res.bagTracking).toBe(true); expect(res.addons).toEqual([]); }); it('coerces the addon enabled column to a boolean (both 1 and 0)', () => { feedReads( [ { id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }, { id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 }, ], [], [], ); const res = svc().list(); expect(res.addons).toEqual([ { id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }, { id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: false }, ]); }); it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', () => { feedReads( [], [{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }], [], ); getPhotoProviderConfig.mockReturnValue({ baseUrl: 'http://x' }); const res = svc().list(); expect(res.addons).toEqual([ { id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'image', enabled: true, config: { baseUrl: 'http://x' }, fields: [], }, ]); expect(getPhotoProviderConfig).toHaveBeenCalledWith('immich'); }); it('coerces a disabled photo provider enabled flag to false', () => { feedReads( [], [{ id: 'synology', name: 'Synology', icon: 'image', enabled: 0, sort_order: 1 }], [], ); const res = svc().list(); expect((res.addons[0] as { enabled: boolean }).enabled).toBe(false); }); it('groups multiple fields under their provider and keeps insertion order', () => { feedReads( [], [{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }], [ { provider_id: 'immich', field_key: 'url', label: 'URL', input_type: 'text', placeholder: 'https://', hint: 'Base URL', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'url', sort_order: 0, }, // Second field for the SAME provider exercises the `get(...) || []` truthy branch. { provider_id: 'immich', field_key: 'token', label: 'Token', input_type: 'password', placeholder: null, hint: null, required: 0, secret: 1, settings_key: null, payload_key: null, sort_order: 1, }, ], ); const res = svc().list(); const provider = res.addons[0] as { fields: Array> }; expect(provider.fields).toEqual([ { key: 'url', label: 'URL', input_type: 'text', placeholder: 'https://', hint: 'Base URL', required: true, secret: false, settings_key: 'immich_url', payload_key: 'url', sort_order: 0, }, { key: 'token', label: 'Token', input_type: 'password', placeholder: '', hint: null, required: false, secret: true, settings_key: null, payload_key: null, sort_order: 1, }, ]); }); it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', () => { feedReads( [], [{ id: 'p', name: 'P', icon: 'i', enabled: 1, sort_order: 0 }], [ { provider_id: 'p', field_key: 'k', label: 'L', input_type: 'text', // placeholder/hint/settings_key/payload_key omitted entirely (undefined) required: 0, secret: 0, sort_order: 0, }, ], ); const res = svc().list(); const field = (res.addons[0] as { fields: Array> }).fields[0]; expect(field).toMatchObject({ placeholder: '', hint: null, settings_key: null, payload_key: null, }); }); it('keeps fields belonging to other providers out of a provider with none of its own', () => { // A field exists, but for a DIFFERENT provider than the one returned — exercises // the `fieldsByProvider.get(p.id) || []` fallback while the map is non-empty. feedReads( [], [{ id: 'has-none', name: 'X', icon: 'i', enabled: 1, sort_order: 0 }], [ { provider_id: 'other', field_key: 'k', label: 'L', input_type: 'text', required: 0, secret: 0, sort_order: 0, }, ], ); const res = svc().list(); expect((res.addons[0] as { fields: unknown[] }).fields).toEqual([]); }); it('concatenates regular addons before the photo providers', () => { feedReads( [{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }], [{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }], [], ); const res = svc().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'); }); }); // Direct coverage of the enablement reads/writers relocated from // services/adminService (finding `admin-1`). The polarity asymmetry is on // purpose: bag tracking is opt-in (=== 'true'), collab flags opt-out (!== 'false'). describe('AddonsService addon/feature flags', () => { it('isAddonEnabled coerces the enabled column (1/0/missing row)', () => { dbMock._stmt.get.mockReturnValueOnce({ enabled: 1 }); expect(svc().isAddonEnabled('budget')).toBe(true); dbMock._stmt.get.mockReturnValueOnce({ enabled: 0 }); expect(svc().isAddonEnabled('budget')).toBe(false); dbMock._stmt.get.mockReturnValueOnce(undefined); expect(svc().isAddonEnabled('nope')).toBe(false); }); it('getBagTracking is opt-in: only the literal string true enables it', () => { dbMock._stmt.get.mockReturnValueOnce({ value: 'true' }); expect(svc().getBagTracking()).toEqual({ enabled: true }); dbMock._stmt.get.mockReturnValueOnce({ value: 'false' }); expect(svc().getBagTracking()).toEqual({ enabled: false }); dbMock._stmt.get.mockReturnValueOnce(undefined); // absent row → OFF expect(svc().getBagTracking()).toEqual({ enabled: false }); }); it('updateBagTracking persists true/false strings and echoes the flag (ADMIN-SVC-030)', () => { expect(svc().updateBagTracking(true)).toEqual({ enabled: true }); expect(dbMock._stmt.run).toHaveBeenCalledWith('true'); expect(svc().updateBagTracking(false)).toEqual({ enabled: false }); expect(dbMock._stmt.run).toHaveBeenCalledWith('false'); }); it('getCollabFeatures is opt-out: absent rows default ON, only false disables', () => { dbMock._stmt.all.mockReturnValueOnce([ { key: 'collab_chat_enabled', value: 'false' }, { key: 'collab_polls_enabled', value: 'true' }, ]); expect(svc().getCollabFeatures()).toEqual({ chat: false, notes: true, polls: true, whatsnext: true }); }); it('updateCollabFeatures writes only the provided flags and reports changed (#1414, ADMIN-SVC-070)', () => { // before-read: all default ON; after-read: chat flipped off → changed dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]); const first = svc().updateCollabFeatures({ chat: false }); expect(first.changed).toBe(true); expect(first.features.chat).toBe(false); expect(dbMock._stmt.run).toHaveBeenCalledTimes(1); expect(dbMock._stmt.run).toHaveBeenCalledWith('collab_chat_enabled', 'false'); // identical save → before and after read the same → no change, MCP sessions must survive dbMock._stmt.run.mockClear(); dbMock._stmt.all .mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]) .mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]); const second = svc().updateCollabFeatures({ chat: false }); expect(second.changed).toBe(false); expect(dbMock._stmt.run).toHaveBeenCalledWith('collab_chat_enabled', 'false'); // undefined flags are not written dbMock._stmt.run.mockClear(); dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([]); const third = svc().updateCollabFeatures({}); expect(third.changed).toBe(false); expect(dbMock._stmt.run).not.toHaveBeenCalled(); }); }); /** * The three places flags moved here from AdminService, where they were raw * app_settings SQL sitting next to flags that already delegated to this service. * They are fail-CLOSED (`=== 'true'`), matching getBagTracking: unset used to read as * ON (`!== 'false'`), and a migration backfills 'true' for existing installs so * nobody loses a feature on upgrade. */ describe('AddonsService places flags', () => { beforeEach(() => { vi.clearAllMocks(); }); const cases = [ ['getPlacesPhotos', 'updatePlacesPhotos', 'places_photos_enabled'], ['getPlacesAutocomplete', 'updatePlacesAutocomplete', 'places_autocomplete_enabled'], ['getPlacesDetails', 'updatePlacesDetails', 'places_details_enabled'], ] as const; it('ADDONS-SVC-080 an unset flag reads as OFF, and anything but the literal true does too', () => { for (const [getter, , key] of cases) { dbMock._stmt.get.mockReturnValueOnce(undefined); expect(svc()[getter]()).toEqual({ enabled: false }); expect(dbMock.prepare).toHaveBeenLastCalledWith('SELECT value FROM app_settings WHERE key = ?'); expect(dbMock._stmt.get).toHaveBeenLastCalledWith(key); dbMock._stmt.get.mockReturnValueOnce({ value: 'garbage' }); expect(svc()[getter]()).toEqual({ enabled: false }); } }); it('ADDONS-SVC-081 a stored "true" reads as ON', () => { for (const [getter] of cases) { dbMock._stmt.get.mockReturnValueOnce({ value: 'true' }); expect(svc()[getter]()).toEqual({ enabled: true }); } }); it('ADDONS-SVC-082 the setters persist the literal string and echo the boolean back', () => { for (const [, setter, key] of cases) { expect(svc()[setter](true)).toEqual({ enabled: true }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith(key, 'true'); expect(svc()[setter](false)).toEqual({ enabled: false }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith(key, 'false'); } }); }); /** * Enrichment sits beside the three above and reads the opposite way round. * * They are fail-closed because a migration backfilled a row for every install * that predates the change. This one is new: there is nothing to backfill, so * fail-open reaches the same place without a migration for one boolean. What * matters is that it agrees with PlaceEnrichmentService.enrichDisabled(), which * reads the same key — if the two ever diverge the panel shows "off" while the * feature runs. */ describe('AddonsService places enrichment flag', () => { beforeEach(() => { vi.clearAllMocks(); }); it('ADDONS-SVC-083 an unset flag reads as ON', () => { dbMock._stmt.get.mockReturnValueOnce(undefined); expect(svc().getPlacesEnrich()).toEqual({ enabled: true }); }); it('ADDONS-SVC-084 only the literal "false" switches it off', () => { dbMock._stmt.get.mockReturnValueOnce({ value: 'false' }); expect(svc().getPlacesEnrich()).toEqual({ enabled: false }); for (const value of ['true', 'garbage', '']) { dbMock._stmt.get.mockReturnValueOnce({ value }); expect(svc().getPlacesEnrich()).toEqual({ enabled: true }); } }); it('ADDONS-SVC-085 the setter persists the literal string and echoes the boolean back', () => { expect(svc().updatePlacesEnrich(false)).toEqual({ enabled: false }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_enrich_enabled', 'false'); expect(svc().updatePlacesEnrich(true)).toEqual({ enabled: true }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_enrich_enabled', 'true'); }); });