stock-bot/libs/core/handlers/test/base-handler.test.ts
2025-06-25 11:38:23 -04:00

448 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, mock, type Mock } from 'bun:test';
import type { Collection, Db, MongoClient } from 'mongodb';
import type { Pool, QueryResult } from 'pg';
import type { SimpleBrowser } from '@stock-bot/browser';
import type { CacheProvider } from '@stock-bot/cache';
import type { Logger } from '@stock-bot/logger';
import type { SimpleProxyManager } from '@stock-bot/proxy';
import type { Queue, QueueManager } from '@stock-bot/queue';
import type { ExecutionContext, IServiceContainer, ServiceTypes } from '@stock-bot/types';
import { BaseHandler, ScheduledHandler } from '../src/base/BaseHandler';
import { Handler, Operation } from '../src/decorators/decorators';
type MockQueue = {
add: Mock<(name: string, data: any) => Promise<{ id: string }>>;
getName: Mock<() => string>;
};
type MockQueueManager = {
getQueue: Mock<(name: string) => MockQueue | null>;
createQueue: Mock<(name: string) => MockQueue>;
hasQueue: Mock<(name: string) => boolean>;
sendToQueue: Mock<(service: string, handler: string, data: any) => Promise<string>>;
};
type MockCache = {
get: Mock<(key: string) => Promise<any>>;
set: Mock<(key: string, value: any, ttl?: number) => Promise<void>>;
del: Mock<(key: string) => Promise<void>>;
clear: Mock<() => Promise<void>>;
has: Mock<(key: string) => Promise<boolean>>;
keys: Mock<(pattern?: string) => Promise<string[]>>;
ttl: Mock<(key: string) => Promise<number>>;
type: 'memory';
};
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 MockBrowser = {
scrape: Mock<(url: string) => Promise<{ data: string }>>;
};
type MockProxy = {
getProxy: Mock<() => { host: string; port: number }>;
};
type MockPostgres = {
query: Mock<(text: string, values?: any[]) => Promise<QueryResult>>;
};
type MockMongoDB = {
db: Mock<
(name?: string) => {
collection: Mock<
(name: string) => {
find: Mock<(filter: any) => { toArray: Mock<() => Promise<any[]>> }>;
insertOne: Mock<(doc: any) => Promise<{ insertedId: string }>>;
}
>;
}
>;
};
describe('BaseHandler', () => {
let mockServices: IServiceContainer;
let mockContext: ExecutionContext;
let mockQueue: MockQueue;
let mockQueueManager: MockQueueManager;
let mockCache: MockCache;
let mockLogger: MockLogger;
beforeEach(() => {
mockQueue = {
add: mock(async () => ({ id: 'job-456' })),
getName: mock(() => 'test-queue'),
};
mockQueueManager = {
getQueue: mock(() => mockQueue),
createQueue: mock(() => mockQueue),
hasQueue: mock(() => true),
sendToQueue: mock(async () => 'job-123'),
};
mockCache = {
get: mock(async () => null),
set: mock(async () => {}),
del: mock(async () => {}),
clear: mock(async () => {}),
has: mock(async () => false),
keys: mock(async () => []),
ttl: mock(async () => -1),
type: 'memory',
};
mockLogger = {
info: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
debug: mock(() => {}),
};
const mockBrowser: MockBrowser = {
scrape: mock(async () => ({ data: 'scraped' })),
};
const mockProxy: MockProxy = {
getProxy: mock(() => ({ host: 'proxy.example.com', port: 8080 })),
};
const mockPostgres: MockPostgres = {
query: mock(async () => ({ rows: [], rowCount: 0 }) as QueryResult),
};
const mockMongoDB: MockMongoDB = {
db: mock(() => ({
collection: mock(() => ({
find: mock(() => ({ toArray: mock(async () => []) })),
insertOne: mock(async () => ({ insertedId: 'id-123' })),
})),
})),
};
mockServices = {
cache: mockCache as unknown as ServiceTypes['cache'],
globalCache: { ...mockCache } as unknown as ServiceTypes['globalCache'],
queueManager: mockQueueManager as unknown as ServiceTypes['queueManager'],
proxy: mockProxy as unknown as ServiceTypes['proxy'],
browser: mockBrowser as unknown as ServiceTypes['browser'],
mongodb: mockMongoDB as unknown as ServiceTypes['mongodb'],
postgres: mockPostgres as unknown as ServiceTypes['postgres'],
questdb: null,
logger: mockLogger as unknown as ServiceTypes['logger'],
queue: mockQueue as unknown as ServiceTypes['queue'],
};
mockContext = {
logger: mockLogger as unknown as Logger,
traceId: 'trace-123',
handlerName: 'TestHandler',
operationName: 'testOperation',
metadata: {},
startTime: new Date(),
container: {
resolve: mock((name: string) => mockServices[name as keyof IServiceContainer]),
resolveAsync: mock(async (name: string) => mockServices[name as keyof IServiceContainer]),
},
};
// Reset all mocks
Object.values(mockServices).forEach(service => {
if (service && typeof service === 'object') {
Object.values(service).forEach(method => {
if (typeof method === 'function' && 'mockClear' in method) {
(method as unknown as Mock<any>).mockClear();
}
});
}
});
});
class TestHandler extends BaseHandler {
constructor() {
super(mockServices, 'TestHandler');
}
async testOperation(data: unknown): Promise<{ processed: unknown }> {
return { processed: data };
}
}
describe('service access', () => {
it('should provide access to cache service', async () => {
const handler = new TestHandler();
await handler.cache.set('key', 'value');
expect(mockCache.set).toHaveBeenCalledWith('key', 'value');
});
it('should have logger initialized', () => {
const handler = new TestHandler();
expect(handler.logger).toBeDefined();
// Logger is created by getLogger, not from mockServices
});
it('should provide access to queue service', () => {
const handler = new TestHandler();
expect(handler.queue).toBeDefined();
expect(mockQueue.getName()).toBe('test-queue');
});
it('should provide access to mongodb', () => {
const handler = new TestHandler();
expect(handler.mongodb).toBe(mockServices.mongodb);
});
it('should provide access to postgres', async () => {
const handler = new TestHandler();
const result = await handler.postgres.query('SELECT 1');
expect(result.rows).toEqual([]);
expect(mockServices.postgres.query).toHaveBeenCalledWith('SELECT 1');
});
it('should provide access to browser', async () => {
const handler = new TestHandler();
const result = await handler.browser.scrape('https://example.com');
expect(result).toEqual({ data: 'scraped' });
expect((mockServices.browser as unknown as MockBrowser).scrape).toHaveBeenCalledWith(
'https://example.com'
);
});
it('should provide access to proxy manager', () => {
const handler = new TestHandler();
const proxy = handler.proxy.getProxy();
expect(proxy).toEqual({ host: 'proxy.example.com', port: 8080 });
});
});
describe('caching utilities', () => {
it('should set and get cache values with handler namespace', async () => {
const handler = new TestHandler();
mockCache.set.mockClear();
mockCache.get.mockClear();
// Test cacheSet
await handler['cacheSet']('testKey', 'testValue', 3600);
expect(mockCache.set).toHaveBeenCalledWith('TestHandler:testKey', 'testValue', 3600);
// Test cacheGet
mockCache.get.mockImplementation(async () => 'cachedValue');
const result = await handler['cacheGet']('testKey');
expect(mockCache.get).toHaveBeenCalledWith('TestHandler:testKey');
expect(result).toBe('cachedValue');
});
it('should delete cache values with handler namespace', async () => {
const handler = new TestHandler();
mockCache.del.mockClear();
await handler['cacheDel']('testKey');
expect(mockCache.del).toHaveBeenCalledWith('TestHandler:testKey');
});
it('should handle null cache gracefully', async () => {
mockServices.cache = null;
const handler = new TestHandler();
// Should not throw when cache is null
await expect(handler['cacheSet']('key', 'value')).resolves.toBeUndefined();
await expect(handler['cacheGet']('key')).resolves.toBeNull();
await expect(handler['cacheDel']('key')).resolves.toBeUndefined();
});
});
describe('scheduling', () => {
it('should schedule operations', async () => {
const handler = new TestHandler();
mockQueueManager.hasQueue.mockClear();
mockQueue.add.mockClear();
await handler.scheduleOperation('processData', { data: 'test' }, { delay: 5000 });
expect(mockQueueManager.getQueue).toHaveBeenCalledWith('TestHandler');
expect(mockQueue.add).toHaveBeenCalledWith(
'processData',
{
handler: 'TestHandler',
operation: 'processData',
payload: { data: 'test' },
},
{ delay: 5000 }
);
});
});
describe('HTTP client', () => {
it('should provide http methods', () => {
const handler = new TestHandler();
const http = handler['http'];
expect(http).toBeDefined();
expect(http.get).toBeDefined();
expect(http.post).toBeDefined();
expect(http.put).toBeDefined();
expect(http.delete).toBeDefined();
});
});
describe('handler metadata', () => {
it('should extract handler metadata', () => {
// For metadata extraction, we need a decorated handler
@Handler('MetadataTestHandler')
class MetadataTestHandler extends BaseHandler {
@Operation('testOp')
async handleTestOp() {
return { result: 'success' };
}
}
const metadata = MetadataTestHandler.extractMetadata();
expect(metadata).toBeDefined();
expect(metadata!.name).toBe('MetadataTestHandler');
expect(metadata!.operations).toContain('testOp');
});
});
describe('lifecycle hooks', () => {
class LifecycleHandler extends BaseHandler {
onInitCalled = false;
onStartCalled = false;
onStopCalled = false;
onDisposeCalled = false;
constructor() {
super(mockServices, 'LifecycleHandler');
}
async onInit(): Promise<void> {
this.onInitCalled = true;
}
async onStart(): Promise<void> {
this.onStartCalled = true;
}
async onStop(): Promise<void> {
this.onStopCalled = true;
}
async onDispose(): Promise<void> {
this.onDisposeCalled = true;
}
}
it('should call lifecycle hooks', async () => {
const handler = new LifecycleHandler();
await handler.onInit();
expect(handler.onInitCalled).toBe(true);
await handler.onStart();
expect(handler.onStartCalled).toBe(true);
await handler.onStop();
expect(handler.onStopCalled).toBe(true);
await handler.onDispose();
expect(handler.onDisposeCalled).toBe(true);
});
});
});
describe('ScheduledHandler', () => {
const mockQueue: MockQueue = {
add: mock(async () => ({ id: 'job-456' })),
getName: mock(() => 'test-queue'),
};
const mockServices: IServiceContainer = {
cache: { type: 'memory' } as unknown as ServiceTypes['cache'],
globalCache: { type: 'memory' } as unknown as ServiceTypes['globalCache'],
queueManager: {
getQueue: () => mockQueue,
} as unknown as ServiceTypes['queueManager'],
proxy: null as unknown as ServiceTypes['proxy'],
browser: null as unknown as ServiceTypes['browser'],
mongodb: null as unknown as ServiceTypes['mongodb'],
postgres: null as unknown as ServiceTypes['postgres'],
questdb: null,
logger: null as unknown as ServiceTypes['logger'],
queue: mockQueue as unknown as ServiceTypes['queue'],
};
class TestScheduledHandler extends ScheduledHandler {
constructor() {
super(mockServices, 'TestScheduledHandler');
}
getScheduledJobs() {
return [
{
name: 'dailyJob',
schedule: '0 0 * * *',
handler: 'processDailyData',
},
{
name: 'hourlyJob',
schedule: '0 * * * *',
handler: 'processHourlyData',
options: {
timezone: 'UTC',
},
},
];
}
async processDailyData(): Promise<{ processed: string }> {
return { processed: 'daily' };
}
async processHourlyData(): Promise<{ processed: string }> {
return { processed: 'hourly' };
}
}
it('should define scheduled jobs', () => {
const handler = new TestScheduledHandler();
const jobs = handler.getScheduledJobs();
expect(jobs).toHaveLength(2);
expect(jobs[0]).toEqual({
name: 'dailyJob',
schedule: '0 0 * * *',
handler: 'processDailyData',
});
expect(jobs[1]).toEqual({
name: 'hourlyJob',
schedule: '0 * * * *',
handler: 'processHourlyData',
options: {
timezone: 'UTC',
},
});
});
it('should be a BaseHandler', () => {
const handler = new TestScheduledHandler();
expect(handler).toBeInstanceOf(BaseHandler);
expect(handler).toBeInstanceOf(ScheduledHandler);
});
});