UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

590 lines (489 loc) 17.1 kB
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TriggerManager } from '../trigger-manager.js'; import { ProcessStore } from '../process-store.js'; import { ProcessEngine } from '../process-engine.js'; import { ProcessDefinition, ProcessTrigger, TriggerExecution } from '../types.js'; import { MockCronAdapter } from '../cron-adapter.js'; // Mock dependencies vi.mock('../process-store.js'); vi.mock('../process-engine.js'); describe('TriggerManager', () => { let manager: TriggerManager; let mockStore: vi.Mocked<ProcessStore>; let mockEngine: vi.Mocked<ProcessEngine>; let mockCronAdapter: MockCronAdapter; beforeEach(() => { mockStore = { getAllProcesses: vi.fn().mockResolvedValue([]), getProcess: vi.fn(), saveProcess: vi.fn() } as any; mockEngine = { executeProcess: vi.fn().mockResolvedValue({ id: 'exec-123', status: 'completed' }) } as any; mockCronAdapter = new MockCronAdapter(); manager = new TriggerManager(mockEngine, mockStore, mockCronAdapter); }); afterEach(() => { vi.clearAllMocks(); manager.stopAll(); }); describe('initialization', () => { it('should load all enabled triggers on init', async () => { const processes: ProcessDefinition[] = [ createProcess({ id: 'proc-1', triggers: [ { id: 'trig-1', type: 'schedule', name: 'Daily', enabled: true, config: { cron: '0 9 * * *' } }, { id: 'trig-2', type: 'manual', name: 'Manual', enabled: true } ] }), createProcess({ id: 'proc-2', triggers: [ { id: 'trig-3', type: 'schedule', name: 'Weekly', enabled: false, config: { cron: '0 9 * * 1' } } ] }) ]; mockStore.getAllProcesses.mockResolvedValue(processes); await manager.initialize(); expect(mockStore.getAllProcesses).toHaveBeenCalled(); // Should register 2 enabled triggers (trig-1 and trig-2) expect(manager.getActiveTriggers()).toHaveLength(2); }); }); describe('registerTrigger', () => { it('should register a manual trigger', async () => { const process = createProcess({ id: 'proc-1' }); const trigger: ProcessTrigger = { id: 'manual-1', type: 'manual', name: 'Manual Trigger', enabled: true }; await manager.registerTrigger(process, trigger); const active = manager.getActiveTriggers(); expect(active).toHaveLength(1); expect(active[0]).toMatchObject({ id: 'manual-1', processId: 'proc-1', type: 'manual', enabled: true, name: 'Manual Trigger' }); }); it('should register a schedule trigger', async () => { const process = createProcess({ id: 'proc-1' }); const trigger: ProcessTrigger = { id: 'schedule-1', type: 'schedule', name: 'Daily Run', enabled: true, config: { cron: '0 9 * * *' } }; await manager.registerTrigger(process, trigger); const active = manager.getActiveTriggers(); expect(active).toHaveLength(1); expect(active[0].type).toBe('schedule'); }); it('should not register disabled triggers', async () => { const process = createProcess({ id: 'proc-1' }); const trigger: ProcessTrigger = { id: 'disabled-1', type: 'manual', name: 'Disabled', enabled: false }; await manager.registerTrigger(process, trigger); expect(manager.getActiveTriggers()).toHaveLength(0); }); it('should throw error for invalid schedule', async () => { const process = createProcess({ id: 'proc-1' }); const trigger: ProcessTrigger = { id: 'bad-schedule', type: 'schedule', name: 'Invalid', enabled: true, config: { cron: 'invalid-cron' } }; // Mock cron validation to return false mockCronAdapter.setShouldValidate(false); await expect(manager.registerTrigger(process, trigger)) .rejects.toThrow('Invalid cron expression'); }); }); describe('unregisterTrigger', () => { it('should unregister an active trigger', async () => { const process = createProcess({ id: 'proc-1' }); const trigger: ProcessTrigger = { id: 'trig-1', type: 'manual', name: 'Manual', enabled: true }; await manager.registerTrigger(process, trigger); expect(manager.getActiveTriggers()).toHaveLength(1); await manager.unregisterTrigger('proc-1', 'trig-1'); expect(manager.getActiveTriggers()).toHaveLength(0); }); it('should stop cron job when unregistering schedule', async () => { const process = createProcess({ id: 'proc-1' }); const trigger: ProcessTrigger = { id: 'schedule-1', type: 'schedule', name: 'Daily', enabled: true, config: { cron: '0 9 * * *' } }; await manager.registerTrigger(process, trigger); // Get the task from the adapter const tasks = mockCronAdapter.getTasks(); const task = tasks.get('0 9 * * *'); expect(task).toBeDefined(); await manager.unregisterTrigger('proc-1', 'schedule-1'); // Verify the task was stopped and destroyed expect(task!.isDestroyed()).toBe(true); }); }); describe('executeTrigger', () => { it('should execute a manual trigger', async () => { const trigger: ProcessTrigger = { id: 'manual-1', type: 'manual', name: 'Manual', enabled: true, config: {} }; const process = createProcess({ id: 'proc-1', triggers: [trigger] }); mockStore.getProcess.mockResolvedValue(process); await manager.registerTrigger(process, trigger); const execution = await manager.executeTrigger('proc-1', 'manual-1'); expect(mockEngine.executeProcess).toHaveBeenCalledWith( process, 'manual-1', undefined ); expect(execution).toMatchObject({ id: 'exec-123', status: 'completed' }); }); it('should pass context to execution', async () => { const trigger: ProcessTrigger = { id: 'manual-1', type: 'manual', name: 'Manual', enabled: true, config: {} }; const process = createProcess({ id: 'proc-1', triggers: [trigger] }); mockStore.getProcess.mockResolvedValue(process); await manager.registerTrigger(process, trigger); const context = { userId: 'user-123', data: { foo: 'bar' } }; await manager.executeTrigger('proc-1', 'manual-1', context); expect(mockEngine.executeProcess).toHaveBeenCalledWith( process, 'manual-1', context ); }); it('should throw error for unregistered trigger', async () => { const process = createProcess({ id: 'proc-1', triggers: [] }); mockStore.getProcess.mockResolvedValue(process); await expect(manager.executeTrigger('proc-1', 'unknown')) .rejects.toThrow('Trigger not found'); }); it('should throw error for disabled trigger', async () => { const trigger: ProcessTrigger = { id: 'disabled-1', type: 'manual', name: 'Disabled', enabled: false }; const process = createProcess({ id: 'proc-1', triggers: [trigger] }); mockStore.getProcess.mockResolvedValue(process); // Register the trigger even though it's disabled await manager.registerTrigger(process, trigger); await expect(manager.executeTrigger('proc-1', 'disabled-1')) .rejects.toThrow('Trigger is disabled'); }); }); describe('updateTrigger', () => { it('should update trigger configuration', async () => { const trigger: ProcessTrigger = { id: 'schedule-1', type: 'schedule', name: 'Daily', enabled: true, config: { cron: '0 9 * * *' } }; const process = createProcess({ id: 'proc-1', triggers: [trigger] }); mockStore.getProcess.mockResolvedValue(process); mockStore.saveProcess.mockResolvedValue(undefined); await manager.registerTrigger(process, trigger); await manager.updateTrigger('proc-1', 'schedule-1', { name: 'Updated Daily', config: { cron: '0 10 * * *' } }); const active = manager.getActiveTriggers(); expect(active[0].name).toBe('Updated Daily'); }); it('should disable trigger when enabled set to false', async () => { const trigger: ProcessTrigger = { id: 'manual-1', type: 'manual', name: 'Manual', enabled: true, config: {} }; const process = createProcess({ id: 'proc-1', triggers: [trigger] }); mockStore.getProcess.mockResolvedValue(process); mockStore.saveProcess.mockResolvedValue(undefined); await manager.registerTrigger(process, trigger); expect(manager.getActiveTriggers()).toHaveLength(1); await manager.updateTrigger('proc-1', 'manual-1', { enabled: false }); // After disabling, the trigger should be removed from active triggers const active = manager.getActiveTriggers(); expect(active).toHaveLength(0); }); it('should restart schedule when cron changes', async () => { const process = createProcess({ id: 'proc-1' }); const trigger: ProcessTrigger = { id: 'schedule-1', type: 'schedule', name: 'Daily', enabled: true, config: { cron: '0 9 * * *' } }; process.triggers = [trigger]; mockStore.getProcess.mockResolvedValue(process); mockStore.saveProcess.mockResolvedValue(undefined); await manager.registerTrigger(process, trigger); // Get the original task const originalTasks = mockCronAdapter.getTasks(); const originalTask = originalTasks.get('0 9 * * *'); expect(originalTask).toBeDefined(); await manager.updateTrigger('proc-1', 'schedule-1', { config: { cron: '0 10 * * *' } }); // Verify the old task was destroyed expect(originalTask!.isDestroyed()).toBe(true); // Verify a new task was created const newTasks = mockCronAdapter.getTasks(); const newTask = newTasks.get('0 10 * * *'); expect(newTask).toBeDefined(); }); }); describe('getTriggerHistory', () => { it('should return execution history for trigger', async () => { const trigger: ProcessTrigger = { id: 'manual-1', type: 'manual', name: 'Manual', enabled: true, config: {} }; const process = createProcess({ id: 'proc-1', triggers: [trigger] }); mockStore.getProcess.mockResolvedValue(process); await manager.registerTrigger(process, trigger); // Execute multiple times await manager.executeTrigger('proc-1', 'manual-1'); await manager.executeTrigger('proc-1', 'manual-1'); await manager.executeTrigger('proc-1', 'manual-1'); const history = manager.getTriggerHistory('proc-1', 'manual-1'); expect(history).toHaveLength(3); expect(history[0]).toMatchObject({ triggerId: 'manual-1', processId: 'proc-1', executionId: 'exec-123', executedAt: expect.any(String), status: 'success' }); }); it('should limit history to recent executions', async () => { const trigger: ProcessTrigger = { id: 'manual-1', type: 'manual', name: 'Manual', enabled: true, config: {} }; const process = createProcess({ id: 'proc-1', triggers: [trigger] }); mockStore.getProcess.mockResolvedValue(process); await manager.registerTrigger(process, trigger); // Execute more than limit (100) for (let i = 0; i < 105; i++) { await manager.executeTrigger('proc-1', 'manual-1'); } const history = manager.getTriggerHistory('proc-1', 'manual-1'); expect(history.length).toBeLessThanOrEqual(100); }); }); describe('getActiveTriggers', () => { it('should return all active triggers', async () => { const process1 = createProcess({ id: 'proc-1' }); const process2 = createProcess({ id: 'proc-2' }); await manager.registerTrigger(process1, { id: 'trig-1', type: 'manual', name: 'Manual 1', enabled: true, config: {} }); await manager.registerTrigger(process2, { id: 'trig-2', type: 'schedule', name: 'Schedule 1', enabled: true, config: { cron: '0 9 * * *' } }); const active = manager.getActiveTriggers(); expect(active).toHaveLength(2); expect(active.map(t => t.type).sort()).toEqual(['manual', 'schedule']); }); it('should filter by process id', async () => { const process1 = createProcess({ id: 'proc-1' }); const process2 = createProcess({ id: 'proc-2' }); await manager.registerTrigger(process1, { id: 'trig-1', type: 'manual', name: 'Manual 1', enabled: true, config: {} }); await manager.registerTrigger(process2, { id: 'trig-2', type: 'manual', name: 'Manual 2', enabled: true, config: {} }); const active = manager.getActiveTriggers('proc-1'); expect(active).toHaveLength(1); expect(active[0].processId).toBe('proc-1'); }); }); describe('stopAll', () => { it('should stop all active triggers', async () => { const process1 = createProcess({ id: 'proc-1' }); const process2 = createProcess({ id: 'proc-2' }); await manager.registerTrigger(process1, { id: 'schedule-1', type: 'schedule', name: 'Schedule 1', enabled: true, config: { cron: '0 9 * * *' } }); await manager.registerTrigger(process2, { id: 'schedule-2', type: 'schedule', name: 'Schedule 2', enabled: true, config: { cron: '0 10 * * *' } }); // Get tasks before stopping const tasks = mockCronAdapter.getTasks(); const task1 = tasks.get('0 9 * * *'); const task2 = tasks.get('0 10 * * *'); expect(task1).toBeDefined(); expect(task2).toBeDefined(); manager.stopAll(); // Verify tasks were destroyed expect(task1!.isDestroyed()).toBe(true); expect(task2!.isDestroyed()).toBe(true); expect(manager.getActiveTriggers()).toHaveLength(0); }); }); describe('event triggers', () => { it('should handle event triggers', async () => { const process = createProcess({ id: 'proc-1' }); const trigger: ProcessTrigger = { id: 'event-1', type: 'event', name: 'On File Change', enabled: true, config: { eventType: 'file_change', eventFilter: { extension: '.js' } } }; await manager.registerTrigger(process, trigger); const active = manager.getActiveTriggers(); expect(active[0].type).toBe('event'); }); it('should emit event for event triggers', async () => { const trigger: ProcessTrigger = { id: 'event-1', type: 'event', name: 'On Data', enabled: true, config: { event: 'data_received' } }; const process = createProcess({ id: 'proc-1', triggers: [trigger] }); mockStore.getProcess.mockResolvedValue(process); await manager.registerTrigger(process, trigger); // Simulate event const eventData = { source: 'api', data: { value: 42 } }; await manager.handleEvent('data_received', eventData); expect(mockEngine.executeProcess).toHaveBeenCalledWith( process, 'event-1', expect.objectContaining({ event: 'data_received', eventData }) ); }); }); }); // Helper functions function createProcess(overrides: Partial<ProcessDefinition> = {}): ProcessDefinition { return { id: 'process-123', name: 'Test Process', version: '1.0.0', triggers: [], activities: [], variables: {}, metadata: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), executionCount: 0 }, ...overrides }; }