refactor(nest): inject DatabaseService in db-using services

This commit is contained in:
jubnl
2026-07-25 18:15:04 +02:00
parent def6b84938
commit fbe200424d
36 changed files with 345 additions and 223 deletions
+2 -1
View File
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
@@ -46,7 +47,7 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [AddonsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AddonsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -7,6 +7,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
@@ -44,7 +45,7 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [BudgetModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, BudgetModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
@@ -51,7 +52,7 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [CollabModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, CollabModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -15,6 +15,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
@@ -55,7 +56,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => {
let prevAppUrl: string | undefined;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [FeedsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, FeedsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
@@ -51,7 +52,7 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [JourneyModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
@@ -48,7 +49,7 @@ describe('Packing e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [PackingModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, PackingModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+2 -1
View File
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
@@ -59,7 +60,7 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [TripsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, TripsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
@@ -27,6 +27,8 @@ const { testDb } = vi.hoisted(() => {
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
import { db as dbConn } from '../../../src/db/database';
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';
@@ -58,7 +60,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();
runtime = new PluginRuntimeService(new DatabaseService(dbConn));
});
afterAll(async () => {
@@ -43,6 +43,8 @@ const { testDb } = vi.hoisted(() => {
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
import { db as dbConn } from '../../../src/db/database';
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';
@@ -77,7 +79,7 @@ beforeAll(() => {
);
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run();
runtime = new PluginRuntimeService();
runtime = new PluginRuntimeService(new DatabaseService(dbConn));
});
afterAll(async () => {
@@ -147,7 +149,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().onModuleInit()).not.toThrow();
expect(() => new PluginRuntimeService(new DatabaseService(dbConn)).onModuleInit()).not.toThrow();
process.env.TREK_PLUGINS_ENABLED = 'true';
});
@@ -170,7 +172,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();
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
await rt.activate('messy'); // must not throw despite the garbage JSON
expect(rt.isActive('messy')).toBe(true);
await rt.deactivate('messy');
@@ -249,7 +251,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();
const rt = new PluginRuntimeService(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);
@@ -261,7 +263,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();
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
rt.onModuleInit();
await new Promise((r) => setTimeout(r, 300));
expect(rt.isActive('sleeper')).toBe(false);
@@ -269,7 +271,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();
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
expect(rt.outboundHostsOf('missing')).toEqual([]);
});
@@ -291,7 +293,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().uninstall('gone', true);
await new PluginRuntimeService(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 });
@@ -310,7 +312,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().uninstall('keepdata', false); // keep the plugin's data
await new PluginRuntimeService(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
@@ -325,12 +327,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().activate('nettry')).rejects.toThrow(/egress/);
await new PluginRuntimeService().deactivate('nettry').catch(() => {});
await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('nettry')).rejects.toThrow(/egress/);
await new PluginRuntimeService(new DatabaseService(dbConn)).deactivate('nettry').catch(() => {});
});
it('onModuleDestroy tears down cleanly', async () => {
await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined();
await expect(new PluginRuntimeService(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined();
});
});
@@ -352,7 +354,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(fakeRegistry(() => {})); // declared perms unchanged
const rt = new PluginRuntimeService(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: [] });
@@ -363,6 +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(
new DatabaseService(dbConn),
fakeRegistry(() => {
testDb.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'")
.run(JSON.stringify(['db:own', 'db:read:trips', 'http:outbound:api.new.com']));
@@ -379,17 +382,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(fakeRegistry(() => {})).update('upd-off');
const res = await new PluginRuntimeService(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(fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/);
await expect(new PluginRuntimeService(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().update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
await expect(new PluginRuntimeService(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
});
});
@@ -556,7 +559,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(registry);
const rt = new PluginRuntimeService(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' });
@@ -601,7 +604,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(registry);
const rt = new PluginRuntimeService(new DatabaseService(dbConn), registry);
await expect(rt.retrust('rt-no', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/);
@@ -627,7 +630,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(registryFor('rt-act'));
const rt = new PluginRuntimeService(new DatabaseService(dbConn), registryFor('rt-act'));
await rt.deactivate('rt-act');
await rt.activate('rt-act');
@@ -663,7 +666,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().activate('outgrown').catch((e) => e);
const err = await new PluginRuntimeService(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' });
@@ -675,14 +678,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().activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' });
await expect(new PluginRuntimeService(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();
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
await rt.activate('fits');
expect(rt.isActive('fits')).toBe(true);
await rt.deactivate('fits');
@@ -696,7 +699,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();
const rt = new PluginRuntimeService(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 };
@@ -714,7 +717,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().activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
cleanup('both-wrong');
});
});
@@ -29,6 +29,8 @@ const { testDb } = vi.hoisted(() => {
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } from '../../../src/nest/plugins/registry/registry.service';
@@ -86,7 +88,7 @@ beforeEach(() => {
testDb.exec('DELETE FROM plugins; DELETE FROM plugin_settings_fields; DELETE FROM plugin_error_log');
__clearRegistryCacheForTests();
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response));
svc = new PluginRegistryService();
svc = new PluginRegistryService(new DatabaseService(dbConn));
});
afterEach(() => {
vi.unstubAllGlobals();
@@ -236,7 +238,7 @@ describe('PluginRegistryService', () => {
it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => {
__clearRegistryCacheForTests();
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
expect((await new PluginRegistryService().fetchRegistry()).plugins).toEqual([]);
expect((await new PluginRegistryService(new DatabaseService(dbConn)).fetchRegistry()).plugins).toEqual([]);
});
it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => {
@@ -7,6 +7,8 @@ const { dbMock } = vi.hoisted(() => {
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
});
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
const { getBagTracking, getCollabFeatures } = vi.hoisted(() => ({
getBagTracking: vi.fn(() => ({ enabled: false })),
@@ -20,7 +22,7 @@ vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProvid
import { AddonsService } from '../../../src/nest/addons/addons.service';
function svc() {
return new AddonsService();
return new AddonsService(new DatabaseService(dbConn));
}
// Feed the three reads in order: addons, providers, fields.
@@ -4,6 +4,8 @@ import { HttpException } from '@nestjs/common';
// Mock the heavy side-effect imports so the service module loads cleanly; the
// preview() path under test only touches the extractor + llmParse deps.
vi.mock('../../../../src/db/database', () => ({ db: { prepare: vi.fn() }, closeDb: () => {}, reinitialize: () => {} }));
import { db as dbConn } from '../../../../src/db/database';
import { DatabaseService } from '../../../../src/nest/database/database.service';
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../../../src/services/permissions', () => ({ checkPermission: vi.fn(() => true) }));
vi.mock('../../../../src/services/tripAccess', () => ({ verifyTripAccess: vi.fn() }));
@@ -19,7 +21,7 @@ const file = (name = 'a.pdf') => ({ buffer: Buffer.from('x'), originalname: name
function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any }) {
const extractor = { isAvailable: () => opts.kit ?? false, extract: vi.fn(opts.extract ?? (async () => [])) };
const llmParse = { isAvailable: () => opts.ai ?? false, parse: vi.fn(opts.parse ?? (async () => ({ kiItems: [], warnings: [] }))) };
return { svc: new BookingImportService(extractor as any, llmParse as any), extractor, llmParse };
return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn)), extractor, llmParse };
}
beforeEach(() => vi.clearAllMocks());
@@ -6,6 +6,8 @@ const { dbMock } = vi.hoisted(() => {
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
});
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
@@ -42,7 +44,7 @@ vi.mock('../../../src/services/budgetService', () => budget);
import { BudgetService } from '../../../src/nest/budget/budget.service';
function svc() {
return new BudgetService();
return new BudgetService(new DatabaseService(dbConn));
}
beforeEach(() => {
@@ -5,6 +5,8 @@ const { dbMock } = vi.hoisted(() => {
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
});
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
@@ -29,7 +31,7 @@ vi.mock('../../../src/services/notificationService', () => ({ send }));
import { PackingService } from '../../../src/nest/packing/packing.service';
function svc() {
return new PackingService();
return new PackingService(new DatabaseService(dbConn));
}
beforeEach(() => vi.clearAllMocks());
@@ -6,6 +6,8 @@ const { dbMock } = vi.hoisted(() => {
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
});
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
@@ -31,7 +33,7 @@ vi.mock('../../../src/services/reservationService', () => resv);
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
function svc() {
return new ReservationsService();
return new ReservationsService(new DatabaseService(dbConn));
}
beforeEach(() => {
+3 -1
View File
@@ -6,6 +6,8 @@ const { dbMock } = vi.hoisted(() => {
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn(() => ({ user_id: 1 })) }));
vi.mock('../../../src/db/database', () => ({ db: dbMock, canAccessTrip, closeDb: () => {}, reinitialize: () => {} }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
@@ -37,7 +39,7 @@ vi.mock('../../../src/services/fileService', () => ({ listFiles: () => [] }));
import { TripsService } from '../../../src/nest/trips/trips.service';
import { rebaseTripCurrency } from '../../../src/services/budgetService';
function svc() { return new TripsService(); }
function svc() { return new TripsService(new DatabaseService(dbConn)); }
beforeEach(() => vi.clearAllMocks());
describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => {
@@ -20,6 +20,8 @@ const { testDb, dbMock } = vi.hoisted(() => {
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
});
vi.mock('../../../src/db/database', () => dbMock);
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
import { createTables } from '../../../src/db/schema';
@@ -42,7 +44,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();
rt = new PluginRuntimeService(new DatabaseService(dbConn));
});
describe('operator-supplied egress hosts', () => {
@@ -156,13 +158,13 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', (
install('gotify', true);
install('plain', false);
const before = new PluginsService().list().plugins;
const before = new PluginsService(new DatabaseService(dbConn)).list().plugins;
expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 });
// A plugin that never asked for it must never invite the admin to add hosts.
expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 });
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
const after = new PluginsService().list().plugins;
const after = new PluginsService(new DatabaseService(dbConn)).list().plugins;
expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2);
delete process.env.TREK_PLUGINS_ENABLED;
});
@@ -14,6 +14,8 @@ vi.mock('../../../src/services/notifications', () => ({ getAppUrl: () => 'https:
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
// The token POST now runs through the SSRF guard (ssrfGuard.safeFetchLlm), which
// resolves the host before fetching. Stub DNS so the fake provider.example host
@@ -50,12 +52,12 @@ const NOW = 1_700_000_000_000;
describe('PluginOAuthService', () => {
let svc: PluginOAuthService;
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(new DatabaseService(dbConn)); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
it('providerConfig returns null unless every piece is present, decrypting the secrets', () => {
expect(svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' });
getDb.current = freshDb({ ...CFG, oauth_client_secret: '' });
expect(new PluginOAuthService().providerConfig('p')).toBeNull();
expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
});
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => {
@@ -76,18 +78,18 @@ describe('PluginOAuthService', () => {
it('rejects a non-https / loopback / metadata / internal authorize endpoint', () => {
getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/https/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/https/);
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback|private/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|private/);
// IPv6-literal loopback must not slip past the fast-fail
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://[::1]/token' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback/);
// cloud-metadata by literal is refused too
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://169.254.169.254/token' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
// an internal name suffix is refused
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' });
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/local/);
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/local/);
});
it('completeCallback verifies state (single-use, user-bound, TTL), exchanges the code, encrypts tokens', async () => {
@@ -15,6 +15,8 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import Database from 'better-sqlite3';
import { PluginsService, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service';
@@ -35,7 +37,7 @@ function freshDb() {
describe('per-user plugin settings', () => {
let svc: PluginsService;
beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(); });
beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(new DatabaseService(dbConn)); });
it('lists only the user-scope fields, in order', () => {
const fields = svc.userSettingsFields('p');
@@ -17,6 +17,8 @@ const { testDb } = vi.hoisted(() => {
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
@@ -38,7 +40,7 @@ describe('PluginsService.list', () => {
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsService().list();
const out = new PluginsService(new DatabaseService(dbConn)).list();
expect(out.enabled).toBe(true);
expect(out.plugins).toHaveLength(1);
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
@@ -49,14 +51,14 @@ describe('PluginsService.list', () => {
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
const out = new PluginsService().list();
const out = new PluginsService(new DatabaseService(dbConn)).list();
expect(out.enabled).toBe(true);
expect(out.plugins).toHaveLength(1);
});
it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
const out = new PluginsService().list();
const out = new PluginsService(new DatabaseService(dbConn)).list();
expect(out.enabled).toBe(false);
expect(out.plugins).toEqual([]);
});
@@ -76,7 +78,7 @@ describe('PluginsService.list', () => {
const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd';
insert('signed-one', 'acme/signed-one', key);
const p = new PluginsService().list().plugins[0];
const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0];
expect(p.signed).toBe(true);
// Short head…tail, for eyeballing against what the author reads out over the
// phone. NOT a confidentiality measure — the key is public, and the re-trust
@@ -86,7 +88,7 @@ describe('PluginsService.list', () => {
it('reports unsigned for a registry plugin with no pinned key', () => {
insert('plain', 'acme/plain', null);
const p = new PluginsService().list().plugins[0];
const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0];
expect(p.signed).toBe(false);
expect(p.keyFingerprint).toBeNull();
});
@@ -94,7 +96,7 @@ describe('PluginsService.list', () => {
it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', () => {
insert('uploaded', 'local:upload', null);
insert('linked', 'local:link', null);
const plugins = new PluginsService().list().plugins;
const plugins = new PluginsService(new DatabaseService(dbConn)).list().plugins;
expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([
['linked', false, 'local:link'],
['uploaded', false, 'local:upload'],
@@ -108,14 +110,14 @@ describe('PluginsService.list', () => {
.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?')
.run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked');
const byId = Object.fromEntries(new PluginsService().list().plugins.map((p) => [p.id, p]));
const byId = Object.fromEntries(new PluginsService(new DatabaseService(dbConn)).list().plugins.map((p) => [p.id, p]));
expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' });
expect(byId.fine.updateBlock).toBeNull();
});
it('never leaks the raw pinned key into the list response (only the fingerprint)', () => {
insert('signed-one', 'acme/signed-one', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd');
const p = new PluginsService().list().plugins[0] as Record<string, unknown>;
const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0] as Record<string, unknown>;
expect(p.author_pubkey).toBeUndefined();
});
});
@@ -133,7 +135,7 @@ describe('PluginsFeedController (client feed)', () => {
it('returns active plugins when enabled, nothing when disabled', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run();
const feed = new PluginsFeedController();
const feed = new PluginsFeedController(new DatabaseService(dbConn));
process.env.TREK_PLUGINS_ENABLED = 'true';
const active = feed.list();
@@ -147,7 +149,7 @@ describe('PluginsFeedController (client feed)', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController().list();
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
});
@@ -155,14 +157,14 @@ describe('PluginsFeedController (client feed)', () => {
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('d','D','widget','Box','active','{\"widget\":{\"slot\":\"day-detail\"}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
});
it('exposes settingsUi only when the capability is exactly true', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('su','S','widget','Box','active','{\"settingsUi\":true}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('no','N','widget','Box','active','{\"settingsUi\":\"yes\"}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController().list();
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true);
expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined();
});
@@ -170,7 +172,7 @@ describe('PluginsFeedController (client feed)', () => {
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('r','R','widget','Box','active','{\"widget\":{\"slot\":\"reservation-detail\"}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
});
it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', () => {
@@ -180,7 +182,7 @@ describe('PluginsFeedController (client feed)', () => {
// the capability is meaningless off a trip-page and must not leak onto widgets
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('w2','W2','widget','Box','active','{\"tripPage\":{\"replaces\":[\"transports\"]}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController().list();
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 });
expect(out.plugins.find((p) => p.id === 'evil')?.tripPage).toBeUndefined();
expect(out.plugins.find((p) => p.id === 'w2')?.tripPage).toBeUndefined();
@@ -236,7 +238,7 @@ describe('PluginsService instance config', () => {
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('x','X','inactive','{}')").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run();
const svc = new PluginsService();
const svc = new PluginsService(new DatabaseService(dbConn));
const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
// client gets the masked view
expect(masked.api_key).toBe('••••••••');
@@ -257,8 +259,8 @@ describe('PluginsService instance config', () => {
});
it('throws for an unknown plugin', () => {
expect(() => new PluginsService().updateInstanceConfig('nope', {})).toThrow(/not found/);
expect(() => new PluginsService().getInstanceConfig('nope')).toThrow(/not found/);
expect(() => new PluginsService(new DatabaseService(dbConn)).updateInstanceConfig('nope', {})).toThrow(/not found/);
expect(() => new PluginsService(new DatabaseService(dbConn)).getInstanceConfig('nope')).toThrow(/not found/);
});
});
@@ -266,7 +268,7 @@ describe('PluginsService error log', () => {
beforeEach(() => testDb.exec('DELETE FROM plugin_error_log'));
it('lists and clears a plugin error log', () => {
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run();
const svc = new PluginsService();
const svc = new PluginsService(new DatabaseService(dbConn));
expect(svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
svc.clearErrors('p');
expect(svc.errors('p')).toEqual([]);
@@ -11,6 +11,8 @@
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 { DatabaseService } from '../../../src/nest/database/database.service';
import { db } from '../../../src/db/database';
function makeSupervisor(): PluginSupervisor {
// createRpcHost is never called on the providersOf path (no spawn).
@@ -85,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();
const rt = new PluginRuntimeService(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']) });
@@ -17,6 +17,8 @@ const { testDb, dbMock } = vi.hoisted(() => {
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
});
vi.mock('../../../src/db/database', () => dbMock);
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
import { createTables } from '../../../src/db/schema';
@@ -120,7 +122,7 @@ describe('a plugin channel label is bounded by the host', () => {
VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`,
).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } }));
const rt = new PluginRuntimeService();
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
// Stand the plugin up as a granted, active notificationChannel provider.
(rt as unknown as { supervisor: { running: Map<string, unknown> } }).supervisor.running.set('loud', {
id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),