From 811230fa5d2bc9fccc18d4f80ddadbe37fc2e118 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 26 Jul 2026 15:28:55 +0200 Subject: [PATCH] refactor(plugins): wire PluginHostDepsFactory through DI, drop tags/categories bridges --- server/src/nest/budget/budget.module.ts | 2 + .../src/nest/categories/categories.bridge.ts | 21 --- .../src/nest/categories/categories.module.ts | 2 + .../src/nest/categories/categories.service.ts | 4 +- .../nest/plugins/plugin-runtime.service.ts | 19 ++- server/src/nest/plugins/plugins.module.ts | 11 +- server/src/nest/plugins/plugins.service.ts | 2 +- .../nest/reservations/reservations.module.ts | 2 + server/src/nest/tags/tags.bridge.ts | 37 ----- server/src/nest/tags/tags.module.ts | 2 + server/src/nest/tags/tags.service.ts | 4 +- server/src/nest/todo/todo.bridge.ts | 15 +- server/tests/helpers/mcp-test-controllers.ts | 2 +- server/tests/helpers/plugin-host.ts | 32 ++++ .../integration/plugins/dev-link.test.ts | 3 +- .../plugins/plugin-runtime.test.ts | 49 +++--- .../unit/nest/categories.service.test.ts | 19 +-- server/tests/unit/nest/tags.service.test.ts | 48 +----- .../unit/plugins/operator-egress.test.ts | 3 +- .../plugins/plugin-host-deps.factory.test.ts | 142 ++++++++++-------- .../unit/plugins/provider-hook-grant.test.ts | 4 +- 21 files changed, 197 insertions(+), 226 deletions(-) delete mode 100644 server/src/nest/categories/categories.bridge.ts delete mode 100644 server/src/nest/tags/tags.bridge.ts create mode 100644 server/tests/helpers/plugin-host.ts diff --git a/server/src/nest/budget/budget.module.ts b/server/src/nest/budget/budget.module.ts index 6c5a8d753..cca2eeec2 100644 --- a/server/src/nest/budget/budget.module.ts +++ b/server/src/nest/budget/budget.module.ts @@ -6,5 +6,7 @@ import { BudgetService } from './budget.service'; @Module({ controllers: [BudgetController], providers: [BudgetService], + // For in-container consumers (PluginHostDepsFactory). + exports: [BudgetService], }) export class BudgetModule {} diff --git a/server/src/nest/categories/categories.bridge.ts b/server/src/nest/categories/categories.bridge.ts deleted file mode 100644 index 49a091b55..000000000 --- a/server/src/nest/categories/categories.bridge.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Category } from '@trek/shared'; -import { db } from '../../db/database'; -import { DatabaseService } from '../database/database.service'; -import { CategoriesService } from './categories.service'; - -/** - * Non-Nest entry point for the categories domain — for code running OUTSIDE - * the Nest container (currently only the plugin RPC host; the MCP tool and - * resource moved to the DI-discovered categories.mcp.ts). Exports only the - * legacy services/categoryService names still consumed outside the container, - * 1:1, so repointing a consumer is an import-path-only diff. Inside the - * container, inject CategoriesService instead. - * - * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto - * the shared better-sqlite3 singleton (same pattern as tags.bridge.ts). - */ -const categories = new CategoriesService(new DatabaseService(db)); - -export function listCategories(): Category[] { - return categories.list(); -} diff --git a/server/src/nest/categories/categories.module.ts b/server/src/nest/categories/categories.module.ts index 233234686..8b15b331d 100644 --- a/server/src/nest/categories/categories.module.ts +++ b/server/src/nest/categories/categories.module.ts @@ -7,5 +7,7 @@ import { CategoriesService } from './categories.service'; @Module({ controllers: [CategoriesController], providers: [CategoriesService, CategoriesMcp], + // For in-container consumers (PluginHostDepsFactory). + exports: [CategoriesService], }) export class CategoriesModule {} diff --git a/server/src/nest/categories/categories.service.ts b/server/src/nest/categories/categories.service.ts index 0bd44c5fd..9daa45bfc 100644 --- a/server/src/nest/categories/categories.service.ts +++ b/server/src/nest/categories/categories.service.ts @@ -5,8 +5,8 @@ import { DatabaseService } from '../database/database.service'; /** * Categories domain service — owns the category SQL (moved 1:1 from the legacy * services/categoryService.ts: identical statements, the #6366f1/📍 defaults - * and the COALESCE update semantics). Non-Nest consumers (plugin RPC host) go - * through categories.bridge.ts instead of importing this class directly. + * and the COALESCE update semantics). All consumers are in-container now — the + * plugin RPC host injects this class via PluginHostDepsFactory. */ @Injectable() export class CategoriesService { diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 0268e1470..6de48cedc 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -14,7 +14,8 @@ import { decrypt_api_key } from '../../services/apiKeyCrypto'; import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor'; import fs from 'node:fs'; import path from 'node:path'; -import { createRealRpcHost, closePluginDataDb } from './host/create-rpc-host'; +import { PluginHostDepsFactory } from './host/plugin-host-deps.factory'; +import { closePluginDataDb } from './host/plugin-host-state'; import { ForbiddenResource } from './host/rpc-host'; import { removePluginData } from './host/plugin-data.service'; import { isKnownPermission } from './protocol/envelope'; @@ -130,8 +131,13 @@ export class PluginDependencyError extends Error { export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // The rpc-host factory is bound to `this` as the inter-plugin router, so a // plugin's ctx.plugins.call / ctx.events.emit resolve through callPlugin/ - // emitPluginEvent below (which own the dependency-edge authorization). - private readonly supervisor = new PluginSupervisor((id, granted) => createRealRpcHost(id, granted, this), { + // emitPluginEvent below (which own the dependency-edge authorization). The + // arrow reads this.hostDeps lazily at spawn time, so the field-initializer + // ordering (it runs before the constructor params are assigned) is safe. + private readonly supervisor = new PluginSupervisor((id, granted) => { + if (!this.hostDeps) throw new Error('PluginHostDepsFactory not provided — tests that activate plugins must pass one'); + return this.hostDeps.create(id, granted, this); + }, { // Both hooks run from child lifecycle EventEmitter callbacks (exit / stderr 'data'), // so a throw here becomes an uncaughtException that has no host-side handler. During a // restore the core DB is briefly CLOSED (closeDb → the this.db proxy throws on access), so a @@ -163,12 +169,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // Coalesces overlapping erasure drains (the sweep and enqueue both trigger one). private drainInFlight: Promise | null = null; - // The registry stays optional at the type level so tests can construct the - // service without one; Nest always injects the real one (the provider is in - // the module). + // The registry and host-deps factory stay optional at the type level so tests + // can construct the service without them; Nest always injects the real ones + // (both providers are in the module). constructor( private readonly dbs: DatabaseService, private readonly registry?: PluginRegistryService, + private readonly hostDeps?: PluginHostDepsFactory, ) {} private get db() { diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index cd4b93e3a..5706b5789 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -23,6 +23,12 @@ import { PluginOAuthService } from './plugin-oauth.service'; import { PluginsService } from './plugins.service'; import { PluginRuntimeService } from './plugin-runtime.service'; import { PluginRegistryService } from './registry/registry.service'; +import { PluginHostDepsFactory } from './host/plugin-host-deps.factory'; +import { TagsModule } from '../tags/tags.module'; +import { CategoriesModule } from '../categories/categories.module'; +import { BudgetModule } from '../budget/budget.module'; +import { ReservationsModule } from '../reservations/reservations.module'; +import { TodoModule } from '../todo/todo.module'; /** * Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend: @@ -32,8 +38,11 @@ import { PluginRegistryService } from './registry/registry.service'; * widget assets at /plugin-frame/:id/*. */ @Module({ + // The DI-native domain services the plugin host wiring injects + // (PluginHostDepsFactory); DatabaseModule is @Global, so not listed. + imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule], controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController], - providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService], + providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory], // Exported so the admin addon-toggle handler can cascade-disable plugins whose // required addon was just turned off (#plugins dependencies). exports: [PluginRuntimeService], diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index b8b60514d..2e18c6ce0 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -6,7 +6,7 @@ import { devLinkEnabled } from './dev-link'; import { maybe_encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto'; import { readAudit } from './host/plugin-audit'; import { keyFingerprint } from './signature-status'; -import { pluginBudgetUsage } from './host/create-rpc-host'; +import { pluginBudgetUsage } from './host/plugin-host-state'; import { isAddonEnabled } from '../../services/adminService'; import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies'; import { hostSatisfies, hostVersion } from './install/host-compat'; diff --git a/server/src/nest/reservations/reservations.module.ts b/server/src/nest/reservations/reservations.module.ts index 9401abde1..e26216417 100644 --- a/server/src/nest/reservations/reservations.module.ts +++ b/server/src/nest/reservations/reservations.module.ts @@ -13,5 +13,7 @@ import { UpcomingReservationsController } from './upcoming-reservations.controll @Module({ controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController], providers: [ReservationsService, AccommodationsService], + // For in-container consumers (PluginHostDepsFactory). + exports: [ReservationsService], }) export class ReservationsModule {} diff --git a/server/src/nest/tags/tags.bridge.ts b/server/src/nest/tags/tags.bridge.ts deleted file mode 100644 index 72fc9481b..000000000 --- a/server/src/nest/tags/tags.bridge.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Tag } from '@trek/shared'; -import { db } from '../../db/database'; -import { DatabaseService } from '../database/database.service'; -import { TagsService } from './tags.service'; - -/** - * Non-Nest entry point for the tags domain — for code running OUTSIDE the Nest - * container (currently only the plugin RPC host; the MCP tools moved to the - * DI-discovered tags.mcp.ts). Exports the legacy services/tagService function - * names 1:1 so repointing a consumer is an import-path-only diff. Inside the - * container, inject TagsService instead. - * - * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto - * the shared better-sqlite3 singleton (same pattern as - * plugins/host/create-rpc-host.ts). - */ -const tags = new TagsService(new DatabaseService(db)); - -export function listTags(userId: number): Tag[] { - return tags.list(userId); -} - -export function createTag(userId: number, name: string, color?: string): Tag { - return tags.create(userId, name, color); -} - -export function getTagByIdAndUser(tagId: number | string, userId: number): Tag | undefined { - return tags.getByIdAndUser(tagId, userId); -} - -export function updateTag(tagId: number | string, name?: string, color?: string): Tag | undefined { - return tags.update(tagId, name, color); -} - -export function deleteTag(tagId: number | string): void { - tags.remove(tagId); -} diff --git a/server/src/nest/tags/tags.module.ts b/server/src/nest/tags/tags.module.ts index c9e54aef8..12ba2d702 100644 --- a/server/src/nest/tags/tags.module.ts +++ b/server/src/nest/tags/tags.module.ts @@ -7,5 +7,7 @@ import { TagsService } from './tags.service'; @Module({ controllers: [TagsController], providers: [TagsService, TagsMcp], + // For in-container consumers (PluginHostDepsFactory). + exports: [TagsService], }) export class TagsModule {} diff --git a/server/src/nest/tags/tags.service.ts b/server/src/nest/tags/tags.service.ts index 6d7e059c7..eccd41e8a 100644 --- a/server/src/nest/tags/tags.service.ts +++ b/server/src/nest/tags/tags.service.ts @@ -5,8 +5,8 @@ import { DatabaseService } from '../database/database.service'; /** * Tags domain service — owns the tag SQL (moved 1:1 from the legacy * services/tagService.ts: identical statements, the #10b981 default and the - * COALESCE update semantics). Non-Nest consumers (MCP tools, plugin RPC host) - * go through tags.bridge.ts instead of importing this class directly. + * COALESCE update semantics). All consumers are in-container now — the plugin + * RPC host injects this class via PluginHostDepsFactory. */ @Injectable() export class TagsService { diff --git a/server/src/nest/todo/todo.bridge.ts b/server/src/nest/todo/todo.bridge.ts index c32f31643..63da985c0 100644 --- a/server/src/nest/todo/todo.bridge.ts +++ b/server/src/nest/todo/todo.bridge.ts @@ -4,15 +4,16 @@ import { TodoService } from './todo.service'; /** * Non-Nest entry point for the todo domain — for code running OUTSIDE the - * Nest container (the plugin RPC host and the legacy get_trip_summary - * registrar in src/mcp/tools/trips.ts; the todo MCP tools and resource moved - * to the DI-discovered todo.mcp.ts). Exports only the legacy - * services/todoService names still consumed outside the container, 1:1, so - * repointing a consumer is an import-path-only diff. Inside the container, - * inject TodoService instead. + * Nest container (the legacy get_trip_summary registrar in + * src/mcp/tools/trips.ts; the todo MCP tools and resource moved to the + * DI-discovered todo.mcp.ts, and the plugin RPC host injects TodoService via + * PluginHostDepsFactory). Exports only the legacy services/todoService names + * still consumed outside the container, 1:1, so repointing a consumer is an + * import-path-only diff. Inside the container, inject TodoService instead. + * Delete this file when the legacy MCP trips registrar migrates. * * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto - * the shared better-sqlite3 singleton (same pattern as categories.bridge.ts). + * the shared better-sqlite3 singleton. */ const todos = new TodoService(new DatabaseService(db)); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 4c8d98f69..0e77c6dab 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -14,7 +14,7 @@ import { TodoService } from '../../src/nest/todo/todo.service'; * for the no-Nest MCP harness. One line per migrated domain — add the new * @McpController instance here when a domain moves off the legacy registrar * fan-out. Constructing against the `db` Proxy keeps per-file vi.mock's of - * src/db/database flowing through (same pattern as tags.bridge.ts). + * src/db/database flowing through (same pattern as todo.bridge.ts). */ export function createMcpTestRegistry(): McpRegistry { const dbService = new DatabaseService(db); diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts new file mode 100644 index 000000000..ccfb42ad4 --- /dev/null +++ b/server/tests/helpers/plugin-host.ts @@ -0,0 +1,32 @@ +import { DatabaseService } from '../../src/nest/database/database.service'; +import { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.service'; +import type { PluginRegistryService } from '../../src/nest/plugins/registry/registry.service'; +import { PluginHostDepsFactory } from '../../src/nest/plugins/host/plugin-host-deps.factory'; +import { PluginOAuthService } from '../../src/nest/plugins/plugin-oauth.service'; +import { BudgetService } from '../../src/nest/budget/budget.service'; +import { ReservationsService } from '../../src/nest/reservations/reservations.service'; +import { TagsService } from '../../src/nest/tags/tags.service'; +import { CategoriesService } from '../../src/nest/categories/categories.service'; +import { TodoService } from '../../src/nest/todo/todo.service'; + +/** + * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests + * (same pattern as mcp-test-controllers.ts): real domain services over the + * test DB, so runtime tests exercise the same wiring production gets from + * the container. + */ +export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFactory { + return new PluginHostDepsFactory( + new BudgetService(dbs), + new ReservationsService(dbs), + new TagsService(dbs), + new CategoriesService(dbs), + new TodoService(dbs), + new PluginOAuthService(dbs), + ); +} + +/** A PluginRuntimeService constructed the way Nest would: with a real host-deps factory. */ +export function createPluginRuntime(dbs: DatabaseService, registry?: PluginRegistryService): PluginRuntimeService { + return new PluginRuntimeService(dbs, registry, createHostDepsFactory(dbs)); +} diff --git a/server/tests/integration/plugins/dev-link.test.ts b/server/tests/integration/plugins/dev-link.test.ts index d9ab79f6a..87ffe90c2 100644 --- a/server/tests/integration/plugins/dev-link.test.ts +++ b/server/tests/integration/plugins/dev-link.test.ts @@ -32,6 +32,7 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import { createPluginRuntime } from '../../helpers/plugin-host'; let codeRoot: string; let dataRoot: string; @@ -60,7 +61,7 @@ beforeAll(() => { process.env.TREK_PLUGINS_DATA_DIR = dataRoot; process.env.TREK_PLUGINS_ENABLED = 'true'; process.env.TREK_PLUGINS_DEV_LINK = '1'; - runtime = new PluginRuntimeService(new DatabaseService(dbConn)); + runtime = createPluginRuntime(new DatabaseService(dbConn)); }); afterAll(async () => { diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index e35372686..2ef14e2f3 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -48,6 +48,7 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service'; +import { createPluginRuntime } from '../../helpers/plugin-host'; import { DependencyCycleError } from '../../../src/nest/plugins/dependencies'; let codeRoot: string; @@ -79,7 +80,7 @@ beforeAll(() => { ); testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run(); - runtime = new PluginRuntimeService(new DatabaseService(dbConn)); + runtime = createPluginRuntime(new DatabaseService(dbConn)); }); afterAll(async () => { @@ -149,7 +150,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { it('onModuleInit is a no-op when the runtime is disabled', () => { process.env.TREK_PLUGINS_ENABLED = 'false'; - expect(() => new PluginRuntimeService(new DatabaseService(dbConn)).onModuleInit()).not.toThrow(); + expect(() => createPluginRuntime(new DatabaseService(dbConn)).onModuleInit()).not.toThrow(); process.env.TREK_PLUGINS_ENABLED = 'true'; }); @@ -172,7 +173,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { fs.mkdirSync(path.join(codeRoot, 'messy', 'server'), { recursive: true }); fs.writeFileSync(path.join(codeRoot, 'messy', 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('messy','inactive','not-json','not-json')").run(); - const rt = new PluginRuntimeService(new DatabaseService(dbConn)); + const rt = createPluginRuntime(new DatabaseService(dbConn)); await rt.activate('messy'); // must not throw despite the garbage JSON expect(rt.isActive('messy')).toBe(true); await rt.deactivate('messy'); @@ -251,7 +252,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { // status='error' from a previous crash, but enabled=1 → must still boot testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('booter','error',1,'[]','{}')").run(); - const rt = new PluginRuntimeService(new DatabaseService(dbConn)); + const rt = createPluginRuntime(new DatabaseService(dbConn)); rt.onModuleInit(); // fire-and-forget spawn for (let i = 0; i < 40 && !rt.isActive('booter'); i++) await new Promise((r) => setTimeout(r, 50)); expect(rt.isActive('booter')).toBe(true); @@ -263,7 +264,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { fs.writeFileSync(path.join(codeRoot, 'sleeper', 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('sleeper','inactive',0,'[]','{}')").run(); - const rt = new PluginRuntimeService(new DatabaseService(dbConn)); + const rt = createPluginRuntime(new DatabaseService(dbConn)); rt.onModuleInit(); await new Promise((r) => setTimeout(r, 300)); expect(rt.isActive('sleeper')).toBe(false); @@ -271,7 +272,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { it('outboundHostsOf extracts declared http:outbound hosts', () => { testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run(); - const rt = new PluginRuntimeService(new DatabaseService(dbConn)); + const rt = createPluginRuntime(new DatabaseService(dbConn)); expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']); expect(rt.outboundHostsOf('missing')).toEqual([]); }); @@ -293,7 +294,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES ('gone', 'poll', 0)").run(); testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('gone', 7)").run(); - await new PluginRuntimeService(new DatabaseService(dbConn)).uninstall('gone', true); + await createPluginRuntime(new DatabaseService(dbConn)).uninstall('gone', true); expect(fs.existsSync(path.join(codeRoot, 'gone'))).toBe(false); expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='gone'").get()).toMatchObject({ c: 0 }); @@ -312,7 +313,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { // a deleted user's erasure is still pending for this plugin testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('keepdata', 42)").run(); - await new PluginRuntimeService(new DatabaseService(dbConn)).uninstall('keepdata', false); // keep the plugin's data + await createPluginRuntime(new DatabaseService(dbConn)).uninstall('keepdata', false); // keep the plugin's data // the plugin row is gone, but the erasure obligation is retained — a reinstall of the // same id will drain it and finally honour the deletion, instead of silently keeping @@ -327,12 +328,12 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { "module.exports = { async onLoad() { await fetch('https://blocked.example/x'); } };"); testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('nettry','inactive','[]','{}')").run(); // no http:outbound granted -> egress guard blocks all outbound -> onLoad throws - await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('nettry')).rejects.toThrow(/egress/); - await new PluginRuntimeService(new DatabaseService(dbConn)).deactivate('nettry').catch(() => {}); + await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('nettry')).rejects.toThrow(/egress/); + await createPluginRuntime(new DatabaseService(dbConn)).deactivate('nettry').catch(() => {}); }); it('onModuleDestroy tears down cleanly', async () => { - await expect(new PluginRuntimeService(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined(); + await expect(createPluginRuntime(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined(); }); }); @@ -354,7 +355,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => { it('restarts transparently when the new version requests no new permissions', async () => { seed('upd-same', 1, ['db:own'], ['db:own']); - const rt = new PluginRuntimeService(new DatabaseService(dbConn), fakeRegistry(() => {})); // declared perms unchanged + const rt = createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})); // declared perms unchanged await rt.activate('upd-same'); const res = await rt.update('upd-same'); expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] }); @@ -364,7 +365,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => { it('leaves the plugin inactive and reports the delta when new rights are requested', async () => { seed('upd-wider', 1, ['db:own'], ['db:own']); - const rt = new PluginRuntimeService( + const rt = createPluginRuntime( new DatabaseService(dbConn), fakeRegistry(() => { testDb.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'") @@ -382,17 +383,17 @@ describe('PluginRuntimeService.update (re-consent gate)', () => { it('a disabled plugin stays disabled even with no new permissions', async () => { seed('upd-off', 0, ['db:own'], ['db:own']); - const res = await new PluginRuntimeService(new DatabaseService(dbConn), fakeRegistry(() => {})).update('upd-off'); + const res = await createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})).update('upd-off'); expect(res).toMatchObject({ activated: false, newPermissions: [], newEgress: [] }); }); it('throws for an unknown plugin id', async () => { - await expect(new PluginRuntimeService(new DatabaseService(dbConn), fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/); + await expect(createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/); }); it('throws if no registry service is wired', async () => { seed('upd-noreg', 0, ['db:own'], ['db:own']); - await expect(new PluginRuntimeService(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/); + await expect(createPluginRuntime(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/); }); }); @@ -559,7 +560,7 @@ describe('PluginRuntimeService.retrust', () => { it('re-pins AND installs in one call: the new key is pinned, the version moved, the block gone', async () => { seed('rt-ok', 'OLDKEY'); const registry = registryFor('rt-ok'); - const rt = new PluginRuntimeService(new DatabaseService(dbConn), registry); + const rt = createPluginRuntime(new DatabaseService(dbConn), registry); await rt.activate('rt-ok'); await rt.retrust('rt-ok', '2.0.0', 'NEWKEY', { userId: 1, ip: '10.0.0.1' }); @@ -604,7 +605,7 @@ describe('PluginRuntimeService.retrust', () => { seed('rt-no', 'OLDKEY'); const registry = registryFor('rt-no'); vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust')); - const rt = new PluginRuntimeService(new DatabaseService(dbConn), registry); + const rt = createPluginRuntime(new DatabaseService(dbConn), registry); await expect(rt.retrust('rt-no', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/); @@ -630,7 +631,7 @@ describe('PluginRuntimeService.retrust', () => { // exactly the "quietly stops updating" failure the block exists to prevent. it('activate does NOT clear a recorded update block', async () => { seed('rt-act', 'OLDKEY'); - const rt = new PluginRuntimeService(new DatabaseService(dbConn), registryFor('rt-act')); + const rt = createPluginRuntime(new DatabaseService(dbConn), registryFor('rt-act')); await rt.deactivate('rt-act'); await rt.activate('rt-act'); @@ -666,7 +667,7 @@ describe('TREK host-version gating', () => { it('refuses to activate a plugin this TREK has outgrown, and says so with a code', async () => { process.env.APP_VERSION = '4.0.0'; seedRanged('outgrown', '>=3.2.0 <4.0.0'); - const err = await new PluginRuntimeService(new DatabaseService(dbConn)).activate('outgrown').catch((e) => e); + const err = await createPluginRuntime(new DatabaseService(dbConn)).activate('outgrown').catch((e) => e); expect(err).toBeInstanceOf(PluginDependencyError); expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); expect(err.detail).toMatchObject({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }); @@ -678,14 +679,14 @@ describe('TREK host-version gating', () => { it('refuses a plugin that never declared a range at all', async () => { process.env.APP_VERSION = '3.3.0'; seedRanged('undeclared', null); - await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' }); + await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' }); cleanup('undeclared'); }); it('activates normally when the range admits the host', async () => { process.env.APP_VERSION = '3.3.0'; seedRanged('fits', '>=3.2.0 <4.0.0'); - const rt = new PluginRuntimeService(new DatabaseService(dbConn)); + const rt = createPluginRuntime(new DatabaseService(dbConn)); await rt.activate('fits'); expect(rt.isActive('fits')).toBe(true); await rt.deactivate('fits'); @@ -699,7 +700,7 @@ describe('TREK host-version gating', () => { // panel showing a green, running plugin that does not exist. process.env.APP_VERSION = '4.0.0'; seedRanged('boot-broken', '>=3.2.0 <4.0.0', 1); - const rt = new PluginRuntimeService(new DatabaseService(dbConn)); + const rt = createPluginRuntime(new DatabaseService(dbConn)); expect(() => rt.onModuleInit()).not.toThrow(); for (let i = 0; i < 40; i++) { const row = testDb.prepare("SELECT enabled FROM plugins WHERE id='boot-broken'").get() as { enabled: number }; @@ -717,7 +718,7 @@ describe('TREK host-version gating', () => { fs.mkdirSync(path.join(codeRoot, 'both-wrong', 'server'), { recursive: true }); fs.writeFileSync(path.join(codeRoot, 'both-wrong', 'server', 'index.js'), 'module.exports = {};'); testDb.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('both-wrong','inactive',0,'[\"db:own\"]','[]','{}','>=3.2.0 <4.0.0')").run(); - await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); + await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); cleanup('both-wrong'); }); }); diff --git a/server/tests/unit/nest/categories.service.test.ts b/server/tests/unit/nest/categories.service.test.ts index 9c40b339e..f23084ffe 100644 --- a/server/tests/unit/nest/categories.service.test.ts +++ b/server/tests/unit/nest/categories.service.test.ts @@ -1,9 +1,9 @@ /** - * Unit tests for CategoriesService — CAT-SVC-001 through CAT-SVC-016. + * Unit tests for CategoriesService — CAT-SVC-001 through CAT-SVC-015. * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. * The service is constructed directly (new CategoriesService(new DatabaseService(db))) - * — no Nest container needed. CAT-SVC-016 covers the categories.bridge export - * used by the non-Nest plugin RPC host. + * — no Nest container needed. (CAT-SVC-016 covered the deleted + * categories.bridge; the plugin RPC host now injects CategoriesService directly.) */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -39,7 +39,6 @@ import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { CategoriesService } from '../../../src/nest/categories/categories.service'; -import { listCategories } from '../../../src/nest/categories/categories.bridge'; const svc = new CategoriesService(new DatabaseService(testDb)); @@ -188,15 +187,3 @@ describe('remove', () => { expect(() => svc.remove(99999)).not.toThrow(); }); }); - -// ── categories.bridge (non-Nest entry point) ────────────────────────────────── - -describe('categories.bridge', () => { - it('CAT-SVC-016 — listCategories delegates to the service', () => { - const { user } = createUser(testDb); - svc.create(user.id, 'Bridged'); - const names = listCategories().map((c) => c.name); - expect(names).toContain('Bridged'); - expect(names).toEqual([...names].sort()); - }); -}); diff --git a/server/tests/unit/nest/tags.service.test.ts b/server/tests/unit/nest/tags.service.test.ts index 708931862..f5847ee57 100644 --- a/server/tests/unit/nest/tags.service.test.ts +++ b/server/tests/unit/nest/tags.service.test.ts @@ -1,9 +1,9 @@ /** - * Unit tests for TagsService — TAG-SVC-001 through TAG-SVC-020. + * Unit tests for TagsService — TAG-SVC-001 through TAG-SVC-015. * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. * The service is constructed directly (new TagsService(new DatabaseService(db))) - * — no Nest container needed. TAG-SVC-016..020 cover the tags.bridge exports - * used by non-Nest consumers (MCP tools, plugin RPC host). + * — no Nest container needed. (TAG-SVC-016..020 covered the deleted + * tags.bridge; the plugin RPC host now injects TagsService directly.) */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -39,7 +39,6 @@ import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { TagsService } from '../../../src/nest/tags/tags.service'; -import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../../src/nest/tags/tags.bridge'; const svc = new TagsService(new DatabaseService(testDb)); @@ -185,44 +184,3 @@ describe('remove', () => { expect(remaining[0].id).toBe(t1.id); }); }); - -// ── tags.bridge (non-Nest entry point) ──────────────────────────────────────── - -describe('tags.bridge', () => { - it('TAG-SVC-016 — listTags delegates to the service', () => { - const { user } = createUser(testDb); - createTag(user.id, 'Bravo'); - createTag(user.id, 'Alpha'); - expect(listTags(user.id).map((t) => t.name)).toEqual(['Alpha', 'Bravo']); - }); - - it('TAG-SVC-017 — createTag applies the default color', () => { - const { user } = createUser(testDb); - const tag = createTag(user.id, 'Bridged'); - expect(tag.color).toBe('#10b981'); - expect(tag.user_id).toBe(user.id); - }); - - it('TAG-SVC-018 — getTagByIdAndUser scopes to the owner', () => { - const { user: a } = createUser(testDb); - const { user: b } = createUser(testDb); - const tag = createTag(a.id, 'Mine'); - expect(getTagByIdAndUser(tag.id, a.id)?.name).toBe('Mine'); - expect(getTagByIdAndUser(tag.id, b.id)).toBeUndefined(); - }); - - it('TAG-SVC-019 — updateTag keeps COALESCE semantics', () => { - const { user } = createUser(testDb); - const tag = createTag(user.id, 'Before', '#eeeeee'); - const updated = updateTag(tag.id, 'After', undefined); - expect(updated?.name).toBe('After'); - expect(updated?.color).toBe('#eeeeee'); - }); - - it('TAG-SVC-020 — deleteTag removes the row', () => { - const { user } = createUser(testDb); - const tag = createTag(user.id, 'Gone'); - deleteTag(tag.id); - expect(getTagByIdAndUser(tag.id, user.id)).toBeUndefined(); - }); -}); diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts index 160b386e0..40de0ba30 100644 --- a/server/tests/unit/plugins/operator-egress.test.ts +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -27,6 +27,7 @@ vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_K import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import { createPluginRuntime } from '../../helpers/plugin-host'; import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest'; import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy'; @@ -44,7 +45,7 @@ beforeEach(() => { testDb.prepare('DELETE FROM plugins').run(); testDb.prepare('DELETE FROM plugin_egress_hosts').run(); testDb.prepare('DELETE FROM plugin_actions').run(); - rt = new PluginRuntimeService(new DatabaseService(dbConn)); + rt = createPluginRuntime(new DatabaseService(dbConn)); }); describe('operator-supplied egress hosts', () => { diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index 05e0d4e4c..548b50ad0 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -1,8 +1,11 @@ /** - * The production wiring that connects a plugin's capability host to the real - * privileged modules (#plugins, M1). Verifies the per-plugin data db is cached, - * a granted db:own call works through the wired host, and trip broadcasts are - * force-namespaced to plugin:{id}:{event}. + * The production wiring (PluginHostDepsFactory) that connects a plugin's + * capability host to the real privileged modules (#plugins, M1). Verifies the + * per-plugin data db is cached, a granted db:own call works through the wired + * host, and trip broadcasts are force-namespaced to plugin:{id}:{event}. + * DI-native domains (budget/reservations/tags/categories/todo/oauth) are + * constructor-injected stubs; legacy services/* domains stay path-mocked + * until their own DI migration lands. */ import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; import fs from 'node:fs'; @@ -43,22 +46,22 @@ vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser })); // Addon gate — flip per test to exercise the "addon disabled" branch of the reads. const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true as boolean) })); vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled })); -vi.mock('../../../src/nest/budget/budget.service', () => ({ - BudgetService: class { - async create(tid: string, input: Record) { return { id: 1, trip_id: Number(tid), ...input }; } - async update(id: string, tid: string, input: Record) { - return id === '404' ? null : { id: Number(id), trip_id: Number(tid), ...input }; - } - remove(id: string, _tid: string) { return id !== '404'; } +// DI-native services are constructor-injected into PluginHostDepsFactory below — +// stub instances instead of path mocks (same behaviors as before the DI move). +const budgetStub = { + async create(tid: string, input: Record) { return { id: 1, trip_id: Number(tid), ...input }; }, + async update(id: string, tid: string, input: Record) { + return id === '404' ? null : { id: Number(id), trip_id: Number(tid), ...input }; }, -})); + remove(id: string, _tid: string) { return id !== '404'; }, +} as unknown as BudgetService; // Edit permission — flip per test to exercise the gates. // eslint-disable-next-line @typescript-eslint/no-explicit-any const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn((..._a: any[]) => true as boolean) })); vi.mock('../../../src/services/permissions', () => ({ checkPermission })); -// The core write services are delegated to; mock them so the create-rpc-host deps' +// The core write services are delegated to; mock them so the host deps' // wiring + error branches run without the full core schema. The error classes must // be defined INSIDE the factory (vi.mock is hoisted above module-scope code). vi.mock('../../../src/services/tripService', () => { @@ -113,10 +116,19 @@ vi.mock('../../../src/services/assignmentService', () => ({ placeExists: vi.fn((placeId: number) => placeId === 7), getAssignmentForTrip: vi.fn((id: number) => (id === 99 ? undefined : { id })), })); -vi.mock('../../../src/services/budgetService', () => ({ listBudgetItems: vi.fn(() => []) })); +// The extra named exports satisfy the real reservations.service.ts module (now +// loaded un-mocked as a factory constructor dep); its instance is stubbed, so +// these are never called. +vi.mock('../../../src/services/budgetService', () => ({ + listBudgetItems: vi.fn(() => []), + createBudgetItem: vi.fn(), + updateBudgetItem: vi.fn(), + deleteBudgetItem: vi.fn(), + linkBudgetItemToReservation: vi.fn(), +})); vi.mock('../../../src/services/packingService', () => ({ listItems: vi.fn((tid: number, userId: number) => [{ id: 1, trip_id: tid, name: 'Socks', _uid: userId }]), - // Return the item with the #858 privacy fields the create-rpc-host deps scope on. + // Return the item with the #858 privacy fields the host deps scope on. createItem: vi.fn((tid: number, input: { name: string; visibility?: string; recipient_ids?: number[] }, ownerId?: number) => { if (input.visibility === 'personal') return { id: 70, trip_id: Number(tid), name: input.name, is_private: 1, owner_id: ownerId, recipients: [] }; if (input.visibility === 'shared') return { id: 70, trip_id: Number(tid), name: input.name, is_private: 1, owner_id: ownerId, recipients: (input.recipient_ids || []).map((id) => ({ user_id: id })) }; @@ -136,20 +148,20 @@ vi.mock('../../../src/services/packingService', () => ({ })); vi.mock('../../../src/services/conflictResult', () => ({ isUpdateConflict: (r: unknown) => !!(r as { conflict?: boolean })?.conflict })); vi.mock('../../../src/services/weatherService', () => ({ getWeather: vi.fn(async (lat: string, lng: string) => ({ lat, lng, temp: 20 })) })); -vi.mock('../../../src/nest/categories/categories.bridge', () => ({ listCategories: vi.fn(() => [{ id: 1, name: 'Food' }]) })); -vi.mock('../../../src/nest/tags/tags.bridge', () => ({ - listTags: vi.fn((uid: number) => [{ id: 1, user_id: uid, name: 'work' }]), - createTag: vi.fn((uid: number, name: string, color?: string) => ({ id: 9, user_id: uid, name, color })), - getTagByIdAndUser: vi.fn((tagId: number, _uid: number) => (Number(tagId) === 404 ? undefined : { id: Number(tagId) })), - updateTag: vi.fn((tagId: number, name?: string) => ({ id: Number(tagId), name })), - deleteTag: vi.fn(), -})); -vi.mock('../../../src/nest/todo/todo.bridge', () => ({ +const categoriesStub = { list: vi.fn(() => [{ id: 1, name: 'Food' }]) } as unknown as CategoriesService; +const tagsStub = { + list: vi.fn((uid: number) => [{ id: 1, user_id: uid, name: 'work' }]), + create: vi.fn((uid: number, name: string, color?: string) => ({ id: 9, user_id: uid, name, color })), + getByIdAndUser: vi.fn((tagId: number, _uid: number) => (Number(tagId) === 404 ? undefined : { id: Number(tagId) })), + update: vi.fn((tagId: number, name?: string) => ({ id: Number(tagId), name })), + remove: vi.fn(), +} as unknown as TagsService; +const todoStub = { listItems: vi.fn((tid: number) => [{ id: 1, trip_id: Number(tid), name: 'Pack' }]), createItem: vi.fn((tid: number, data: { name: string }) => ({ id: 90, trip_id: Number(tid), name: data.name })), updateItem: vi.fn((_tid: number, id: string) => (Number(id) === 404 ? null : { id: Number(id), name: 'Done' })), deleteItem: vi.fn((_tid: number, id: string) => Number(id) !== 404), -})); +} as unknown as TodoService; const { testFilesDir } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const osm = require('node:os') as typeof import('node:os'); @@ -193,30 +205,28 @@ vi.mock('../../../src/nest/llm-parse/llm-config.resolver', () => ({ })); const { llmExtract } = vi.hoisted(() => ({ llmExtract: vi.fn(async (input: { text?: string }) => [{ text: `answer:${input.text ?? ''}` }]) })); vi.mock('../../../src/nest/llm-parse/llm-client.factory', () => ({ createLlmClient: vi.fn(() => ({ extract: llmExtract })) })); -// The per-user settings read + OAuth broker the runtime deps delegate to. +// The per-user settings read the runtime deps delegate to (still a path mock — +// readUserSettingDecrypted is a bare function export, not an injected service). vi.mock('../../../src/nest/plugins/plugins.service', () => ({ readUserSettingDecrypted: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)) })); -vi.mock('../../../src/nest/plugins/plugin-oauth.service', () => ({ PluginOAuthService: class { async getAccessToken(_pid: string, uid: number) { return uid === 5 ? 'tok-5' : null; } } })); -// Reservations: the Nest service is delegated to; mock it so the create-rpc-host -// reservation deps' side-effect branches (accommodation / budget-sync / notify) run. -vi.mock('../../../src/nest/reservations/reservations.service', () => ({ - ReservationsService: class { - create(tid: string, input: Record) { - return { reservation: { id: 40, trip_id: Number(tid), ...input }, accommodationCreated: input.title === 'Stay' }; - } - getReservation(id: string) { return id === '404' ? undefined : { id: Number(id), title: 'Old', type: 'flight' }; } - update(id: string, tid: string, input: Record) { - return { reservation: { id: Number(id), trip_id: Number(tid), ...input }, accommodationChanged: input.title === 'New' }; - } - remove(id: string) { - if (id === '404') return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }; - return { deleted: { id: Number(id), title: 'Gone', type: 'hotel', accommodation_id: 7 }, accommodationDeleted: true, deletedBudgetItemId: 9 }; - } - list(tid: string) { return [{ id: 1, trip_id: Number(tid), title: 'Flight' }]; } - syncBudgetOnCreate() {} - syncBudgetOnUpdate() {} - notifyBookingChange() {} +const oauthStub = { async getAccessToken(_pid: string, uid: number) { return uid === 5 ? 'tok-5' : null; } } as unknown as PluginOAuthService; +// Reservations: injected stub so the reservation deps' side-effect branches +// (accommodation / budget-sync / notify) run. +const reservationsStub = { + create(tid: string, input: Record) { + return { reservation: { id: 40, trip_id: Number(tid), ...input }, accommodationCreated: input.title === 'Stay' }; }, -})); + getReservation(id: string) { return id === '404' ? undefined : { id: Number(id), title: 'Old', type: 'flight' }; }, + update(id: string, tid: string, input: Record) { + return { reservation: { id: Number(id), trip_id: Number(tid), ...input }, accommodationChanged: input.title === 'New' }; + }, + remove(id: string) { + if (id === '404') return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }; + return { deleted: { id: Number(id), title: 'Gone', type: 'hotel', accommodation_id: 7 }, accommodationDeleted: true, deletedBudgetItemId: 9 }; + }, + list(tid: string) { return [{ id: 1, trip_id: Number(tid), title: 'Flight' }]; }, + syncBudgetOnCreate() {}, + syncBudgetOnUpdate() {}, +} as unknown as ReservationsService; vi.mock('../../../src/services/journeyService', () => ({ listJourneys: vi.fn((uid: number) => [{ id: 1, owner: uid }]), // journeyId 88 = no access (listEntries self-gates to null); else returns entries @@ -265,8 +275,22 @@ vi.mock('../../../src/services/dayNoteService', () => ({ dayExists: vi.fn((dayId: number) => dayId === 3), })); -import { createRealRpcHost, getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/create-rpc-host'; +import { PluginHostDepsFactory, type PluginCallRouter } from '../../../src/nest/plugins/host/plugin-host-deps.factory'; +import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state'; import { db as mockDb } from '../../../src/db/database'; +import type { BudgetService } from '../../../src/nest/budget/budget.service'; +import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; +import type { TagsService } from '../../../src/nest/tags/tags.service'; +import type { CategoriesService } from '../../../src/nest/categories/categories.service'; +import type { TodoService } from '../../../src/nest/todo/todo.service'; +import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service'; + +// The factory under test, wired exactly like PluginsModule does — but with the +// DI-native domain services replaced by the stubs above. The shim keeps the +// ~45 historical call sites unchanged and supplies a default no-op router. +const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, oauthStub); +const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; +const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router); let tmp: string; beforeAll(() => { @@ -279,7 +303,7 @@ afterAll(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); -describe('create-rpc-host wiring', () => { +describe('plugin host wiring (PluginHostDepsFactory)', () => { it('caches one data db per plugin id', () => { const a = getPluginDataDb('wired'); const b = getPluginDataDb('wired'); @@ -344,7 +368,7 @@ describe('create-rpc-host wiring', () => { }); }); -describe('create-rpc-host — planner write + metadata deps', () => { +describe('host-deps factory — planner write + metadata deps', () => { const host = (...perms: string[]) => createRealRpcHost('writer', new Set(perms)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (h: ReturnType, method: string, params: Record, uid = 5): Promise => @@ -447,7 +471,7 @@ describe('create-rpc-host — planner write + metadata deps', () => { }); }); -describe('create-rpc-host — reservations, day notes, cross-trip + addon reads (Waves 1-5)', () => { +describe('host-deps factory — reservations, day notes, cross-trip + addon reads (Waves 1-5)', () => { const host = (...perms: string[]) => createRealRpcHost('w15', new Set(perms)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined = 5): Promise => @@ -572,7 +596,7 @@ describe('create-rpc-host — reservations, day notes, cross-trip + addon reads }); }); -describe('create-rpc-host — packing write with #858 privacy-scoped broadcasts', () => { +describe('host-deps factory — packing write with #858 privacy-scoped broadcasts', () => { const host = () => createRealRpcHost('pk', new Set(['db:write:packing'])) // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (method: string, params: Record, uid = 5): Promise => @@ -635,7 +659,7 @@ describe('create-rpc-host — packing write with #858 privacy-scoped broadcasts' }) }) -describe('create-rpc-host — Wave 1 wiring (weather/categories/tags/todos/roster/bags)', () => { +describe('host-deps factory — Wave 1 wiring (weather/categories/tags/todos/roster/bags)', () => { const host = (...perms: string[]) => createRealRpcHost('w1', new Set(perms)) // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined = 5): Promise => @@ -685,7 +709,7 @@ describe('create-rpc-host — Wave 1 wiring (weather/categories/tags/todos/roste }) }) -describe('create-rpc-host — Wave 2 wiring (atlas/vacay/journal/collections writes)', () => { +describe('host-deps factory — Wave 2 wiring (atlas/vacay/journal/collections writes)', () => { const host = (...perms: string[]) => createRealRpcHost('w2', new Set(perms)) // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined = 5): Promise => @@ -736,7 +760,7 @@ describe('create-rpc-host — Wave 2 wiring (atlas/vacay/journal/collections wri }) }) -describe('create-rpc-host — Wave 3 wiring (files write / collab / member-add)', () => { +describe('host-deps factory — Wave 3 wiring (files write / collab / member-add)', () => { const host = (...perms: string[]) => createRealRpcHost('w3', new Set(perms)) // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined = 5): Promise => @@ -806,7 +830,7 @@ describe('create-rpc-host — Wave 3 wiring (files write / collab / member-add)' }) }) -describe('create-rpc-host — Wave 4 wiring (notify / ai)', () => { +describe('host-deps factory — Wave 4 wiring (notify / ai)', () => { const host = (...perms: string[]) => createRealRpcHost('w4', new Set(perms)) // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined = 5): Promise => @@ -840,7 +864,7 @@ describe('create-rpc-host — Wave 4 wiring (notify / ai)', () => { }) it('scheduler.set upserts by name with caps; cancel removes; recurring floor enforced', async () => { - const h = createRealRpcHost('wsched', new Set(['jobs:run']), { callPlugin: async () => undefined, emitPluginEvent: () => {} } as never); + const h = createRealRpcHost('wsched', new Set(['jobs:run'])); const c = async (method: string, params: Record): Promise<{ ok: boolean; result?: unknown; error?: { code: string; message: string } }> => h.dispatch({ k: 'req', id: 'x', method, params }, undefined) as never; const due = Date.now() + 120_000; @@ -868,7 +892,7 @@ describe('create-rpc-host — Wave 4 wiring (notify / ai)', () => { const ins = dbAny.prepare("INSERT INTO plugin_capability_audit (plugin_id, method, code, ts) VALUES (?, ?, 'ok', ?)"); for (let i = 0; i < 100; i++) ins.run('wbudget', 'notify.send', today); for (let i = 0; i < 200; i++) ins.run('wbudget', 'ai.complete', today); - const h = createRealRpcHost('wbudget', new Set(['notify:send', 'ai:invoke']), { callPlugin: async () => undefined, emitPluginEvent: () => {} } as never); + const h = createRealRpcHost('wbudget', new Set(['notify:send', 'ai:invoke'])); const c = async (method: string, params: Record): Promise<{ ok: boolean; error?: { code: string; message: string } }> => h.dispatch({ k: 'req', id: 'x', method, params }, 5) as never; const n = await c('notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } }); @@ -882,7 +906,7 @@ describe('create-rpc-host — Wave 4 wiring (notify / ai)', () => { }) }) -describe('create-rpc-host — Wave 8 wiring (settings.get / oauth.getToken)', () => { +describe('host-deps factory — Wave 8 wiring (settings.get / oauth.getToken)', () => { const host = (...perms: string[]) => createRealRpcHost('w8', new Set(perms)) // uid is explicit here (no default) — an undefined must stay undefined, not fall back. // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/tests/unit/plugins/provider-hook-grant.test.ts b/server/tests/unit/plugins/provider-hook-grant.test.ts index f6aa1fa12..faacb0267 100644 --- a/server/tests/unit/plugins/provider-hook-grant.test.ts +++ b/server/tests/unit/plugins/provider-hook-grant.test.ts @@ -10,7 +10,7 @@ */ import { describe, it, expect } from 'vitest'; import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor'; -import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import { createPluginRuntime } from '../../helpers/plugin-host'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { db } from '../../../src/db/database'; @@ -87,7 +87,7 @@ describe('providersOf enforces the hook:* grant', () => { describe('runtime.invokeHook defense-in-depth', () => { it('refuses a plugin id that is not a granted provider of the hook, even if passed directly', async () => { - const rt = new PluginRuntimeService(new DatabaseService(db)); + const rt = createPluginRuntime(new DatabaseService(db)); // one legitimate granted provider exists, so providersOf('placeDetailProvider') = ['ok'] // eslint-disable-next-line @typescript-eslint/no-explicit-any (rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', hooks: ['placeDetailProvider'], events: [], granted: new Set(['hook:place-detail-provider']) });