mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
d4a1601855
Twelve admin packing-template routes sat on AdminController and reached
PackingService through eleven pass-through methods on AdminService that did
nothing but forward and cast. PackingService already owns all three template
tables, so the routes move to AdminPackingTemplatesController next to it and the
pass-through block is gone. AdminService no longer injects PackingService at all.
The three places flags were raw app_settings SQL on AdminService, sitting beside
bag-tracking and collab-features which it already delegated to AddonsService.
They move to AddonsService, which owns every other flag, and AdminController now
calls it directly rather than through delegating methods.
The toggle ROUTES stay on AdminController, which is a departure from the plan and
worth stating: an admin-gated controller in AddonsModule makes that module import
AuthModule, and PluginGuardsModule imports AddonsModule precisely because it is a
leaf — Nest refused to build the graph. Same for the addon CRUD, whose PUT
cascades into PluginRuntimeService. Both notes are in the code.
Paths, response shapes, the {error,status} envelope, the create-201 split and
every audit action are unchanged.
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
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<Record<string, unknown>> };
|
|
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<Record<string, unknown>> }).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');
|
|
}
|
|
});
|
|
});
|