309 lines
9.1 KiB
TypeScript
309 lines
9.1 KiB
TypeScript
import { beforeEach, describe, expect, it, mock, type Mock } from 'bun:test';
|
|
import type { CacheProvider } from '@stock-bot/cache';
|
|
import type { Logger } from '@stock-bot/logger';
|
|
import type { Queue, QueueManager } from '@stock-bot/queue';
|
|
import type { ExecutionContext, IServiceContainer, ServiceTypes } from '@stock-bot/types';
|
|
import { BaseHandler } from '../src/base/BaseHandler';
|
|
import {
|
|
Handler,
|
|
Operation,
|
|
QueueSchedule,
|
|
ScheduledOperation,
|
|
} from '../src/decorators/decorators';
|
|
import { createJobHandler } from '../src/utils/create-job-handler';
|
|
|
|
type MockLogger = {
|
|
info: Mock<(message: string, meta?: any) => void>;
|
|
error: Mock<(message: string, meta?: any) => void>;
|
|
warn: Mock<(message: string, meta?: any) => void>;
|
|
debug: Mock<(message: string, meta?: any) => void>;
|
|
};
|
|
|
|
type MockQueue = {
|
|
add: Mock<(name: string, data: any, options?: any) => Promise<void>>;
|
|
};
|
|
|
|
type MockQueueManager = {
|
|
getQueue: Mock<(name: string) => MockQueue>;
|
|
};
|
|
|
|
type MockCache = {
|
|
get: Mock<(key: string) => Promise<any>>;
|
|
set: Mock<(key: string, value: any, ttl?: number) => Promise<void>>;
|
|
del: Mock<(key: string) => Promise<void>>;
|
|
};
|
|
|
|
// Mock service container
|
|
const createMockServices = (): IServiceContainer => {
|
|
const mockLogger: MockLogger = {
|
|
info: mock(() => {}),
|
|
error: mock(() => {}),
|
|
warn: mock(() => {}),
|
|
debug: mock(() => {}),
|
|
};
|
|
|
|
const mockQueue: MockQueue = {
|
|
add: mock(() => Promise.resolve()),
|
|
};
|
|
|
|
const mockQueueManager: MockQueueManager = {
|
|
getQueue: mock(() => mockQueue),
|
|
};
|
|
|
|
return {
|
|
logger: mockLogger as unknown as ServiceTypes['logger'],
|
|
cache: null,
|
|
globalCache: null,
|
|
queueManager: mockQueueManager as unknown as ServiceTypes['queueManager'],
|
|
proxy: null,
|
|
browser: null,
|
|
mongodb: null,
|
|
postgres: null,
|
|
questdb: null,
|
|
queue: mockQueue as unknown as ServiceTypes['queue'],
|
|
};
|
|
};
|
|
|
|
describe('BaseHandler', () => {
|
|
let mockServices: IServiceContainer;
|
|
|
|
beforeEach(() => {
|
|
mockServices = createMockServices();
|
|
});
|
|
|
|
it('should initialize with services', () => {
|
|
const handler = new BaseHandler(mockServices, 'test-handler');
|
|
expect(handler).toBeDefined();
|
|
expect(handler.logger).toBeDefined();
|
|
});
|
|
|
|
it('should execute operations', async () => {
|
|
@Handler('test')
|
|
class TestHandler extends BaseHandler {
|
|
@Operation('testOp')
|
|
async handleTestOp(payload: unknown) {
|
|
return { result: 'success', payload };
|
|
}
|
|
}
|
|
|
|
const handler = new TestHandler(mockServices);
|
|
const context: ExecutionContext = {
|
|
type: 'queue',
|
|
metadata: { source: 'test' },
|
|
};
|
|
|
|
const result = await handler.execute('testOp', { data: 'test' }, context);
|
|
expect(result).toEqual({ result: 'success', payload: { data: 'test' } });
|
|
});
|
|
|
|
it('should throw for unknown operation', async () => {
|
|
@Handler('test')
|
|
class TestHandler extends BaseHandler {}
|
|
|
|
const handler = new TestHandler(mockServices);
|
|
const context: ExecutionContext = {
|
|
type: 'queue',
|
|
metadata: {},
|
|
};
|
|
|
|
await expect(handler.execute('unknown', {}, context)).rejects.toThrow(
|
|
'Unknown operation: unknown'
|
|
);
|
|
});
|
|
|
|
it('should schedule operations', async () => {
|
|
const mockQueue: MockQueue = {
|
|
add: mock(() => Promise.resolve()),
|
|
};
|
|
const mockQueueManager: MockQueueManager = {
|
|
getQueue: mock(() => mockQueue),
|
|
};
|
|
mockServices.queueManager = mockQueueManager as unknown as ServiceTypes['queueManager'];
|
|
|
|
const handler = new BaseHandler(mockServices, 'test-handler');
|
|
|
|
await handler.scheduleOperation('test-op', { data: 'test' }, { delay: 1000 });
|
|
|
|
expect(mockQueueManager.getQueue).toHaveBeenCalledWith('test-handler');
|
|
expect(mockQueue.add).toHaveBeenCalledWith(
|
|
'test-op',
|
|
{
|
|
handler: 'test-handler',
|
|
operation: 'test-op',
|
|
payload: { data: 'test' },
|
|
},
|
|
{ delay: 1000 }
|
|
);
|
|
});
|
|
|
|
describe('cache helpers', () => {
|
|
it('should handle cache operations with namespace', async () => {
|
|
const mockCache: MockCache = {
|
|
set: mock(() => Promise.resolve()),
|
|
get: mock(() => Promise.resolve('cached-value')),
|
|
del: mock(() => Promise.resolve()),
|
|
};
|
|
|
|
mockServices.cache = mockCache as unknown as ServiceTypes['cache'];
|
|
const handler = new BaseHandler(mockServices, 'my-handler');
|
|
|
|
await handler['cacheSet']('key', 'value', 3600);
|
|
expect(mockCache.set).toHaveBeenCalledWith('my-handler:key', 'value', 3600);
|
|
|
|
const result = await handler['cacheGet']('key');
|
|
expect(mockCache.get).toHaveBeenCalledWith('my-handler:key');
|
|
expect(result).toBe('cached-value');
|
|
|
|
await handler['cacheDel']('key');
|
|
expect(mockCache.del).toHaveBeenCalledWith('my-handler:key');
|
|
});
|
|
|
|
it('should handle null cache gracefully', async () => {
|
|
const handler = new BaseHandler(mockServices, 'test');
|
|
|
|
await expect(handler['cacheSet']('key', 'value')).resolves.toBeUndefined();
|
|
await expect(handler['cacheGet']('key')).resolves.toBeNull();
|
|
await expect(handler['cacheDel']('key')).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('metadata extraction', () => {
|
|
it('should extract metadata from decorated class', () => {
|
|
@Handler('metadata-test')
|
|
class MetadataHandler extends BaseHandler {
|
|
@Operation('op1')
|
|
async operation1() {}
|
|
|
|
@Operation('op2')
|
|
async operation2() {}
|
|
|
|
@ScheduledOperation('scheduled-op', '* * * * *', { priority: 10 })
|
|
async scheduledOp() {}
|
|
}
|
|
|
|
const metadata = MetadataHandler.extractMetadata();
|
|
|
|
expect(metadata).toBeDefined();
|
|
expect(metadata!.name).toBe('metadata-test');
|
|
expect(metadata!.operations).toContain('op1');
|
|
expect(metadata!.operations).toContain('op2');
|
|
expect(metadata!.operations).toContain('scheduled-op');
|
|
expect(metadata!.scheduledJobs).toHaveLength(1);
|
|
expect(metadata!.scheduledJobs![0]).toMatchObject({
|
|
operation: 'scheduled-op',
|
|
cronPattern: '* * * * *',
|
|
priority: 10,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Decorators', () => {
|
|
it('should apply Handler decorator', () => {
|
|
@Handler('test-handler')
|
|
class TestClass {}
|
|
|
|
const decoratedClass = TestClass as typeof TestClass & { __handlerName: string };
|
|
expect(decoratedClass.__handlerName).toBe('test-handler');
|
|
});
|
|
|
|
it('should apply Operation decorator', () => {
|
|
class TestClass {
|
|
@Operation('my-operation')
|
|
myMethod() {}
|
|
}
|
|
|
|
const decoratedClass = TestClass as typeof TestClass & {
|
|
__operations: Array<{ name: string; method: string }>;
|
|
};
|
|
const operations = decoratedClass.__operations;
|
|
expect(operations).toBeDefined();
|
|
expect(operations).toHaveLength(1);
|
|
expect(operations[0]).toMatchObject({
|
|
name: 'my-operation',
|
|
method: 'myMethod',
|
|
});
|
|
});
|
|
|
|
it('should apply ScheduledOperation decorator with options', () => {
|
|
class TestClass {
|
|
@ScheduledOperation('scheduled-task', '0 * * * *', {
|
|
priority: 8,
|
|
payload: { action: 'test' },
|
|
batch: { size: 100, delayInHours: 1 },
|
|
})
|
|
scheduledMethod() {}
|
|
}
|
|
|
|
const decoratedClass = TestClass as typeof TestClass & {
|
|
__schedules: Array<{
|
|
operation: string;
|
|
cronPattern: string;
|
|
priority: number;
|
|
payload: any;
|
|
batch: { size: number; delayInHours: number };
|
|
}>;
|
|
};
|
|
const schedules = decoratedClass.__schedules;
|
|
expect(schedules).toBeDefined();
|
|
expect(schedules).toHaveLength(1);
|
|
expect(schedules[0]).toMatchObject({
|
|
operation: 'scheduledMethod',
|
|
cronPattern: '0 * * * *',
|
|
priority: 8,
|
|
payload: { action: 'test' },
|
|
batch: { size: 100, delayInHours: 1 },
|
|
});
|
|
});
|
|
|
|
it('should apply QueueSchedule decorator', () => {
|
|
class TestClass {
|
|
@QueueSchedule('15 * * * *', { priority: 3 })
|
|
queueMethod() {}
|
|
}
|
|
|
|
const decoratedClass = TestClass as typeof TestClass & {
|
|
__schedules: Array<{
|
|
operation: string;
|
|
cronPattern: string;
|
|
priority: number;
|
|
}>;
|
|
};
|
|
const schedules = decoratedClass.__schedules;
|
|
expect(schedules).toBeDefined();
|
|
expect(schedules[0]).toMatchObject({
|
|
operation: 'queueMethod',
|
|
cronPattern: '15 * * * *',
|
|
priority: 3,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('createJobHandler', () => {
|
|
it('should create a job handler', async () => {
|
|
type TestPayload = { data: string };
|
|
type TestResult = { success: boolean; payload: TestPayload };
|
|
|
|
const handlerFn = mock(
|
|
async (payload: TestPayload): Promise<TestResult> => ({
|
|
success: true,
|
|
payload,
|
|
})
|
|
);
|
|
const jobHandler = createJobHandler(handlerFn);
|
|
|
|
const result = await jobHandler({ data: 'test' });
|
|
|
|
expect(handlerFn).toHaveBeenCalledWith({ data: 'test' });
|
|
expect(result).toEqual({ success: true, payload: { data: 'test' } });
|
|
});
|
|
|
|
it('should handle errors in job handler', async () => {
|
|
const handlerFn = mock(async () => {
|
|
throw new Error('Handler error');
|
|
});
|
|
const jobHandler = createJobHandler(handlerFn);
|
|
|
|
await expect(jobHandler({})).rejects.toThrow('Handler error');
|
|
});
|
|
});
|