337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import { asFunction, createContainer, type AwilixContainer } from 'awilix';
|
|
import { beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
|
import type { HandlerRegistry } from '@stock-bot/handler-registry';
|
|
import * as logger from '@stock-bot/logger';
|
|
import type { ExecutionContext, IHandler } from '@stock-bot/types';
|
|
import { HandlerScanner } from '../src/scanner/handler-scanner';
|
|
|
|
// Mock handler class
|
|
class MockHandler implements IHandler {
|
|
static __handlerName = 'mockHandler';
|
|
static __operations = [
|
|
{ name: 'processData', method: 'processData' },
|
|
{ name: 'validateData', method: 'validateData' },
|
|
];
|
|
static __schedules = [
|
|
{
|
|
operation: 'processData',
|
|
cronPattern: '0 * * * *',
|
|
priority: 5,
|
|
immediately: false,
|
|
description: 'Process data every hour',
|
|
payload: { type: 'hourly' },
|
|
},
|
|
];
|
|
static __disabled = false;
|
|
|
|
constructor(private serviceContainer: any) {}
|
|
|
|
async execute(operation: string, payload: any, context: ExecutionContext): Promise<any> {
|
|
switch (operation) {
|
|
case 'processData':
|
|
return { processed: true, data: payload };
|
|
case 'validateData':
|
|
return { valid: true, data: payload };
|
|
default:
|
|
throw new Error(`Unknown operation: ${operation}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Disabled handler for testing
|
|
class DisabledHandler extends MockHandler {
|
|
static __handlerName = 'disabledHandler';
|
|
static __disabled = true;
|
|
}
|
|
|
|
// Handler without metadata
|
|
class InvalidHandler {
|
|
constructor() {}
|
|
execute() {}
|
|
}
|
|
|
|
describe('HandlerScanner', () => {
|
|
let scanner: HandlerScanner;
|
|
let mockRegistry: HandlerRegistry;
|
|
let container: AwilixContainer;
|
|
let mockLogger: any;
|
|
|
|
beforeEach(() => {
|
|
// Create mock logger
|
|
mockLogger = {
|
|
info: mock(() => {}),
|
|
debug: mock(() => {}),
|
|
error: mock(() => {}),
|
|
warn: mock(() => {}),
|
|
};
|
|
|
|
// Mock getLogger to return our mock logger
|
|
spyOn(logger, 'getLogger').mockReturnValue(mockLogger);
|
|
|
|
// Create mock registry
|
|
mockRegistry = {
|
|
register: mock(() => {}),
|
|
getHandler: mock(() => null),
|
|
getHandlerMetadata: mock(() => null),
|
|
getAllHandlers: mock(() => []),
|
|
clear: mock(() => {}),
|
|
} as unknown as HandlerRegistry;
|
|
|
|
// Create container
|
|
container = createContainer();
|
|
|
|
// Create scanner
|
|
scanner = new HandlerScanner(mockRegistry, container, {
|
|
serviceName: 'test-service',
|
|
autoRegister: true,
|
|
});
|
|
});
|
|
|
|
describe('scanHandlers', () => {
|
|
it('should handle empty patterns gracefully', async () => {
|
|
await scanner.scanHandlers([]);
|
|
|
|
// Should complete without errors
|
|
expect(mockLogger.info).toHaveBeenCalledWith('Starting handler scan', { patterns: [] });
|
|
});
|
|
|
|
it('should handle file scan errors gracefully', async () => {
|
|
// We'll test that the scanner handles errors properly
|
|
// by calling internal methods directly
|
|
const filePath = '/non-existent-file.ts';
|
|
|
|
// This should not throw
|
|
await (scanner as any).scanFile(filePath);
|
|
|
|
expect(mockLogger.error).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('registerHandlerClass', () => {
|
|
it('should register a handler class with registry and container', () => {
|
|
scanner.registerHandlerClass(MockHandler);
|
|
|
|
// Check registry registration
|
|
expect(mockRegistry.register).toHaveBeenCalledWith(
|
|
{
|
|
name: 'mockHandler',
|
|
service: 'test-service',
|
|
operations: [
|
|
{ name: 'processData', method: 'processData' },
|
|
{ name: 'validateData', method: 'validateData' },
|
|
],
|
|
schedules: [
|
|
{
|
|
operation: 'processData',
|
|
cronPattern: '0 * * * *',
|
|
priority: 5,
|
|
immediately: false,
|
|
description: 'Process data every hour',
|
|
payload: { type: 'hourly' },
|
|
},
|
|
],
|
|
},
|
|
expect.objectContaining({
|
|
name: 'mockHandler',
|
|
operations: expect.any(Object),
|
|
scheduledJobs: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: 'mockHandler-processData',
|
|
operation: 'processData',
|
|
cronPattern: '0 * * * *',
|
|
priority: 5,
|
|
immediately: false,
|
|
description: 'Process data every hour',
|
|
payload: { type: 'hourly' },
|
|
}),
|
|
]),
|
|
})
|
|
);
|
|
|
|
// Check container registration
|
|
expect(container.hasRegistration('mockHandler')).toBe(true);
|
|
});
|
|
|
|
it('should skip disabled handlers', () => {
|
|
scanner.registerHandlerClass(DisabledHandler);
|
|
|
|
expect(mockRegistry.register).not.toHaveBeenCalled();
|
|
expect(container.hasRegistration('disabledHandler')).toBe(false);
|
|
});
|
|
|
|
it('should handle handlers without schedules', () => {
|
|
class NoScheduleHandler extends MockHandler {
|
|
static __handlerName = 'noScheduleHandler';
|
|
static __schedules = [];
|
|
}
|
|
|
|
scanner.registerHandlerClass(NoScheduleHandler);
|
|
|
|
expect(mockRegistry.register).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
schedules: [],
|
|
}),
|
|
expect.objectContaining({
|
|
scheduledJobs: [],
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use custom service name when provided', () => {
|
|
scanner.registerHandlerClass(MockHandler, { serviceName: 'custom-service' });
|
|
|
|
expect(mockRegistry.register).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
service: 'custom-service',
|
|
}),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('should not register with container when autoRegister is false', () => {
|
|
scanner = new HandlerScanner(mockRegistry, container, {
|
|
serviceName: 'test-service',
|
|
autoRegister: false,
|
|
});
|
|
|
|
scanner.registerHandlerClass(MockHandler);
|
|
|
|
expect(mockRegistry.register).toHaveBeenCalled();
|
|
expect(container.hasRegistration('mockHandler')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('handler validation', () => {
|
|
it('should identify valid handlers', () => {
|
|
const isHandler = (scanner as any).isHandler;
|
|
|
|
expect(isHandler(MockHandler)).toBe(true);
|
|
expect(isHandler(InvalidHandler)).toBe(false);
|
|
expect(isHandler({})).toBe(false);
|
|
expect(isHandler('not a function')).toBe(false);
|
|
expect(isHandler(null)).toBe(false);
|
|
});
|
|
|
|
it('should handle handlers with batch configuration', () => {
|
|
class BatchHandler extends MockHandler {
|
|
static __handlerName = 'batchHandler';
|
|
static __schedules = [
|
|
{
|
|
operation: 'processBatch',
|
|
cronPattern: '*/5 * * * *',
|
|
priority: 10,
|
|
batch: {
|
|
size: 100,
|
|
window: 60000,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
scanner.registerHandlerClass(BatchHandler);
|
|
|
|
expect(mockRegistry.register).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
scheduledJobs: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
batch: {
|
|
size: 100,
|
|
window: 60000,
|
|
},
|
|
}),
|
|
]),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getDiscoveredHandlers', () => {
|
|
it('should return all discovered handlers', () => {
|
|
scanner.registerHandlerClass(MockHandler);
|
|
|
|
const discovered = scanner.getDiscoveredHandlers();
|
|
|
|
expect(discovered.size).toBe(1);
|
|
expect(discovered.get('mockHandler')).toBe(MockHandler);
|
|
});
|
|
|
|
it('should return a copy of the map', () => {
|
|
scanner.registerHandlerClass(MockHandler);
|
|
|
|
const discovered1 = scanner.getDiscoveredHandlers();
|
|
const discovered2 = scanner.getDiscoveredHandlers();
|
|
|
|
expect(discovered1).not.toBe(discovered2);
|
|
expect(discovered1.get('mockHandler')).toBe(discovered2.get('mockHandler'));
|
|
});
|
|
});
|
|
|
|
describe('operation handler creation', () => {
|
|
it('should create job handlers for operations', () => {
|
|
scanner.registerHandlerClass(MockHandler);
|
|
|
|
const registrationCall = (mockRegistry.register as any).mock.calls[0];
|
|
const configuration = registrationCall[1];
|
|
|
|
expect(configuration.operations).toHaveProperty('processData');
|
|
expect(configuration.operations).toHaveProperty('validateData');
|
|
expect(typeof configuration.operations.processData).toBe('function');
|
|
});
|
|
|
|
it('should resolve handler from container when executing operations', async () => {
|
|
// Register handler with container
|
|
container.register({
|
|
serviceContainer: asFunction(() => ({})).singleton(),
|
|
});
|
|
|
|
scanner.registerHandlerClass(MockHandler);
|
|
|
|
// Create handler instance
|
|
const handlerInstance = container.resolve<IHandler>('mockHandler');
|
|
|
|
// Test execution
|
|
const context: ExecutionContext = {
|
|
type: 'queue',
|
|
metadata: { source: 'test', timestamp: Date.now() },
|
|
};
|
|
|
|
const result = await handlerInstance.execute('processData', { test: true }, context);
|
|
|
|
expect(result).toEqual({ processed: true, data: { test: true } });
|
|
});
|
|
});
|
|
|
|
describe('module scanning', () => {
|
|
it('should handle modules with multiple exports', () => {
|
|
const mockModule = {
|
|
Handler1: MockHandler,
|
|
Handler2: class SecondHandler extends MockHandler {
|
|
static __handlerName = 'secondHandler';
|
|
},
|
|
notAHandler: { some: 'object' },
|
|
helperFunction: () => {},
|
|
};
|
|
|
|
(scanner as any).registerHandlersFromModule(mockModule, 'test.ts');
|
|
|
|
expect(mockRegistry.register).toHaveBeenCalledTimes(2);
|
|
expect(mockRegistry.register).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'mockHandler' }),
|
|
expect.any(Object)
|
|
);
|
|
expect(mockRegistry.register).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'secondHandler' }),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('should handle empty modules', () => {
|
|
const mockModule = {};
|
|
|
|
(scanner as any).registerHandlersFromModule(mockModule, 'empty.ts');
|
|
|
|
expect(mockRegistry.register).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|