refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories

This commit is contained in:
jubnl
2026-09-24 03:53:44 +02:00
parent d522882590
commit b6de35a2ce
32 changed files with 633 additions and 163 deletions
@@ -76,9 +76,8 @@ function build(addonOn: boolean) {
{ put: async () => undefined, delete: async () => undefined } as never,
{ get: () => '*' } as never,
{ schedule: () => undefined } as never,
// Plan 3g Task 3: JR1's demo-mode gate reads through `UsersRepository
// .getEmail`, not `DatabaseService.prepare`.
{ getEmail: async () => 'u@example.test' } as never),
// SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive.
{ isDemoUserId: async () => false } as never),
new CollectionsRpc(spyService(calls, 'collections'), guards),
new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards, spyService(calls, 'membership')),
]);
@@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { DayScheduleController } from '../../../src/nest/plugins/contributions/day-schedule.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -29,7 +30,9 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) {
providersOf: vi.fn(() => providers),
daySchedule: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new DayScheduleController(runtime, { canAccessTrip, connection: { prepare: () => ({ all: () => tripDays.value }) } } as unknown as DatabaseService), runtime };
// CT1 (Plan 3j Task 5) — the day-id-set read is now DaysRepository.listIdsByTrip.
const days = { listIdsByTrip: vi.fn(async () => tripDays.value.map((d) => d.id)) } as unknown as DaysRepository;
return { c: new DayScheduleController(runtime, { canAccessTrip } as unknown as DatabaseService, days), runtime };
}
const item = (over: Record<string, unknown> = {}) => ({ id: 's1', dayId: 10, label: 'Charging', ...over });
@@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { DayTintsController } from '../../../src/nest/plugins/contributions/day-tints.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -29,7 +30,9 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) {
providersOf: vi.fn(() => providers),
dayTints: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new DayTintsController(runtime, { canAccessTrip, connection: { prepare: () => ({ all: () => tripDays.value }) } } as unknown as DatabaseService), runtime };
// CT2 (Plan 3j Task 5) — the day-id-set read is now DaysRepository.listIdsByTrip.
const days = { listIdsByTrip: vi.fn(async () => tripDays.value.map((d) => d.id)) } as unknown as DaysRepository;
return { c: new DayTintsController(runtime, { canAccessTrip } as unknown as DatabaseService, days), runtime };
}
const tint = (over: Record<string, unknown> = {}) => ({ dayId: 10, tone: 'success', ...over });
+129 -2
View File
@@ -74,6 +74,13 @@ import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.r
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
import type { ReservationsRepository } from '../../../src/db/repositories/Reservations.repository';
import type { DayAccommodationsRepository } from '../../../src/db/repositories/DayAccommodations.repository';
import type { PluginEntityMetadataRepository } from '../../../src/db/repositories/PluginEntityMetadata.repository';
import type { PluginScheduledTasksRepository } from '../../../src/db/repositories/PluginScheduledTasks.repository';
// Typed from the real method rather than from the always-true body below, so a case that
// swaps in an implementation reading the action key (HOSTRPC-015) still type-checks.
@@ -130,6 +137,117 @@ const pluginAuditRepo = {
},
} as unknown as PluginCapabilityAuditRepository;
/**
* Plan 3j Task 5: `MetaRpc`/`HostSurfaceRpc`'s own repository conversions
* (MR#/HR#). Same reasoning as `pluginAuditRepo` above — this file's
* hand-trimmed `:memory:` schema (no top-level await here) means a real
* MikroORM repository isn't reachable, so every converted method is
* reproduced as real raw SQL against `mockDb`, wrapped in the repository's
* own method shape, rather than a canned stub — HOSTRPC-014/028/029/030
* read the real table state back out through `mockDb.prepare(...)`
* directly, so this HAS to be the real table.
*/
type RawDb = {
prepare(sql: string): { get(...a: unknown[]): unknown; all(...a: unknown[]): unknown[]; run(...a: unknown[]): { changes: number } };
};
const raw = mockDb as unknown as RawDb;
const tripsRepo = {
async sharesTripWith(userIdA: number, userIdB: number) {
return !!raw
.prepare(
`SELECT 1 FROM trips t
LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ?
LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ?
WHERE (t.user_id = ? OR m1.user_id IS NOT NULL)
AND (t.user_id = ? OR m2.user_id IS NOT NULL)
LIMIT 1`,
)
.get(userIdA, userIdB, userIdA, userIdB);
},
async existsById(id: number) {
return !!raw.prepare('SELECT id FROM trips WHERE id = ?').get(id);
},
} as unknown as TripsRepository;
const placesRepo = {
async findTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM places WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as PlacesRepository;
const daysRepo = {
async findTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM days WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as DaysRepository;
const reservationsRepo = {
async findTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM reservations WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as ReservationsRepository;
const dayAccommodationsRepo = {
async getTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM day_accommodations WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as DayAccommodationsRepository;
const metaRepo = {
async findValue(pluginId: string, entityType: string, entityId: number, key: string) {
const row = raw
.prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.get(pluginId, entityType, entityId, key) as { value: string } | undefined;
return row?.value ?? null;
},
async countForEntity(pluginId: string, entityType: string, entityId: number) {
return (
raw
.prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?')
.get(pluginId, entityType, entityId) as { n: number }
).n;
},
async upsertValue(pluginId: string, entityType: string, entityId: number, key: string, value: string) {
raw
.prepare(
`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(plugin_id, entity_type, entity_id, key)
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.run(pluginId, entityType, entityId, key, value);
},
async listForEntity(pluginId: string, entityType: string, entityId: number) {
return raw
.prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key')
.all(pluginId, entityType, entityId) as Array<{ key: string; value: string }>;
},
async deleteValue(pluginId: string, entityType: string, entityId: number, key: string) {
return raw.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?').run(pluginId, entityType, entityId, key).changes > 0;
},
} as unknown as PluginEntityMetadataRepository;
const scheduledTasksRepo = {
async existsForPluginAndName(pluginId: string, name: string) {
return !!raw.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').get(pluginId, name);
},
async countForPlugin(pluginId: string) {
return (raw.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(pluginId) as { c: number }).c;
},
async upsertTask(input: { plugin_id: string; name: string; due_at: number; payload: string; every_ms: number | null }) {
raw
.prepare(
`INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`,
)
.run(input.plugin_id, input.name, input.due_at, input.payload, input.every_ms);
},
async deleteByPluginAndName(pluginId: string, name: string) {
return raw.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').run(pluginId, name).changes > 0;
},
} as unknown as PluginScheduledTasksRepository;
const dbs = new DatabaseService(mockDb);
// Plan 3c Task 0b: `canAccessTrip` is `TripsRepository.findAccessible` now,
// which needs a real EntityManager over the full migrated schema — this
@@ -152,12 +270,21 @@ vi.spyOn(dbs, 'canAccessTrip').mockImplementation(async (tripId, userId) =>
const seededRoles: Record<number, string> = { 5: 'trip_owner', 6: 'user', 9: 'user' };
const usersRepo = {
getRole: vi.fn(async (id: number) => seededRoles[id] ?? null),
// HR1 (Plan 3j Task 5) — same "real SQL against mockDb" reasoning as the
// repository stubs above; HOSTRPC-019 asserts the exact returned shape.
async findPublicIdentity(id: number) {
return (
(raw.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id) as
| { id: number; username: string; display_name: string | null; avatar: string | null }
| undefined) ?? null
);
},
} as unknown as UsersRepository;
const guards = new PluginGuards(dbs, permissions, addons, usersRepo);
const registry = createTestPluginRegistry([
new DbRpc(userSettings),
new MetaRpc(dbs, guards),
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo),
new MetaRpc(dbs, guards, metaRepo, tripsRepo, placesRepo, daysRepo, reservationsRepo, dayAccommodationsRepo),
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo, usersRepo, tripsRepo, scheduledTasksRepo),
]);
const factory = new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} };
@@ -5,19 +5,8 @@
* to link and no provider asset. These cases pin what the handler refuses before
* it writes anything, and that a refused write leaves no object behind.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// vi.mock is hoisted above the imports, so the flag it reads has to be too.
// Only readEnv().demo is overridden: the module is shared, and replacing it
// wholesale takes defaultLanguage and the rest of the env with it.
const { demo } = vi.hoisted(() => ({ demo: { enabled: false } }));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return { ...actual, readEnv: () => ({ ...actual.readEnv(), demo }) };
});
import { describe, it, expect, vi } from 'vitest';
import { JournalRpc } from '../../../src/nest/journey/journal.rpc';
import { DEMO_EMAIL_PRIMARY } from '../../../src/nest/common/demo';
const ACTOR = { actingUserId: 7 } as never;
const PNG = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64');
@@ -25,8 +14,10 @@ const PNG = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64');
function build(overrides: {
addPhoto?: unknown;
allowed?: string;
demoEnabled?: boolean;
email?: string;
/** SV8 (Plan 3i, R-survivors) — JournalRpc now delegates the whole demo-mode
* check (env-enabled + email match) to `DemoService.isDemoUserId`, so the
* test double only needs to say whether that call refuses. */
demoBlocked?: boolean;
} = {}) {
// Typed parameters so the assertions below can index mock.calls.
const put = vi.fn(async (_category: string, _filename: string, _body?: unknown, _opts?: unknown) => undefined);
@@ -36,27 +27,20 @@ function build(overrides: {
? () => ({ id: 5, photo_id: 42 })
: () => overrides.addPhoto);
demo.enabled = overrides.demoEnabled ?? false;
const rpc = new JournalRpc(
{ addPhoto } as never,
{ requireAddon: vi.fn() } as never,
{ put, delete: del } as never,
{ get: () => overrides.allowed ?? '*' } as never,
{ schedule } as never,
// Plan 3g Task 3: JR1's demo-mode gate reads through `UsersRepository
// .getEmail`, not `DatabaseService.prepare`.
{ getEmail: async () => overrides.email ?? 'user@example.test' } as never,
// SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive.
{ isDemoUserId: async () => overrides.demoBlocked ?? false } as never,
);
return { rpc, put, del, schedule, addPhoto };
}
const input = (over: Record<string, unknown> = {}) => ({ name: 'photo.jpg', content_base64: PNG, ...over });
beforeEach(() => {
demo.enabled = false;
});
describe('journal.addEntryPhoto', () => {
it('JPHOTO-001: stores the bytes under a name of its own, then links the photo', async () => {
const { rpc, put, schedule, addPhoto } = build();
@@ -129,7 +113,7 @@ describe('journal.addEntryPhoto', () => {
});
it('JPHOTO-007: refuses to write bytes for a demo user, exactly as the REST upload does', async () => {
const { rpc, put } = build({ demoEnabled: true, email: DEMO_EMAIL_PRIMARY });
const { rpc, put } = build({ demoBlocked: true });
await expect(rpc.addEntryPhoto({ entryId: 3, input: input() }, ACTOR)).rejects.toThrow(/disabled in demo mode/);
expect(put).not.toHaveBeenCalled();
@@ -14,6 +14,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PlaceDetailsController } from '../../../src/nest/plugins/contributions/place-details.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -23,7 +24,9 @@ function controller(over: Partial<PluginHooks> = {}) {
placeDetails: vi.fn(async (id: string) => (id === 'p2' ? [{ label: 'Rating', value: '4.5' }] : [{ label: 'Reviews', value: '12', url: 'https://x' }])),
...over,
} as unknown as PluginHooks;
return { c: new PlaceDetailsController(runtime, { canAccessTrip, connection: { prepare: () => ({ get: (placeId: number) => placeTrip(placeId) }) } } as unknown as DatabaseService), runtime };
// CT7 (Plan 3j Task 5) — the place's owning trip id is now Places.repository.ts#findTripId.
const places = { findTripId: vi.fn(async (placeId: number) => placeTrip(placeId)?.trip_id) } as unknown as PlacesRepository;
return { c: new PlaceDetailsController(runtime, { canAccessTrip } as unknown as DatabaseService, places), runtime };
}
describe('PlaceDetailsController', () => {
@@ -11,12 +11,10 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({
getMock: vi.fn(() => ({ 1: 1 })), // plugin is active by default
}));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PluginOAuthController } from '../../../src/nest/plugins/oauth/plugin-oauth.controller';
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -32,7 +30,9 @@ function ctrl(over: Partial<PluginOAuthService> = {}) {
disconnect: vi.fn(async () => undefined),
...over,
} as unknown as PluginOAuthService;
return { c: new PluginOAuthController(svc, new DatabaseService(dbConn)), svc };
// POC1 (Plan 3j Task 5) — the active-plugin guard is now Plugins.repository.ts#existsActive.
const plugins = { existsActive: vi.fn(async () => !!getMock()) } as unknown as PluginsRepository;
return { c: new PluginOAuthController(svc, plugins), svc };
}
describe('PluginOAuthController', () => {
@@ -10,13 +10,11 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({
getMock: vi.fn(() => ({ 1: 1 })), // active by default
}));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PluginUserSettingsController } from '../../../src/nest/plugins/plugin-user-settings.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import type { PluginsService } from '../../../src/nest/plugins/plugins.service';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -28,7 +26,9 @@ function ctrl() {
} as unknown as PluginsService;
// The controller now also takes the runtime (for settings-page actions).
const runtime = { actionsOf: vi.fn(async () => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService;
return { c: new PluginUserSettingsController(svc, runtime, new DatabaseService(dbConn)), svc, runtime };
// PUC1 (Plan 3j Task 5) — the active-plugin guard is now Plugins.repository.ts#existsActive.
const plugins = { existsActive: vi.fn(async () => !!getMock()) } as unknown as PluginsRepository;
return { c: new PluginUserSettingsController(svc, runtime, plugins), svc, runtime };
}
describe('PluginUserSettingsController', () => {
@@ -12,12 +12,10 @@ const { rows, pluginsEnabled } = vi.hoisted(() => ({
rows: { value: [] as Array<Record<string, unknown>> },
pluginsEnabled: vi.fn(() => true),
}));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ all: () => rows.value }) } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
const row = (over: Record<string, unknown> = {}) => ({
id: 'p1', name: 'P', type: 'integration', icon: null,
@@ -26,7 +24,9 @@ const row = (over: Record<string, unknown> = {}) => ({
});
describe('PluginsFeedController', () => {
const c = new PluginsFeedController(new DatabaseService(dbConn));
// PFC1 (Plan 3j Task 5) — the feed read is now Plugins.repository.ts#findActiveFeedRows.
const plugins = { findActiveFeedRows: vi.fn(async () => rows.value) } as unknown as PluginsRepository;
const c = new PluginsFeedController(plugins);
beforeEach(() => { pluginsEnabled.mockReturnValue(true); rows.value = []; });
it('returns an empty feed when the runtime is disabled', async () => {
@@ -67,6 +67,17 @@ async function makeService(): Promise<PluginsService> {
);
}
/**
* PFC1 (Plan 3j Task 5) — PluginsFeedController's own feed read, now
* Plugins.repository.ts#findActiveFeedRows, resolved over the SAME `testDb`
* handle every raw-SQL fixture line above writes to (same `sharedTestOrm`
* reasoning as `makeService` above).
*/
async function makeFeedController(): Promise<PluginsFeedController> {
const orm = await sharedTestOrm(testDb);
return new PluginsFeedController(orm.repo(Plugins));
}
beforeEach(() => {
testDb.exec('DELETE FROM plugins');
testDb.exec('DELETE FROM plugin_settings_fields');
@@ -247,7 +258,7 @@ describe('PluginsFeedController (client feed)', () => {
it('returns active plugins when enabled, nothing when disabled', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run();
const feed = new PluginsFeedController(new DatabaseService(dbConn));
const feed = await makeFeedController();
process.env.TREK_PLUGINS_ENABLED = 'true';
const active = await feed.list();
@@ -261,7 +272,7 @@ describe('PluginsFeedController (client feed)', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await (await makeFeedController()).list();
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
});
@@ -269,14 +280,14 @@ describe('PluginsFeedController (client feed)', () => {
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('d','D','widget','Box','active','{\"widget\":{\"slot\":\"day-detail\"}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
expect((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
expect((await (await makeFeedController()).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
});
it('exposes settingsUi only when the capability is exactly true', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('su','S','widget','Box','active','{\"settingsUi\":true}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('no','N','widget','Box','active','{\"settingsUi\":\"yes\"}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await (await makeFeedController()).list();
expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true);
expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined();
});
@@ -284,7 +295,7 @@ describe('PluginsFeedController (client feed)', () => {
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('r','R','widget','Box','active','{\"widget\":{\"slot\":\"reservation-detail\"}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
expect((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
expect((await (await makeFeedController()).list()).plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
});
it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', async () => {
@@ -294,7 +305,7 @@ describe('PluginsFeedController (client feed)', () => {
// the capability is meaningless off a trip-page and must not leak onto widgets
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('w2','W2','widget','Box','active','{\"tripPage\":{\"replaces\":[\"transports\"]}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await (await makeFeedController()).list();
expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 });
expect(out.plugins.find((p) => p.id === 'evil')?.tripPage).toBeUndefined();
expect(out.plugins.find((p) => p.id === 'w2')?.tripPage).toBeUndefined();