import { Logger } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types'; import { ReloadService } from './reload.service.js'; import { CommandExecutorService } from '../commands/command-executor.service.js'; function createMockCommandRegistry() { return { getManifest: vi.fn().mockReturnValue({ version: 1, commands: [], skills: [], }), }; } function createService() { const registry = createMockCommandRegistry(); const service = new ReloadService(registry as never); return { service, registry }; } describe('ReloadService', () => { it('reload() calls onUnload then onLoad for registered MosaicPlugin', async () => { const { service } = createService(); const callOrder: string[] = []; const mockPlugin = { pluginName: 'test-plugin', onLoad: vi.fn().mockImplementation(() => { callOrder.push('onLoad'); return Promise.resolve(); }), onUnload: vi.fn().mockImplementation(() => { callOrder.push('onUnload'); return Promise.resolve(); }), }; service.registerPlugin('test-plugin', mockPlugin); const result = await service.reload('command'); expect(mockPlugin.onUnload).toHaveBeenCalledOnce(); expect(mockPlugin.onLoad).toHaveBeenCalledOnce(); expect(callOrder).toEqual(['onUnload', 'onLoad']); expect(result.message).toContain('test-plugin'); }); it('reload() continues if one plugin throws during onUnload', async () => { const { service } = createService(); const badPlugin = { pluginName: 'bad-plugin', onLoad: vi.fn().mockResolvedValue(undefined), onUnload: vi.fn().mockRejectedValue(new Error('unload failed')), }; service.registerPlugin('bad-plugin', badPlugin); const result = await service.reload('command'); expect(result.message).toContain('bad-plugin'); expect(result.message).toContain('unload failed'); }); it('reload() skips non-MosaicPlugin objects', async () => { const { service } = createService(); const notAPlugin = { foo: 'bar' }; service.registerPlugin('not-a-plugin', notAPlugin); // Should not throw const result = await service.reload('command'); expect(result).toBeDefined(); expect(result.message).not.toContain('not-a-plugin'); }); it('reload() returns SystemReloadPayload with commands, skills, providers, message', async () => { const { service, registry } = createService(); registry.getManifest.mockReturnValue({ version: 1, commands: [ { name: 'test', description: 'test cmd', aliases: [], scope: 'core', execution: 'socket', available: true, }, ], skills: [], }); const result = await service.reload('rest'); expect(result).toHaveProperty('commands'); expect(result).toHaveProperty('skills'); expect(result).toHaveProperty('providers'); expect(result).toHaveProperty('message'); expect(result.commands).toHaveLength(1); }); it('registerPlugin() logs plugin registration', () => { const { service } = createService(); // Should not throw and should register expect(() => service.registerPlugin('my-plugin', {})).not.toThrow(); }); }); describe('ReloadService — /reload command sanitizes plugin errors', () => { it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => { const registry = { getManifest: vi.fn().mockReturnValue({ version: 1, commands: [ { name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true }, ], skills: [], }), }; const reloadService = new ReloadService(registry as never); const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret'; const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret'; reloadService.registerPlugin('unload-fails', { pluginName: 'unload-fails', onLoad: vi.fn().mockResolvedValue(undefined), onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)), }); reloadService.registerPlugin('load-fails', { pluginName: 'load-fails', onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)), onUnload: vi.fn().mockResolvedValue(undefined), }); const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); const broadcastReload = vi.fn(); const mockChatGateway = { broadcastReload }; const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() }; const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() }; const mockSessionGC = { sweepOrphans: vi.fn() }; const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } }; const mockMcpClient = { getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []), reconnectServer: vi.fn().mockResolvedValue(undefined), }; const executor = new CommandExecutorService( registry as never, mockAgentService as never, mockSystemOverride as never, mockSessionGC as never, null, mockBrain as never, reloadService, mockChatGateway as never, mockMcpClient as never, ); const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' }; const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' }); expect(result.success).toBe(true); expect(result.message).toContain('unload-fails: unload failed (internal error)'); expect(result.message).toContain('load-fails: load failed (internal error)'); expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER); expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER); expect(broadcastReload).toHaveBeenCalledOnce(); const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload; expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)'); expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)'); expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER); expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER); const loggedUnloadMarker = errorSpy.mock.calls.some((call) => call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)), ); const loggedLoadMarker = errorSpy.mock.calls.some((call) => call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)), ); expect(loggedUnloadMarker).toBe(true); expect(loggedLoadMarker).toBe(true); errorSpy.mockRestore(); }); });