refactor(plugins): wire PluginHostDepsFactory through DI, drop tags/categories bridges

This commit is contained in:
jubnl
2026-07-26 15:28:55 +02:00
parent 167db04d69
commit 811230fa5d
21 changed files with 197 additions and 226 deletions
+1 -1
View File
@@ -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);
+32
View File
@@ -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));
}
@@ -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 () => {
@@ -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');
});
});
@@ -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());
});
});
+3 -45
View File
@@ -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();
});
});
@@ -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', () => {
@@ -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<string, unknown>) { return { id: 1, trip_id: Number(tid), ...input }; }
async update(id: string, tid: string, input: Record<string, unknown>) {
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<string, unknown>) { return { id: 1, trip_id: Number(tid), ...input }; },
async update(id: string, tid: string, input: Record<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string>, 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<typeof host>, method: string, params: Record<string, unknown>, uid = 5): Promise<any> =>
@@ -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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
@@ -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<string, unknown>, uid = 5): Promise<any> =>
@@ -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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
@@ -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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
@@ -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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
@@ -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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
@@ -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<string, unknown>): 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<string, unknown>): 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
@@ -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']) });