@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
590 lines (489 loc) • 17.1 kB
text/typescript
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
};
}