diff --git a/.coveragerc.json b/.coveragerc.json index ea197e4..1b1002b 100644 --- a/.coveragerc.json +++ b/.coveragerc.json @@ -1,26 +1,26 @@ -{ - "exclude": [ - "**/node_modules/**", - "**/dist/**", - "**/build/**", - "**/coverage/**", - "**/*.test.ts", - "**/*.test.js", - "**/*.spec.ts", - "**/*.spec.js", - "**/test/**", - "**/tests/**", - "**/__tests__/**", - "**/__mocks__/**", - "**/setup.ts", - "**/setup.js" - ], - "reporters": ["terminal", "html"], - "thresholds": { - "lines": 80, - "functions": 80, - "branches": 80, - "statements": 80 - }, - "outputDir": "coverage" -} \ No newline at end of file +{ + "exclude": [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/coverage/**", + "**/*.test.ts", + "**/*.test.js", + "**/*.spec.ts", + "**/*.spec.js", + "**/test/**", + "**/tests/**", + "**/__tests__/**", + "**/__mocks__/**", + "**/setup.ts", + "**/setup.js" + ], + "reporters": ["terminal", "html"], + "thresholds": { + "lines": 80, + "functions": 80, + "branches": 80, + "statements": 80 + }, + "outputDir": "coverage" +} diff --git a/apps/stock/data-pipeline/src/handlers/index.ts b/apps/stock/data-pipeline/src/handlers/index.ts index e4efe55..a41a377 100644 --- a/apps/stock/data-pipeline/src/handlers/index.ts +++ b/apps/stock/data-pipeline/src/handlers/index.ts @@ -2,8 +2,8 @@ * Handler registration for data pipeline service */ -import type { IServiceContainer } from '@stock-bot/types'; import { getLogger } from '@stock-bot/logger'; +import type { IServiceContainer } from '@stock-bot/types'; // Import handlers directly import { ExchangesHandler } from './exchanges/exchanges.handler'; import { SymbolsHandler } from './symbols/symbols.handler'; @@ -18,10 +18,7 @@ export async function initializeAllHandlers(container: IServiceContainer): Promi try { // Register handlers manually - const handlers = [ - ExchangesHandler, - SymbolsHandler, - ]; + const handlers = [ExchangesHandler, SymbolsHandler]; for (const Handler of handlers) { try { @@ -38,4 +35,4 @@ export async function initializeAllHandlers(container: IServiceContainer): Promi logger.error('Handler registration failed', { error }); throw error; } -} \ No newline at end of file +} diff --git a/libs/core/cache/src/cache-factory.ts b/libs/core/cache/src/cache-factory.ts index 5e4f41b..66d0319 100644 --- a/libs/core/cache/src/cache-factory.ts +++ b/libs/core/cache/src/cache-factory.ts @@ -44,11 +44,13 @@ export function createNamespacedCache( * Type guard to check if cache is available */ export function isCacheAvailable(cache: unknown): cache is CacheProvider { - return cache !== null && - cache !== undefined && - typeof cache === 'object' && - 'get' in cache && - typeof (cache as CacheProvider).get === 'function'; + return ( + cache !== null && + cache !== undefined && + typeof cache === 'object' && + 'get' in cache && + typeof (cache as CacheProvider).get === 'function' + ); } /** diff --git a/libs/core/cache/src/connection-manager.ts b/libs/core/cache/src/connection-manager.ts index 09dce05..008148c 100644 --- a/libs/core/cache/src/connection-manager.ts +++ b/libs/core/cache/src/connection-manager.ts @@ -1,6 +1,6 @@ import Redis from 'ioredis'; -import type { RedisConfig } from './types'; import { REDIS_DEFAULTS } from './constants'; +import type { RedisConfig } from './types'; interface ConnectionConfig { name: string; @@ -29,7 +29,7 @@ export class RedisConnectionManager { */ getConnection(config: ConnectionConfig): Redis { const { name, singleton = true, redisConfig } = config; - + if (singleton) { const existing = RedisConnectionManager.connections.get(name); if (existing) { @@ -38,11 +38,11 @@ export class RedisConnectionManager { } const connection = this.createConnection(redisConfig); - + if (singleton) { RedisConnectionManager.connections.set(name, connection); } - + return connection; } @@ -68,10 +68,8 @@ export class RedisConnectionManager { * Close all connections */ static async closeAll(): Promise { - const promises = Array.from(this.connections.values()).map(conn => - conn.quit().catch(() => {}) - ); + const promises = Array.from(this.connections.values()).map(conn => conn.quit().catch(() => {})); await Promise.all(promises); this.connections.clear(); } -} \ No newline at end of file +} diff --git a/libs/core/cache/src/constants.ts b/libs/core/cache/src/constants.ts index 0f9a523..4ec590c 100644 --- a/libs/core/cache/src/constants.ts +++ b/libs/core/cache/src/constants.ts @@ -1,16 +1,16 @@ -// Cache constants -export const CACHE_DEFAULTS = { - TTL: 3600, // 1 hour in seconds - KEY_PREFIX: 'cache:', - SCAN_COUNT: 100, -} as const; - -// Redis connection constants -export const REDIS_DEFAULTS = { - DB: 0, - MAX_RETRIES: 3, - RETRY_DELAY: 100, - CONNECT_TIMEOUT: 10000, - COMMAND_TIMEOUT: 5000, - KEEP_ALIVE: 0, -} as const; \ No newline at end of file +// Cache constants +export const CACHE_DEFAULTS = { + TTL: 3600, // 1 hour in seconds + KEY_PREFIX: 'cache:', + SCAN_COUNT: 100, +} as const; + +// Redis connection constants +export const REDIS_DEFAULTS = { + DB: 0, + MAX_RETRIES: 3, + RETRY_DELAY: 100, + CONNECT_TIMEOUT: 10000, + COMMAND_TIMEOUT: 5000, + KEEP_ALIVE: 0, +} as const; diff --git a/libs/core/cache/src/index.ts b/libs/core/cache/src/index.ts index cd5eef2..abde2be 100644 --- a/libs/core/cache/src/index.ts +++ b/libs/core/cache/src/index.ts @@ -40,4 +40,4 @@ export function createCache(options: CacheOptions): CacheProvider { // Export only what's actually used export type { CacheProvider, CacheStats } from './types'; export { NamespacedCache } from './namespaced-cache'; -export { createNamespacedCache } from './cache-factory'; \ No newline at end of file +export { createNamespacedCache } from './cache-factory'; diff --git a/libs/core/cache/src/redis-cache.ts b/libs/core/cache/src/redis-cache.ts index 0663d9c..a08d637 100644 --- a/libs/core/cache/src/redis-cache.ts +++ b/libs/core/cache/src/redis-cache.ts @@ -8,7 +8,8 @@ import type { CacheOptions, CacheProvider, CacheStats } from './types'; */ export class RedisCache implements CacheProvider { private redis: Redis; - private logger: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void } = console; + private logger: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void } = + console; private defaultTTL: number; private keyPrefix: string; private stats: CacheStats = { @@ -28,7 +29,7 @@ export class RedisCache implements CacheProvider { const manager = RedisConnectionManager.getInstance(); const name = options.name || 'CACHE'; - + this.redis = manager.getConnection({ name: `${name}-SERVICE`, singleton: options.shared ?? true, @@ -72,19 +73,21 @@ export class RedisCache implements CacheProvider { async set( key: string, value: T, - options?: number | { - ttl?: number; - preserveTTL?: boolean; - onlyIfExists?: boolean; - onlyIfNotExists?: boolean; - getOldValue?: boolean; - } + options?: + | number + | { + ttl?: number; + preserveTTL?: boolean; + onlyIfExists?: boolean; + onlyIfNotExists?: boolean; + getOldValue?: boolean; + } ): Promise { try { const fullKey = this.getKey(key); const serialized = JSON.stringify(value); const opts = typeof options === 'number' ? { ttl: options } : options || {}; - + let oldValue: T | null = null; if (opts.getOldValue) { const existing = await this.redis.get(fullKey); @@ -92,9 +95,9 @@ export class RedisCache implements CacheProvider { oldValue = JSON.parse(existing); } } - + const ttl = opts.ttl ?? this.defaultTTL; - + if (opts.onlyIfExists) { const result = await this.redis.set(fullKey, serialized, 'EX', ttl, 'XX'); if (!result) { @@ -115,7 +118,7 @@ export class RedisCache implements CacheProvider { } else { await this.redis.setex(fullKey, ttl, serialized); } - + return oldValue; } catch (error) { this.updateStats(false, true); @@ -145,21 +148,21 @@ export class RedisCache implements CacheProvider { try { const stream = this.redis.scanStream({ match: `${this.keyPrefix}*`, - count: CACHE_DEFAULTS.SCAN_COUNT + count: CACHE_DEFAULTS.SCAN_COUNT, }); - + const pipeline = this.redis.pipeline(); stream.on('data', (keys: string[]) => { if (keys.length) { keys.forEach(key => pipeline.del(key)); } }); - + await new Promise((resolve, reject) => { stream.on('end', resolve); stream.on('error', reject); }); - + await pipeline.exec(); } catch (error) { this.updateStats(false, true); @@ -172,9 +175,9 @@ export class RedisCache implements CacheProvider { const keys: string[] = []; const stream = this.redis.scanStream({ match: `${this.keyPrefix}${pattern}`, - count: CACHE_DEFAULTS.SCAN_COUNT + count: CACHE_DEFAULTS.SCAN_COUNT, }); - + await new Promise((resolve, reject) => { stream.on('data', (resultKeys: string[]) => { keys.push(...resultKeys.map(k => k.replace(this.keyPrefix, ''))); @@ -182,7 +185,7 @@ export class RedisCache implements CacheProvider { stream.on('end', resolve); stream.on('error', reject); }); - + return keys; } catch { this.updateStats(false, true); @@ -206,8 +209,10 @@ export class RedisCache implements CacheProvider { } async waitForReady(timeout = 5000): Promise { - if (this.redis.status === 'ready') {return;} - + if (this.redis.status === 'ready') { + return; + } + return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Redis connection timeout after ${timeout}ms`)); @@ -223,4 +228,4 @@ export class RedisCache implements CacheProvider { isReady(): boolean { return this.redis.status === 'ready'; } -} \ No newline at end of file +} diff --git a/libs/core/cache/src/types.ts b/libs/core/cache/src/types.ts index 0e55b35..a691fdc 100644 --- a/libs/core/cache/src/types.ts +++ b/libs/core/cache/src/types.ts @@ -113,7 +113,12 @@ export interface CacheOptions { name?: string; // Name for connection identification shared?: boolean; // Whether to use shared connection redisConfig: RedisConfig; - logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void }; + logger?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; + debug?: (...args: unknown[]) => void; + }; } export interface CacheStats { diff --git a/libs/core/cache/test/connection-manager.test.ts b/libs/core/cache/test/connection-manager.test.ts index 9e35d5b..846c9f1 100644 --- a/libs/core/cache/test/connection-manager.test.ts +++ b/libs/core/cache/test/connection-manager.test.ts @@ -1,94 +1,94 @@ -import { describe, it, expect } from 'bun:test'; -import { RedisConnectionManager } from '../src/connection-manager'; - -describe('RedisConnectionManager', () => { - it('should be a singleton', () => { - const instance1 = RedisConnectionManager.getInstance(); - const instance2 = RedisConnectionManager.getInstance(); - expect(instance1).toBe(instance2); - }); - - it('should create connections', () => { - const manager = RedisConnectionManager.getInstance(); - const connection = manager.getConnection({ - name: 'test', - redisConfig: { - host: 'localhost', - port: 6379, - }, - }); - - expect(connection).toBeDefined(); - expect(connection.options.host).toBe('localhost'); - expect(connection.options.port).toBe(6379); - }); - - it('should reuse singleton connections', () => { - const manager = RedisConnectionManager.getInstance(); - - const conn1 = manager.getConnection({ - name: 'shared', - singleton: true, - redisConfig: { - host: 'localhost', - port: 6379, - }, - }); - - const conn2 = manager.getConnection({ - name: 'shared', - singleton: true, - redisConfig: { - host: 'localhost', - port: 6379, - }, - }); - - expect(conn1).toBe(conn2); - }); - - it('should create separate non-singleton connections', () => { - const manager = RedisConnectionManager.getInstance(); - - const conn1 = manager.getConnection({ - name: 'separate1', - singleton: false, - redisConfig: { - host: 'localhost', - port: 6379, - }, - }); - - const conn2 = manager.getConnection({ - name: 'separate2', - singleton: false, - redisConfig: { - host: 'localhost', - port: 6379, - }, - }); - - expect(conn1).not.toBe(conn2); - }); - - it('should close all connections', async () => { - const manager = RedisConnectionManager.getInstance(); - - // Create a few connections - manager.getConnection({ - name: 'close-test-1', - redisConfig: { host: 'localhost', port: 6379 }, - }); - - manager.getConnection({ - name: 'close-test-2', - redisConfig: { host: 'localhost', port: 6379 }, - }); - - // Close all - await RedisConnectionManager.closeAll(); - - // Should not throw - expect(true).toBe(true); - }); -}); \ No newline at end of file +import { describe, expect, it } from 'bun:test'; +import { RedisConnectionManager } from '../src/connection-manager'; + +describe('RedisConnectionManager', () => { + it('should be a singleton', () => { + const instance1 = RedisConnectionManager.getInstance(); + const instance2 = RedisConnectionManager.getInstance(); + expect(instance1).toBe(instance2); + }); + + it('should create connections', () => { + const manager = RedisConnectionManager.getInstance(); + const connection = manager.getConnection({ + name: 'test', + redisConfig: { + host: 'localhost', + port: 6379, + }, + }); + + expect(connection).toBeDefined(); + expect(connection.options.host).toBe('localhost'); + expect(connection.options.port).toBe(6379); + }); + + it('should reuse singleton connections', () => { + const manager = RedisConnectionManager.getInstance(); + + const conn1 = manager.getConnection({ + name: 'shared', + singleton: true, + redisConfig: { + host: 'localhost', + port: 6379, + }, + }); + + const conn2 = manager.getConnection({ + name: 'shared', + singleton: true, + redisConfig: { + host: 'localhost', + port: 6379, + }, + }); + + expect(conn1).toBe(conn2); + }); + + it('should create separate non-singleton connections', () => { + const manager = RedisConnectionManager.getInstance(); + + const conn1 = manager.getConnection({ + name: 'separate1', + singleton: false, + redisConfig: { + host: 'localhost', + port: 6379, + }, + }); + + const conn2 = manager.getConnection({ + name: 'separate2', + singleton: false, + redisConfig: { + host: 'localhost', + port: 6379, + }, + }); + + expect(conn1).not.toBe(conn2); + }); + + it('should close all connections', async () => { + const manager = RedisConnectionManager.getInstance(); + + // Create a few connections + manager.getConnection({ + name: 'close-test-1', + redisConfig: { host: 'localhost', port: 6379 }, + }); + + manager.getConnection({ + name: 'close-test-2', + redisConfig: { host: 'localhost', port: 6379 }, + }); + + // Close all + await RedisConnectionManager.closeAll(); + + // Should not throw + expect(true).toBe(true); + }); +}); diff --git a/libs/core/cache/test/namespaced-cache.test.ts b/libs/core/cache/test/namespaced-cache.test.ts index b27566b..177c105 100644 --- a/libs/core/cache/test/namespaced-cache.test.ts +++ b/libs/core/cache/test/namespaced-cache.test.ts @@ -1,429 +1,429 @@ -import { describe, it, expect, beforeEach, mock } from 'bun:test'; -import { NamespacedCache, CacheAdapter } from '../src/namespaced-cache'; -import type { CacheProvider, ICache } from '../src/types'; - -describe('NamespacedCache', () => { - let mockCache: CacheProvider; - let namespacedCache: NamespacedCache; - - beforeEach(() => { - // Create mock base cache - mockCache = { - get: mock(async () => null), - set: mock(async () => null), - del: mock(async () => {}), - exists: mock(async () => false), - clear: mock(async () => {}), - keys: mock(async () => []), - getStats: mock(() => ({ - hits: 100, - misses: 20, - errors: 5, - hitRate: 0.83, - total: 120, - uptime: 3600, - })), - health: mock(async () => true), - waitForReady: mock(async () => {}), - isReady: mock(() => true), - }; - - // Create namespaced cache - namespacedCache = new NamespacedCache(mockCache, 'test-namespace'); - }); - - describe('constructor', () => { - it('should set namespace and prefix correctly', () => { - expect(namespacedCache.getNamespace()).toBe('test-namespace'); - expect(namespacedCache.getFullPrefix()).toBe('test-namespace:'); - }); - - it('should handle empty namespace', () => { - const emptyNamespace = new NamespacedCache(mockCache, ''); - expect(emptyNamespace.getNamespace()).toBe(''); - expect(emptyNamespace.getFullPrefix()).toBe(':'); - }); - }); - - describe('get', () => { - it('should prefix key when getting', async () => { - const testData = { value: 'test' }; - (mockCache.get as any).mockResolvedValue(testData); - - const result = await namespacedCache.get('mykey'); - - expect(mockCache.get).toHaveBeenCalledWith('test-namespace:mykey'); - expect(result).toEqual(testData); - }); - - it('should handle null values', async () => { - (mockCache.get as any).mockResolvedValue(null); - - const result = await namespacedCache.get('nonexistent'); - - expect(mockCache.get).toHaveBeenCalledWith('test-namespace:nonexistent'); - expect(result).toBeNull(); - }); - }); - - describe('set', () => { - it('should prefix key when setting with ttl number', async () => { - const value = { data: 'test' }; - const ttl = 3600; - - await namespacedCache.set('mykey', value, ttl); - - expect(mockCache.set).toHaveBeenCalledWith('test-namespace:mykey', value, ttl); - }); - - it('should prefix key when setting with options object', async () => { - const value = 'test-value'; - const options = { ttl: 7200 }; - - await namespacedCache.set('mykey', value, options); - - expect(mockCache.set).toHaveBeenCalledWith('test-namespace:mykey', value, options); - }); - - it('should handle set without TTL', async () => { - const value = [1, 2, 3]; - - await namespacedCache.set('mykey', value); - - expect(mockCache.set).toHaveBeenCalledWith('test-namespace:mykey', value, undefined); - }); - }); - - describe('del', () => { - it('should prefix key when deleting', async () => { - await namespacedCache.del('mykey'); - - expect(mockCache.del).toHaveBeenCalledWith('test-namespace:mykey'); - }); - - it('should handle multiple deletes', async () => { - await namespacedCache.del('key1'); - await namespacedCache.del('key2'); - - expect(mockCache.del).toHaveBeenCalledTimes(2); - expect(mockCache.del).toHaveBeenCalledWith('test-namespace:key1'); - expect(mockCache.del).toHaveBeenCalledWith('test-namespace:key2'); - }); - }); - - describe('exists', () => { - it('should prefix key when checking existence', async () => { - (mockCache.exists as any).mockResolvedValue(true); - - const result = await namespacedCache.exists('mykey'); - - expect(mockCache.exists).toHaveBeenCalledWith('test-namespace:mykey'); - expect(result).toBe(true); - }); - - it('should return false for non-existent keys', async () => { - (mockCache.exists as any).mockResolvedValue(false); - - const result = await namespacedCache.exists('nonexistent'); - - expect(result).toBe(false); - }); - }); - - describe('keys', () => { - it('should prefix pattern and strip prefix from results', async () => { - (mockCache.keys as any).mockResolvedValue([ - 'test-namespace:key1', - 'test-namespace:key2', - 'test-namespace:key3', - ]); - - const keys = await namespacedCache.keys('*'); - - expect(mockCache.keys).toHaveBeenCalledWith('test-namespace:*'); - expect(keys).toEqual(['key1', 'key2', 'key3']); - }); - - it('should handle specific patterns', async () => { - (mockCache.keys as any).mockResolvedValue([ - 'test-namespace:user:123', - 'test-namespace:user:456', - ]); - - const keys = await namespacedCache.keys('user:*'); - - expect(mockCache.keys).toHaveBeenCalledWith('test-namespace:user:*'); - expect(keys).toEqual(['user:123', 'user:456']); - }); - - it('should filter out keys from other namespaces', async () => { - (mockCache.keys as any).mockResolvedValue([ - 'test-namespace:key1', - 'other-namespace:key2', - 'test-namespace:key3', - ]); - - const keys = await namespacedCache.keys('*'); - - expect(keys).toEqual(['key1', 'key3']); - }); - - it('should handle empty results', async () => { - (mockCache.keys as any).mockResolvedValue([]); - - const keys = await namespacedCache.keys('nonexistent*'); - - expect(keys).toEqual([]); - }); - }); - - describe('clear', () => { - it('should clear only namespaced keys', async () => { - (mockCache.keys as any).mockResolvedValue([ - 'test-namespace:key1', - 'test-namespace:key2', - 'test-namespace:key3', - ]); - - await namespacedCache.clear(); - - expect(mockCache.keys).toHaveBeenCalledWith('test-namespace:*'); - expect(mockCache.del).toHaveBeenCalledTimes(3); - expect(mockCache.del).toHaveBeenCalledWith('key1'); - expect(mockCache.del).toHaveBeenCalledWith('key2'); - expect(mockCache.del).toHaveBeenCalledWith('key3'); - }); - - it('should handle empty namespace', async () => { - (mockCache.keys as any).mockResolvedValue([]); - - await namespacedCache.clear(); - - expect(mockCache.keys).toHaveBeenCalledWith('test-namespace:*'); - expect(mockCache.del).not.toHaveBeenCalled(); - }); - }); - - describe('delegated methods', () => { - it('should delegate getStats', () => { - const stats = namespacedCache.getStats(); - - expect(mockCache.getStats).toHaveBeenCalled(); - expect(stats).toEqual({ - hits: 100, - misses: 20, - errors: 5, - hitRate: 0.83, - total: 120, - uptime: 3600, - }); - }); - - it('should delegate health', async () => { - const health = await namespacedCache.health(); - - expect(mockCache.health).toHaveBeenCalled(); - expect(health).toBe(true); - }); - - it('should delegate waitForReady', async () => { - await namespacedCache.waitForReady(5000); - - expect(mockCache.waitForReady).toHaveBeenCalledWith(5000); - }); - - it('should delegate isReady', () => { - const ready = namespacedCache.isReady(); - - expect(mockCache.isReady).toHaveBeenCalled(); - expect(ready).toBe(true); - }); - }); - - describe('edge cases', () => { - it('should handle special characters in namespace', () => { - const specialNamespace = new NamespacedCache(mockCache, 'test:namespace:with:colons'); - expect(specialNamespace.getFullPrefix()).toBe('test:namespace:with:colons:'); - }); - - it('should handle very long keys', async () => { - const longKey = 'a'.repeat(1000); - await namespacedCache.get(longKey); - - expect(mockCache.get).toHaveBeenCalledWith(`test-namespace:${longKey}`); - }); - - it('should handle errors from underlying cache', async () => { - const error = new Error('Cache error'); - (mockCache.get as any).mockRejectedValue(error); - - await expect(namespacedCache.get('key')).rejects.toThrow('Cache error'); - }); - }); -}); - -describe('CacheAdapter', () => { - let mockICache: ICache; - let adapter: CacheAdapter; - - beforeEach(() => { - mockICache = { - get: mock(async () => null), - set: mock(async () => {}), - del: mock(async () => {}), - exists: mock(async () => false), - clear: mock(async () => {}), - keys: mock(async () => []), - ping: mock(async () => true), - isConnected: mock(() => true), - has: mock(async () => false), - ttl: mock(async () => -1), - type: 'memory' as const, - }; - - adapter = new CacheAdapter(mockICache); - }); - - describe('get', () => { - it('should delegate to ICache.get', async () => { - const data = { value: 'test' }; - (mockICache.get as any).mockResolvedValue(data); - - const result = await adapter.get('key'); - - expect(mockICache.get).toHaveBeenCalledWith('key'); - expect(result).toEqual(data); - }); - }); - - describe('set', () => { - it('should handle TTL as number', async () => { - await adapter.set('key', 'value', 3600); - - expect(mockICache.set).toHaveBeenCalledWith('key', 'value', 3600); - }); - - it('should handle TTL as options object', async () => { - await adapter.set('key', 'value', { ttl: 7200 }); - - expect(mockICache.set).toHaveBeenCalledWith('key', 'value', 7200); - }); - - it('should handle no TTL', async () => { - await adapter.set('key', 'value'); - - expect(mockICache.set).toHaveBeenCalledWith('key', 'value', undefined); - }); - - it('should always return null', async () => { - const result = await adapter.set('key', 'value'); - expect(result).toBeNull(); - }); - }); - - describe('del', () => { - it('should delegate to ICache.del', async () => { - await adapter.del('key'); - - expect(mockICache.del).toHaveBeenCalledWith('key'); - }); - }); - - describe('exists', () => { - it('should delegate to ICache.exists', async () => { - (mockICache.exists as any).mockResolvedValue(true); - - const result = await adapter.exists('key'); - - expect(mockICache.exists).toHaveBeenCalledWith('key'); - expect(result).toBe(true); - }); - }); - - describe('clear', () => { - it('should delegate to ICache.clear', async () => { - await adapter.clear(); - - expect(mockICache.clear).toHaveBeenCalled(); - }); - }); - - describe('keys', () => { - it('should delegate to ICache.keys', async () => { - const keys = ['key1', 'key2']; - (mockICache.keys as any).mockResolvedValue(keys); - - const result = await adapter.keys('*'); - - expect(mockICache.keys).toHaveBeenCalledWith('*'); - expect(result).toEqual(keys); - }); - }); - - describe('getStats', () => { - it('should return default stats', () => { - const stats = adapter.getStats(); - - expect(stats).toEqual({ - hits: 0, - misses: 0, - errors: 0, - hitRate: 0, - total: 0, - uptime: expect.any(Number), - }); - }); - }); - - describe('health', () => { - it('should use ping for health check', async () => { - (mockICache.ping as any).mockResolvedValue(true); - - const result = await adapter.health(); - - expect(mockICache.ping).toHaveBeenCalled(); - expect(result).toBe(true); - }); - - it('should handle ping failures', async () => { - (mockICache.ping as any).mockResolvedValue(false); - - const result = await adapter.health(); - - expect(result).toBe(false); - }); - }); - - describe('waitForReady', () => { - it('should succeed if connected', async () => { - (mockICache.isConnected as any).mockReturnValue(true); - - await expect(adapter.waitForReady()).resolves.toBeUndefined(); - }); - - it('should throw if not connected', async () => { - (mockICache.isConnected as any).mockReturnValue(false); - - await expect(adapter.waitForReady()).rejects.toThrow('Cache not connected'); - }); - }); - - describe('isReady', () => { - it('should delegate to isConnected', () => { - (mockICache.isConnected as any).mockReturnValue(true); - - const result = adapter.isReady(); - - expect(mockICache.isConnected).toHaveBeenCalled(); - expect(result).toBe(true); - }); - - it('should return false when not connected', () => { - (mockICache.isConnected as any).mockReturnValue(false); - - const result = adapter.isReady(); - - expect(result).toBe(false); - }); - }); -}); \ No newline at end of file +import { beforeEach, describe, expect, it, mock } from 'bun:test'; +import { CacheAdapter, NamespacedCache } from '../src/namespaced-cache'; +import type { CacheProvider, ICache } from '../src/types'; + +describe('NamespacedCache', () => { + let mockCache: CacheProvider; + let namespacedCache: NamespacedCache; + + beforeEach(() => { + // Create mock base cache + mockCache = { + get: mock(async () => null), + set: mock(async () => null), + del: mock(async () => {}), + exists: mock(async () => false), + clear: mock(async () => {}), + keys: mock(async () => []), + getStats: mock(() => ({ + hits: 100, + misses: 20, + errors: 5, + hitRate: 0.83, + total: 120, + uptime: 3600, + })), + health: mock(async () => true), + waitForReady: mock(async () => {}), + isReady: mock(() => true), + }; + + // Create namespaced cache + namespacedCache = new NamespacedCache(mockCache, 'test-namespace'); + }); + + describe('constructor', () => { + it('should set namespace and prefix correctly', () => { + expect(namespacedCache.getNamespace()).toBe('test-namespace'); + expect(namespacedCache.getFullPrefix()).toBe('test-namespace:'); + }); + + it('should handle empty namespace', () => { + const emptyNamespace = new NamespacedCache(mockCache, ''); + expect(emptyNamespace.getNamespace()).toBe(''); + expect(emptyNamespace.getFullPrefix()).toBe(':'); + }); + }); + + describe('get', () => { + it('should prefix key when getting', async () => { + const testData = { value: 'test' }; + (mockCache.get as any).mockResolvedValue(testData); + + const result = await namespacedCache.get('mykey'); + + expect(mockCache.get).toHaveBeenCalledWith('test-namespace:mykey'); + expect(result).toEqual(testData); + }); + + it('should handle null values', async () => { + (mockCache.get as any).mockResolvedValue(null); + + const result = await namespacedCache.get('nonexistent'); + + expect(mockCache.get).toHaveBeenCalledWith('test-namespace:nonexistent'); + expect(result).toBeNull(); + }); + }); + + describe('set', () => { + it('should prefix key when setting with ttl number', async () => { + const value = { data: 'test' }; + const ttl = 3600; + + await namespacedCache.set('mykey', value, ttl); + + expect(mockCache.set).toHaveBeenCalledWith('test-namespace:mykey', value, ttl); + }); + + it('should prefix key when setting with options object', async () => { + const value = 'test-value'; + const options = { ttl: 7200 }; + + await namespacedCache.set('mykey', value, options); + + expect(mockCache.set).toHaveBeenCalledWith('test-namespace:mykey', value, options); + }); + + it('should handle set without TTL', async () => { + const value = [1, 2, 3]; + + await namespacedCache.set('mykey', value); + + expect(mockCache.set).toHaveBeenCalledWith('test-namespace:mykey', value, undefined); + }); + }); + + describe('del', () => { + it('should prefix key when deleting', async () => { + await namespacedCache.del('mykey'); + + expect(mockCache.del).toHaveBeenCalledWith('test-namespace:mykey'); + }); + + it('should handle multiple deletes', async () => { + await namespacedCache.del('key1'); + await namespacedCache.del('key2'); + + expect(mockCache.del).toHaveBeenCalledTimes(2); + expect(mockCache.del).toHaveBeenCalledWith('test-namespace:key1'); + expect(mockCache.del).toHaveBeenCalledWith('test-namespace:key2'); + }); + }); + + describe('exists', () => { + it('should prefix key when checking existence', async () => { + (mockCache.exists as any).mockResolvedValue(true); + + const result = await namespacedCache.exists('mykey'); + + expect(mockCache.exists).toHaveBeenCalledWith('test-namespace:mykey'); + expect(result).toBe(true); + }); + + it('should return false for non-existent keys', async () => { + (mockCache.exists as any).mockResolvedValue(false); + + const result = await namespacedCache.exists('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('keys', () => { + it('should prefix pattern and strip prefix from results', async () => { + (mockCache.keys as any).mockResolvedValue([ + 'test-namespace:key1', + 'test-namespace:key2', + 'test-namespace:key3', + ]); + + const keys = await namespacedCache.keys('*'); + + expect(mockCache.keys).toHaveBeenCalledWith('test-namespace:*'); + expect(keys).toEqual(['key1', 'key2', 'key3']); + }); + + it('should handle specific patterns', async () => { + (mockCache.keys as any).mockResolvedValue([ + 'test-namespace:user:123', + 'test-namespace:user:456', + ]); + + const keys = await namespacedCache.keys('user:*'); + + expect(mockCache.keys).toHaveBeenCalledWith('test-namespace:user:*'); + expect(keys).toEqual(['user:123', 'user:456']); + }); + + it('should filter out keys from other namespaces', async () => { + (mockCache.keys as any).mockResolvedValue([ + 'test-namespace:key1', + 'other-namespace:key2', + 'test-namespace:key3', + ]); + + const keys = await namespacedCache.keys('*'); + + expect(keys).toEqual(['key1', 'key3']); + }); + + it('should handle empty results', async () => { + (mockCache.keys as any).mockResolvedValue([]); + + const keys = await namespacedCache.keys('nonexistent*'); + + expect(keys).toEqual([]); + }); + }); + + describe('clear', () => { + it('should clear only namespaced keys', async () => { + (mockCache.keys as any).mockResolvedValue([ + 'test-namespace:key1', + 'test-namespace:key2', + 'test-namespace:key3', + ]); + + await namespacedCache.clear(); + + expect(mockCache.keys).toHaveBeenCalledWith('test-namespace:*'); + expect(mockCache.del).toHaveBeenCalledTimes(3); + expect(mockCache.del).toHaveBeenCalledWith('key1'); + expect(mockCache.del).toHaveBeenCalledWith('key2'); + expect(mockCache.del).toHaveBeenCalledWith('key3'); + }); + + it('should handle empty namespace', async () => { + (mockCache.keys as any).mockResolvedValue([]); + + await namespacedCache.clear(); + + expect(mockCache.keys).toHaveBeenCalledWith('test-namespace:*'); + expect(mockCache.del).not.toHaveBeenCalled(); + }); + }); + + describe('delegated methods', () => { + it('should delegate getStats', () => { + const stats = namespacedCache.getStats(); + + expect(mockCache.getStats).toHaveBeenCalled(); + expect(stats).toEqual({ + hits: 100, + misses: 20, + errors: 5, + hitRate: 0.83, + total: 120, + uptime: 3600, + }); + }); + + it('should delegate health', async () => { + const health = await namespacedCache.health(); + + expect(mockCache.health).toHaveBeenCalled(); + expect(health).toBe(true); + }); + + it('should delegate waitForReady', async () => { + await namespacedCache.waitForReady(5000); + + expect(mockCache.waitForReady).toHaveBeenCalledWith(5000); + }); + + it('should delegate isReady', () => { + const ready = namespacedCache.isReady(); + + expect(mockCache.isReady).toHaveBeenCalled(); + expect(ready).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle special characters in namespace', () => { + const specialNamespace = new NamespacedCache(mockCache, 'test:namespace:with:colons'); + expect(specialNamespace.getFullPrefix()).toBe('test:namespace:with:colons:'); + }); + + it('should handle very long keys', async () => { + const longKey = 'a'.repeat(1000); + await namespacedCache.get(longKey); + + expect(mockCache.get).toHaveBeenCalledWith(`test-namespace:${longKey}`); + }); + + it('should handle errors from underlying cache', async () => { + const error = new Error('Cache error'); + (mockCache.get as any).mockRejectedValue(error); + + await expect(namespacedCache.get('key')).rejects.toThrow('Cache error'); + }); + }); +}); + +describe('CacheAdapter', () => { + let mockICache: ICache; + let adapter: CacheAdapter; + + beforeEach(() => { + mockICache = { + get: mock(async () => null), + set: mock(async () => {}), + del: mock(async () => {}), + exists: mock(async () => false), + clear: mock(async () => {}), + keys: mock(async () => []), + ping: mock(async () => true), + isConnected: mock(() => true), + has: mock(async () => false), + ttl: mock(async () => -1), + type: 'memory' as const, + }; + + adapter = new CacheAdapter(mockICache); + }); + + describe('get', () => { + it('should delegate to ICache.get', async () => { + const data = { value: 'test' }; + (mockICache.get as any).mockResolvedValue(data); + + const result = await adapter.get('key'); + + expect(mockICache.get).toHaveBeenCalledWith('key'); + expect(result).toEqual(data); + }); + }); + + describe('set', () => { + it('should handle TTL as number', async () => { + await adapter.set('key', 'value', 3600); + + expect(mockICache.set).toHaveBeenCalledWith('key', 'value', 3600); + }); + + it('should handle TTL as options object', async () => { + await adapter.set('key', 'value', { ttl: 7200 }); + + expect(mockICache.set).toHaveBeenCalledWith('key', 'value', 7200); + }); + + it('should handle no TTL', async () => { + await adapter.set('key', 'value'); + + expect(mockICache.set).toHaveBeenCalledWith('key', 'value', undefined); + }); + + it('should always return null', async () => { + const result = await adapter.set('key', 'value'); + expect(result).toBeNull(); + }); + }); + + describe('del', () => { + it('should delegate to ICache.del', async () => { + await adapter.del('key'); + + expect(mockICache.del).toHaveBeenCalledWith('key'); + }); + }); + + describe('exists', () => { + it('should delegate to ICache.exists', async () => { + (mockICache.exists as any).mockResolvedValue(true); + + const result = await adapter.exists('key'); + + expect(mockICache.exists).toHaveBeenCalledWith('key'); + expect(result).toBe(true); + }); + }); + + describe('clear', () => { + it('should delegate to ICache.clear', async () => { + await adapter.clear(); + + expect(mockICache.clear).toHaveBeenCalled(); + }); + }); + + describe('keys', () => { + it('should delegate to ICache.keys', async () => { + const keys = ['key1', 'key2']; + (mockICache.keys as any).mockResolvedValue(keys); + + const result = await adapter.keys('*'); + + expect(mockICache.keys).toHaveBeenCalledWith('*'); + expect(result).toEqual(keys); + }); + }); + + describe('getStats', () => { + it('should return default stats', () => { + const stats = adapter.getStats(); + + expect(stats).toEqual({ + hits: 0, + misses: 0, + errors: 0, + hitRate: 0, + total: 0, + uptime: expect.any(Number), + }); + }); + }); + + describe('health', () => { + it('should use ping for health check', async () => { + (mockICache.ping as any).mockResolvedValue(true); + + const result = await adapter.health(); + + expect(mockICache.ping).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should handle ping failures', async () => { + (mockICache.ping as any).mockResolvedValue(false); + + const result = await adapter.health(); + + expect(result).toBe(false); + }); + }); + + describe('waitForReady', () => { + it('should succeed if connected', async () => { + (mockICache.isConnected as any).mockReturnValue(true); + + await expect(adapter.waitForReady()).resolves.toBeUndefined(); + }); + + it('should throw if not connected', async () => { + (mockICache.isConnected as any).mockReturnValue(false); + + await expect(adapter.waitForReady()).rejects.toThrow('Cache not connected'); + }); + }); + + describe('isReady', () => { + it('should delegate to isConnected', () => { + (mockICache.isConnected as any).mockReturnValue(true); + + const result = adapter.isReady(); + + expect(mockICache.isConnected).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should return false when not connected', () => { + (mockICache.isConnected as any).mockReturnValue(false); + + const result = adapter.isReady(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/libs/core/cache/test/redis-cache-simple.test.ts b/libs/core/cache/test/redis-cache-simple.test.ts index c2065ff..7b5c6e2 100644 --- a/libs/core/cache/test/redis-cache-simple.test.ts +++ b/libs/core/cache/test/redis-cache-simple.test.ts @@ -1,37 +1,37 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; -import { RedisCache } from '../src/redis-cache'; -import type { CacheOptions } from '../src/types'; - -describe('RedisCache Simple', () => { - let cache: RedisCache; - - beforeEach(() => { - const options: CacheOptions = { - keyPrefix: 'test:', - ttl: 3600, - redisConfig: { host: 'localhost', port: 6379 }, - }; - cache = new RedisCache(options); - }); - - describe('Core functionality', () => { - it('should create cache instance', () => { - expect(cache).toBeDefined(); - expect(cache.isReady).toBeDefined(); - expect(cache.get).toBeDefined(); - expect(cache.set).toBeDefined(); - }); - - it('should have stats tracking', () => { - const stats = cache.getStats(); - expect(stats).toMatchObject({ - hits: 0, - misses: 0, - errors: 0, - hitRate: 0, - total: 0, - }); - expect(stats.uptime).toBeGreaterThanOrEqual(0); - }); - }); -}); \ No newline at end of file +import { beforeEach, describe, expect, it } from 'bun:test'; +import { RedisCache } from '../src/redis-cache'; +import type { CacheOptions } from '../src/types'; + +describe('RedisCache Simple', () => { + let cache: RedisCache; + + beforeEach(() => { + const options: CacheOptions = { + keyPrefix: 'test:', + ttl: 3600, + redisConfig: { host: 'localhost', port: 6379 }, + }; + cache = new RedisCache(options); + }); + + describe('Core functionality', () => { + it('should create cache instance', () => { + expect(cache).toBeDefined(); + expect(cache.isReady).toBeDefined(); + expect(cache.get).toBeDefined(); + expect(cache.set).toBeDefined(); + }); + + it('should have stats tracking', () => { + const stats = cache.getStats(); + expect(stats).toMatchObject({ + hits: 0, + misses: 0, + errors: 0, + hitRate: 0, + total: 0, + }); + expect(stats.uptime).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/libs/core/cache/test/redis-cache.test.ts b/libs/core/cache/test/redis-cache.test.ts index 5ed149c..13cbe06 100644 --- a/libs/core/cache/test/redis-cache.test.ts +++ b/libs/core/cache/test/redis-cache.test.ts @@ -1,210 +1,210 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; -import { RedisCache } from '../src/redis-cache'; -import type { CacheOptions } from '../src/types'; - -describe('RedisCache', () => { - let cache: RedisCache; - - beforeEach(() => { - const options: CacheOptions = { - keyPrefix: 'test:', - ttl: 3600, - redisConfig: { host: 'localhost', port: 6379 }, - }; - cache = new RedisCache(options); - }); - - describe('Core functionality', () => { - it('should create cache instance', () => { - expect(cache).toBeDefined(); - expect(cache.isReady).toBeDefined(); - expect(cache.get).toBeDefined(); - expect(cache.set).toBeDefined(); - }); - - it('should have stats tracking', () => { - const stats = cache.getStats(); - expect(stats).toMatchObject({ - hits: 0, - misses: 0, - errors: 0, - hitRate: 0, - total: 0, - }); - expect(stats.uptime).toBeGreaterThanOrEqual(0); - }); - }); - - describe('Basic operations', () => { - it('should handle get/set operations', async () => { - const key = 'test-key'; - const value = { foo: 'bar' }; - - // Should return null for non-existent key - const miss = await cache.get(key); - expect(miss).toBeNull(); - - // Should set and retrieve value - await cache.set(key, value); - const retrieved = await cache.get(key); - expect(retrieved).toEqual(value); - - // Should delete key - await cache.del(key); - const deleted = await cache.get(key); - expect(deleted).toBeNull(); - }); - - it('should check key existence', async () => { - const key = 'existence-test'; - - expect(await cache.exists(key)).toBe(false); - - await cache.set(key, 'value'); - expect(await cache.exists(key)).toBe(true); - - await cache.del(key); - expect(await cache.exists(key)).toBe(false); - }); - - it('should handle TTL in set operations', async () => { - const key = 'ttl-test'; - const value = 'test-value'; - - // Set with custom TTL as number - await cache.set(key, value, 1); - expect(await cache.get(key)).toBe(value); - - // Set with custom TTL in options - await cache.set(key, value, { ttl: 2 }); - expect(await cache.get(key)).toBe(value); - }); - }); - - describe('Advanced set options', () => { - it('should handle onlyIfExists option', async () => { - const key = 'conditional-test'; - const value1 = 'value1'; - const value2 = 'value2'; - - // Should not set if key doesn't exist - await cache.set(key, value1, { onlyIfExists: true }); - expect(await cache.get(key)).toBeNull(); - - // Create the key - await cache.set(key, value1); - - // Should update if key exists - await cache.set(key, value2, { onlyIfExists: true }); - expect(await cache.get(key)).toBe(value2); - }); - - it('should handle onlyIfNotExists option', async () => { - const key = 'nx-test'; - const value1 = 'value1'; - const value2 = 'value2'; - - // Should set if key doesn't exist - await cache.set(key, value1, { onlyIfNotExists: true }); - expect(await cache.get(key)).toBe(value1); - - // Should not update if key exists - await cache.set(key, value2, { onlyIfNotExists: true }); - expect(await cache.get(key)).toBe(value1); - }); - - it('should handle preserveTTL option', async () => { - const key = 'preserve-ttl-test'; - const value1 = 'value1'; - const value2 = 'value2'; - - // Set with short TTL - await cache.set(key, value1, 10); - - // Update preserving TTL - await cache.set(key, value2, { preserveTTL: true }); - expect(await cache.get(key)).toBe(value2); - }); - - it('should handle getOldValue option', async () => { - const key = 'old-value-test'; - const value1 = 'value1'; - const value2 = 'value2'; - - // Should return null when no old value - const oldValue1 = await cache.set(key, value1, { getOldValue: true }); - expect(oldValue1).toBeNull(); - - // Should return old value - const oldValue2 = await cache.set(key, value2, { getOldValue: true }); - expect(oldValue2).toBe(value1); - }); - }); - - describe('Error handling', () => { - it('should handle errors gracefully in get', async () => { - // Force an error by using invalid JSON - const badCache = new RedisCache({ - keyPrefix: 'bad:', - redisConfig: { host: 'localhost', port: 6379 }, - }); - - // This would normally throw but should return null - const result = await badCache.get('non-existent'); - expect(result).toBeNull(); - - // Check stats updated - const stats = badCache.getStats(); - expect(stats.misses).toBe(1); - }); - }); - - describe('Pattern operations', () => { - it('should find keys by pattern', async () => { - // Clear first to ensure clean state - await cache.clear(); - - await cache.set('user:1', { id: 1 }); - await cache.set('user:2', { id: 2 }); - await cache.set('post:1', { id: 1 }); - - const userKeys = await cache.keys('user:*'); - expect(userKeys).toHaveLength(2); - expect(userKeys).toContain('user:1'); - expect(userKeys).toContain('user:2'); - - const allKeys = await cache.keys('*'); - expect(allKeys.length).toBeGreaterThanOrEqual(3); - expect(allKeys).toContain('user:1'); - expect(allKeys).toContain('user:2'); - expect(allKeys).toContain('post:1'); - }); - - it('should clear all keys with prefix', async () => { - await cache.set('key1', 'value1'); - await cache.set('key2', 'value2'); - - await cache.clear(); - - const keys = await cache.keys('*'); - expect(keys).toHaveLength(0); - }); - }); - - describe('Health checks', () => { - it('should check health', async () => { - const healthy = await cache.health(); - expect(healthy).toBe(true); - }); - - it('should check if ready', () => { - // May not be ready immediately - const ready = cache.isReady(); - expect(typeof ready).toBe('boolean'); - }); - - it('should wait for ready', async () => { - await expect(cache.waitForReady(1000)).resolves.toBeUndefined(); - }); - }); -}); \ No newline at end of file +import { beforeEach, describe, expect, it } from 'bun:test'; +import { RedisCache } from '../src/redis-cache'; +import type { CacheOptions } from '../src/types'; + +describe('RedisCache', () => { + let cache: RedisCache; + + beforeEach(() => { + const options: CacheOptions = { + keyPrefix: 'test:', + ttl: 3600, + redisConfig: { host: 'localhost', port: 6379 }, + }; + cache = new RedisCache(options); + }); + + describe('Core functionality', () => { + it('should create cache instance', () => { + expect(cache).toBeDefined(); + expect(cache.isReady).toBeDefined(); + expect(cache.get).toBeDefined(); + expect(cache.set).toBeDefined(); + }); + + it('should have stats tracking', () => { + const stats = cache.getStats(); + expect(stats).toMatchObject({ + hits: 0, + misses: 0, + errors: 0, + hitRate: 0, + total: 0, + }); + expect(stats.uptime).toBeGreaterThanOrEqual(0); + }); + }); + + describe('Basic operations', () => { + it('should handle get/set operations', async () => { + const key = 'test-key'; + const value = { foo: 'bar' }; + + // Should return null for non-existent key + const miss = await cache.get(key); + expect(miss).toBeNull(); + + // Should set and retrieve value + await cache.set(key, value); + const retrieved = await cache.get(key); + expect(retrieved).toEqual(value); + + // Should delete key + await cache.del(key); + const deleted = await cache.get(key); + expect(deleted).toBeNull(); + }); + + it('should check key existence', async () => { + const key = 'existence-test'; + + expect(await cache.exists(key)).toBe(false); + + await cache.set(key, 'value'); + expect(await cache.exists(key)).toBe(true); + + await cache.del(key); + expect(await cache.exists(key)).toBe(false); + }); + + it('should handle TTL in set operations', async () => { + const key = 'ttl-test'; + const value = 'test-value'; + + // Set with custom TTL as number + await cache.set(key, value, 1); + expect(await cache.get(key)).toBe(value); + + // Set with custom TTL in options + await cache.set(key, value, { ttl: 2 }); + expect(await cache.get(key)).toBe(value); + }); + }); + + describe('Advanced set options', () => { + it('should handle onlyIfExists option', async () => { + const key = 'conditional-test'; + const value1 = 'value1'; + const value2 = 'value2'; + + // Should not set if key doesn't exist + await cache.set(key, value1, { onlyIfExists: true }); + expect(await cache.get(key)).toBeNull(); + + // Create the key + await cache.set(key, value1); + + // Should update if key exists + await cache.set(key, value2, { onlyIfExists: true }); + expect(await cache.get(key)).toBe(value2); + }); + + it('should handle onlyIfNotExists option', async () => { + const key = 'nx-test'; + const value1 = 'value1'; + const value2 = 'value2'; + + // Should set if key doesn't exist + await cache.set(key, value1, { onlyIfNotExists: true }); + expect(await cache.get(key)).toBe(value1); + + // Should not update if key exists + await cache.set(key, value2, { onlyIfNotExists: true }); + expect(await cache.get(key)).toBe(value1); + }); + + it('should handle preserveTTL option', async () => { + const key = 'preserve-ttl-test'; + const value1 = 'value1'; + const value2 = 'value2'; + + // Set with short TTL + await cache.set(key, value1, 10); + + // Update preserving TTL + await cache.set(key, value2, { preserveTTL: true }); + expect(await cache.get(key)).toBe(value2); + }); + + it('should handle getOldValue option', async () => { + const key = 'old-value-test'; + const value1 = 'value1'; + const value2 = 'value2'; + + // Should return null when no old value + const oldValue1 = await cache.set(key, value1, { getOldValue: true }); + expect(oldValue1).toBeNull(); + + // Should return old value + const oldValue2 = await cache.set(key, value2, { getOldValue: true }); + expect(oldValue2).toBe(value1); + }); + }); + + describe('Error handling', () => { + it('should handle errors gracefully in get', async () => { + // Force an error by using invalid JSON + const badCache = new RedisCache({ + keyPrefix: 'bad:', + redisConfig: { host: 'localhost', port: 6379 }, + }); + + // This would normally throw but should return null + const result = await badCache.get('non-existent'); + expect(result).toBeNull(); + + // Check stats updated + const stats = badCache.getStats(); + expect(stats.misses).toBe(1); + }); + }); + + describe('Pattern operations', () => { + it('should find keys by pattern', async () => { + // Clear first to ensure clean state + await cache.clear(); + + await cache.set('user:1', { id: 1 }); + await cache.set('user:2', { id: 2 }); + await cache.set('post:1', { id: 1 }); + + const userKeys = await cache.keys('user:*'); + expect(userKeys).toHaveLength(2); + expect(userKeys).toContain('user:1'); + expect(userKeys).toContain('user:2'); + + const allKeys = await cache.keys('*'); + expect(allKeys.length).toBeGreaterThanOrEqual(3); + expect(allKeys).toContain('user:1'); + expect(allKeys).toContain('user:2'); + expect(allKeys).toContain('post:1'); + }); + + it('should clear all keys with prefix', async () => { + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + + await cache.clear(); + + const keys = await cache.keys('*'); + expect(keys).toHaveLength(0); + }); + }); + + describe('Health checks', () => { + it('should check health', async () => { + const healthy = await cache.health(); + expect(healthy).toBe(true); + }); + + it('should check if ready', () => { + // May not be ready immediately + const ready = cache.isReady(); + expect(typeof ready).toBe('boolean'); + }); + + it('should wait for ready', async () => { + await expect(cache.waitForReady(1000)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/libs/core/config/src/config-manager.ts b/libs/core/config/src/config-manager.ts index 8003bb6..1781eec 100644 --- a/libs/core/config/src/config-manager.ts +++ b/libs/core/config/src/config-manager.ts @@ -28,10 +28,7 @@ export class ConfigManager> { this.loaders = options.loaders; } else { const configPath = options.configPath || join(process.cwd(), 'config'); - this.loaders = [ - new FileLoader(configPath, this.environment), - new EnvLoader(''), - ]; + this.loaders = [new FileLoader(configPath, this.environment), new EnvLoader('')]; } } @@ -61,7 +58,11 @@ export class ConfigManager> { const mergedConfig = this.merge(...configs) as T; // Add environment if not present - if (typeof mergedConfig === 'object' && mergedConfig !== null && !('environment' in mergedConfig)) { + if ( + typeof mergedConfig === 'object' && + mergedConfig !== null && + !('environment' in mergedConfig) + ) { (mergedConfig as Record)['environment'] = this.environment; } @@ -225,4 +226,4 @@ export class ConfigManager> { return result; } -} \ No newline at end of file +} diff --git a/libs/core/config/src/index.ts b/libs/core/config/src/index.ts index 656b145..6c85ed8 100644 --- a/libs/core/config/src/index.ts +++ b/libs/core/config/src/index.ts @@ -18,6 +18,9 @@ export { } from './schemas'; // createAppConfig function for apps/stock -export function createAppConfig(schema: unknown, options?: ConfigManagerOptions): ConfigManager { +export function createAppConfig( + schema: unknown, + options?: ConfigManagerOptions +): ConfigManager { return new ConfigManager(options); -} \ No newline at end of file +} diff --git a/libs/core/config/src/loaders/env.loader.ts b/libs/core/config/src/loaders/env.loader.ts index 3300f94..791a5c5 100644 --- a/libs/core/config/src/loaders/env.loader.ts +++ b/libs/core/config/src/loaders/env.loader.ts @@ -133,10 +133,7 @@ export class EnvLoader implements ConfigLoader { private shouldPreserveStringForKey(key: string): boolean { // Keys that should preserve string values even if they look like numbers - const preserveStringKeys = [ - 'QM_WEBMASTER_ID', - 'IB_MARKET_DATA_TYPE' - ]; + const preserveStringKeys = ['QM_WEBMASTER_ID', 'IB_MARKET_DATA_TYPE']; return preserveStringKeys.includes(key); } diff --git a/libs/core/config/src/schemas/provider.schema.ts b/libs/core/config/src/schemas/provider.schema.ts index 297fd36..0f7e4cf 100644 --- a/libs/core/config/src/schemas/provider.schema.ts +++ b/libs/core/config/src/schemas/provider.schema.ts @@ -24,23 +24,27 @@ export const eodProviderConfigSchema = baseProviderConfigSchema.extend({ // Interactive Brokers provider export const ibProviderConfigSchema = baseProviderConfigSchema.extend({ - gateway: z.object({ - host: z.string().default('localhost'), - port: z.number().default(5000), - clientId: z.number().default(1), - }).default({ - host: 'localhost', - port: 5000, - clientId: 1, - }), - account: z.string().optional(), - marketDataType: z.union([ - z.enum(['live', 'delayed', 'frozen']), - z.enum(['1', '2', '3']).transform((val) => { - const mapping = { '1': 'live', '2': 'frozen', '3': 'delayed' } as const; - return mapping[val]; + gateway: z + .object({ + host: z.string().default('localhost'), + port: z.number().default(5000), + clientId: z.number().default(1), + }) + .default({ + host: 'localhost', + port: 5000, + clientId: 1, }), - ]).default('delayed'), + account: z.string().optional(), + marketDataType: z + .union([ + z.enum(['live', 'delayed', 'frozen']), + z.enum(['1', '2', '3']).transform(val => { + const mapping = { '1': 'live', '2': 'frozen', '3': 'delayed' } as const; + return mapping[val]; + }), + ]) + .default('delayed'), }); // QuoteMedia provider diff --git a/libs/core/config/test/config-manager.test.ts b/libs/core/config/test/config-manager.test.ts index 804c171..83ac6d7 100644 --- a/libs/core/config/test/config-manager.test.ts +++ b/libs/core/config/test/config-manager.test.ts @@ -1,515 +1,517 @@ -import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test'; -import { z } from 'zod'; -import { ConfigManager } from '../src/config-manager'; -import { ConfigError, ConfigValidationError } from '../src/errors'; -import type { ConfigLoader, Environment } from '../src/types'; - -// Mock the logger -mock.module('@stock-bot/logger', () => ({ - getLogger: () => ({ - info: mock(() => {}), - error: mock(() => {}), - warn: mock(() => {}), - debug: mock(() => {}), - }) -})); - -// Mock loader class -class MockLoader implements ConfigLoader { - constructor( - private data: Record, - public priority: number = 0 - ) {} - - load(): Record { - return this.data; - } -} - -describe('ConfigManager', () => { - let manager: ConfigManager; - - beforeEach(() => { - // Reset environment - delete process.env.NODE_ENV; - }); - - describe('constructor', () => { - it('should initialize with default loaders', () => { - manager = new ConfigManager(); - expect(manager).toBeDefined(); - expect(manager.getEnvironment()).toBe('development'); - }); - - it('should detect environment from NODE_ENV', () => { - process.env.NODE_ENV = 'production'; - manager = new ConfigManager(); - expect(manager.getEnvironment()).toBe('production'); - }); - - it('should handle various environment values', () => { - const envMap: Record = { - 'production': 'production', - 'prod': 'production', - 'test': 'test', - 'development': 'development', - 'dev': 'development', - 'unknown': 'development', - }; - - for (const [input, expected] of Object.entries(envMap)) { - process.env.NODE_ENV = input; - manager = new ConfigManager(); - expect(manager.getEnvironment()).toBe(expected); - } - }); - - it('should use custom loaders when provided', () => { - const customLoader = new MockLoader({ custom: 'data' }); - manager = new ConfigManager({ - loaders: [customLoader], - }); - - manager.initialize(); - expect(manager.get()).toEqual({ custom: 'data', environment: 'development' }); - }); - - it('should use custom environment when provided', () => { - manager = new ConfigManager({ - environment: 'test', - }); - expect(manager.getEnvironment()).toBe('test'); - }); - }); - - describe('initialize', () => { - it('should load and merge configurations', () => { - const loader1 = new MockLoader({ a: 1, b: { c: 2 } }, 1); - const loader2 = new MockLoader({ b: { d: 3 }, e: 4 }, 2); - - manager = new ConfigManager({ - loaders: [loader1, loader2], - }); - - const config = manager.initialize(); - - expect(config).toEqual({ - a: 1, - b: { c: 2, d: 3 }, - e: 4, - environment: 'development', - }); - }); - - it('should return cached config on subsequent calls', () => { - const loader = new MockLoader({ test: 'data' }); - const loadSpy = spyOn(loader, 'load'); - - manager = new ConfigManager({ - loaders: [loader], - }); - - const config1 = manager.initialize(); - const config2 = manager.initialize(); - - expect(config1).toBe(config2); - expect(loadSpy).toHaveBeenCalledTimes(1); - }); - - it('should validate config with schema', () => { - const schema = z.object({ - name: z.string(), - port: z.number(), - environment: z.string(), - }); - - const loader = new MockLoader({ - name: 'test-app', - port: 3000, - }); - - manager = new ConfigManager({ - loaders: [loader], - }); - - const config = manager.initialize(schema); - - expect(config).toEqual({ - name: 'test-app', - port: 3000, - environment: 'development', - }); - }); - - it('should throw validation error for invalid config', () => { - const schema = z.object({ - name: z.string(), - port: z.number(), - }); - - const loader = new MockLoader({ - name: 'test-app', - port: 'invalid', // Should be number - }); - - manager = new ConfigManager({ - loaders: [loader], - }); - - expect(() => manager.initialize(schema)).toThrow(ConfigValidationError); - }); - - it('should handle empty loaders', () => { - manager = new ConfigManager({ - loaders: [], - }); - - const config = manager.initialize(); - expect(config).toEqual({ environment: 'development' }); - }); - - it('should ignore loaders that return empty config', () => { - const loader1 = new MockLoader({}); - const loader2 = new MockLoader({ data: 'value' }); - - manager = new ConfigManager({ - loaders: [loader1, loader2], - }); - - const config = manager.initialize(); - expect(config).toEqual({ data: 'value', environment: 'development' }); - }); - - it('should respect loader priority order', () => { - const loader1 = new MockLoader({ value: 'first' }, 1); - const loader2 = new MockLoader({ value: 'second' }, 2); - const loader3 = new MockLoader({ value: 'third' }, 0); - - manager = new ConfigManager({ - loaders: [loader1, loader2, loader3], - }); - - const config = manager.initialize(); - // Priority order: 0, 1, 2 (lowest to highest) - // So 'second' should win - expect(config.value).toBe('second'); - }); - - it('should handle validation errors with detailed error info', () => { - const schema = z.object({ - name: z.string(), - port: z.number().min(1).max(65535), - features: z.object({ - enabled: z.boolean(), - }), - }); - - const loader = new MockLoader({ - name: 123, // Should be string - port: 99999, // Out of range - features: { - enabled: 'yes', // Should be boolean - }, - }); - - manager = new ConfigManager({ - loaders: [loader], - }); - - try { - manager.initialize(schema); - expect(true).toBe(false); // Should not reach here - } catch (error) { - expect(error).toBeInstanceOf(ConfigValidationError); - const validationError = error as ConfigValidationError; - expect(validationError.errors).toBeDefined(); - expect(validationError.errors.length).toBeGreaterThan(0); - } - }); - }); - - describe('get', () => { - it('should return config after initialization', () => { - const loader = new MockLoader({ test: 'data' }); - manager = new ConfigManager({ loaders: [loader] }); - - manager.initialize(); - expect(manager.get()).toEqual({ test: 'data', environment: 'development' }); - }); - - it('should throw error if not initialized', () => { - manager = new ConfigManager(); - - expect(() => manager.get()).toThrow(ConfigError); - expect(() => manager.get()).toThrow('Configuration not initialized'); - }); - }); - - describe('getValue', () => { - beforeEach(() => { - const loader = new MockLoader({ - database: { - host: 'localhost', - port: 5432, - credentials: { - username: 'admin', - password: 'secret', - }, - }, - cache: { - enabled: true, - ttl: 3600, - }, - }); - - manager = new ConfigManager({ loaders: [loader] }); - manager.initialize(); - }); - - it('should get value by path', () => { - expect(manager.getValue('database.host')).toBe('localhost'); - expect(manager.getValue('database.port')).toBe(5432); - expect(manager.getValue('cache.enabled')).toBe(true); - }); - - it('should get nested values', () => { - expect(manager.getValue('database.credentials.username')).toBe('admin'); - expect(manager.getValue('database.credentials.password')).toBe('secret'); - }); - - it('should throw error for non-existent path', () => { - expect(() => manager.getValue('nonexistent.path')).toThrow(ConfigError); - expect(() => manager.getValue('nonexistent.path')).toThrow('Configuration key not found'); - }); - - it('should handle top-level values', () => { - expect(manager.getValue('database')).toEqual({ - host: 'localhost', - port: 5432, - credentials: { - username: 'admin', - password: 'secret', - }, - }); - }); - }); - - describe('has', () => { - beforeEach(() => { - const loader = new MockLoader({ - database: { host: 'localhost' }, - cache: { enabled: true }, - }); - - manager = new ConfigManager({ loaders: [loader] }); - manager.initialize(); - }); - - it('should return true for existing paths', () => { - expect(manager.has('database')).toBe(true); - expect(manager.has('database.host')).toBe(true); - expect(manager.has('cache.enabled')).toBe(true); - }); - - it('should return false for non-existent paths', () => { - expect(manager.has('nonexistent')).toBe(false); - expect(manager.has('database.port')).toBe(false); - expect(manager.has('cache.ttl')).toBe(false); - }); - }); - - describe('set', () => { - beforeEach(() => { - const loader = new MockLoader({ - app: { name: 'test', version: '1.0.0' }, - port: 3000, - }); - - manager = new ConfigManager({ loaders: [loader] }); - manager.initialize(); - }); - - it('should update configuration values', () => { - manager.set({ port: 4000 }); - expect(manager.get().port).toBe(4000); - - manager.set({ app: { version: '2.0.0' } }); - expect(manager.get().app.version).toBe('2.0.0'); - expect(manager.get().app.name).toBe('test'); // Unchanged - }); - - it('should validate updates when schema is present', () => { - const schema = z.object({ - app: z.object({ - name: z.string(), - version: z.string(), - }), - port: z.number().min(1000).max(9999), - environment: z.string(), - }); - - manager = new ConfigManager({ loaders: [new MockLoader({ app: { name: 'test', version: '1.0.0' }, port: 3000 })] }); - manager.initialize(schema); - - // Valid update - manager.set({ port: 4000 }); - expect(manager.get().port).toBe(4000); - - // Invalid update - expect(() => manager.set({ port: 99999 })).toThrow(ConfigValidationError); - }); - - it('should throw error if not initialized', () => { - const newManager = new ConfigManager(); - expect(() => newManager.set({ test: 'value' })).toThrow(ConfigError); - }); - }); - - describe('reset', () => { - it('should clear configuration', () => { - const loader = new MockLoader({ test: 'data' }); - manager = new ConfigManager({ loaders: [loader] }); - - manager.initialize(); - expect(manager.get()).toBeDefined(); - - manager.reset(); - expect(() => manager.get()).toThrow(ConfigError); - }); - }); - - describe('validate', () => { - it('should validate current config against schema', () => { - const loader = new MockLoader({ - name: 'test-app', - port: 3000, - }); - - manager = new ConfigManager({ loaders: [loader] }); - manager.initialize(); - - const schema = z.object({ - name: z.string(), - port: z.number(), - environment: z.string(), - }); - - const validated = manager.validate(schema); - expect(validated).toEqual({ - name: 'test-app', - port: 3000, - environment: 'development', - }); - }); - - it('should throw if validation fails', () => { - const loader = new MockLoader({ - name: 'test-app', - port: 'invalid', - }); - - manager = new ConfigManager({ loaders: [loader] }); - manager.initialize(); - - const schema = z.object({ - name: z.string(), - port: z.number(), - }); - - expect(() => manager.validate(schema)).toThrow(); - }); - }); - - describe('createTypedGetter', () => { - it('should create a typed getter function', () => { - const loader = new MockLoader({ - database: { - host: 'localhost', - port: 5432, - }, - }); - - manager = new ConfigManager({ loaders: [loader] }); - manager.initialize(); - - const schema = z.object({ - database: z.object({ - host: z.string(), - port: z.number(), - }), - environment: z.string(), - }); - - const getConfig = manager.createTypedGetter(schema); - const config = getConfig(); - - expect(config.database.host).toBe('localhost'); - expect(config.database.port).toBe(5432); - expect(config.environment).toBe('development'); - }); - }); - - describe('deepMerge', () => { - it('should handle circular references', () => { - const obj1: any = { a: 1 }; - const obj2: any = { b: 2 }; - obj1.circular = obj1; // Create circular reference - obj2.ref = obj1; - - const loader1 = new MockLoader(obj1); - const loader2 = new MockLoader(obj2); - - manager = new ConfigManager({ loaders: [loader1, loader2] }); - - // Should not throw on circular reference - const config = manager.initialize(); - expect(config.a).toBe(1); - expect(config.b).toBe(2); - }); - - it('should handle null and undefined values', () => { - const loader1 = new MockLoader({ a: null, b: 'value' }); - const loader2 = new MockLoader({ a: 'overridden', c: undefined }); - - manager = new ConfigManager({ loaders: [loader1, loader2] }); - const config = manager.initialize(); - - expect(config.a).toBe('overridden'); - expect(config.b).toBe('value'); - expect(config.c).toBeUndefined(); - }); - - it('should handle Date and RegExp objects', () => { - const date = new Date('2024-01-01'); - const regex = /test/gi; - - const loader = new MockLoader({ - date: date, - pattern: regex, - nested: { - date: date, - pattern: regex, - }, - }); - - manager = new ConfigManager({ loaders: [loader] }); - const config = manager.initialize(); - - expect(config.date).toBe(date); - expect(config.pattern).toBe(regex); - expect(config.nested.date).toBe(date); - expect(config.nested.pattern).toBe(regex); - }); - - it('should handle arrays without merging', () => { - const loader1 = new MockLoader({ items: [1, 2, 3] }); - const loader2 = new MockLoader({ items: [4, 5, 6] }); - - manager = new ConfigManager({ loaders: [loader1, loader2] }); - const config = manager.initialize(); - - // Arrays should be replaced, not merged - expect(config.items).toEqual([4, 5, 6]); - }); - }); -}); \ No newline at end of file +import { beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { z } from 'zod'; +import { ConfigManager } from '../src/config-manager'; +import { ConfigError, ConfigValidationError } from '../src/errors'; +import type { ConfigLoader, Environment } from '../src/types'; + +// Mock the logger +mock.module('@stock-bot/logger', () => ({ + getLogger: () => ({ + info: mock(() => {}), + error: mock(() => {}), + warn: mock(() => {}), + debug: mock(() => {}), + }), +})); + +// Mock loader class +class MockLoader implements ConfigLoader { + constructor( + private data: Record, + public priority: number = 0 + ) {} + + load(): Record { + return this.data; + } +} + +describe('ConfigManager', () => { + let manager: ConfigManager; + + beforeEach(() => { + // Reset environment + delete process.env.NODE_ENV; + }); + + describe('constructor', () => { + it('should initialize with default loaders', () => { + manager = new ConfigManager(); + expect(manager).toBeDefined(); + expect(manager.getEnvironment()).toBe('development'); + }); + + it('should detect environment from NODE_ENV', () => { + process.env.NODE_ENV = 'production'; + manager = new ConfigManager(); + expect(manager.getEnvironment()).toBe('production'); + }); + + it('should handle various environment values', () => { + const envMap: Record = { + production: 'production', + prod: 'production', + test: 'test', + development: 'development', + dev: 'development', + unknown: 'development', + }; + + for (const [input, expected] of Object.entries(envMap)) { + process.env.NODE_ENV = input; + manager = new ConfigManager(); + expect(manager.getEnvironment()).toBe(expected); + } + }); + + it('should use custom loaders when provided', () => { + const customLoader = new MockLoader({ custom: 'data' }); + manager = new ConfigManager({ + loaders: [customLoader], + }); + + manager.initialize(); + expect(manager.get()).toEqual({ custom: 'data', environment: 'development' }); + }); + + it('should use custom environment when provided', () => { + manager = new ConfigManager({ + environment: 'test', + }); + expect(manager.getEnvironment()).toBe('test'); + }); + }); + + describe('initialize', () => { + it('should load and merge configurations', () => { + const loader1 = new MockLoader({ a: 1, b: { c: 2 } }, 1); + const loader2 = new MockLoader({ b: { d: 3 }, e: 4 }, 2); + + manager = new ConfigManager({ + loaders: [loader1, loader2], + }); + + const config = manager.initialize(); + + expect(config).toEqual({ + a: 1, + b: { c: 2, d: 3 }, + e: 4, + environment: 'development', + }); + }); + + it('should return cached config on subsequent calls', () => { + const loader = new MockLoader({ test: 'data' }); + const loadSpy = spyOn(loader, 'load'); + + manager = new ConfigManager({ + loaders: [loader], + }); + + const config1 = manager.initialize(); + const config2 = manager.initialize(); + + expect(config1).toBe(config2); + expect(loadSpy).toHaveBeenCalledTimes(1); + }); + + it('should validate config with schema', () => { + const schema = z.object({ + name: z.string(), + port: z.number(), + environment: z.string(), + }); + + const loader = new MockLoader({ + name: 'test-app', + port: 3000, + }); + + manager = new ConfigManager({ + loaders: [loader], + }); + + const config = manager.initialize(schema); + + expect(config).toEqual({ + name: 'test-app', + port: 3000, + environment: 'development', + }); + }); + + it('should throw validation error for invalid config', () => { + const schema = z.object({ + name: z.string(), + port: z.number(), + }); + + const loader = new MockLoader({ + name: 'test-app', + port: 'invalid', // Should be number + }); + + manager = new ConfigManager({ + loaders: [loader], + }); + + expect(() => manager.initialize(schema)).toThrow(ConfigValidationError); + }); + + it('should handle empty loaders', () => { + manager = new ConfigManager({ + loaders: [], + }); + + const config = manager.initialize(); + expect(config).toEqual({ environment: 'development' }); + }); + + it('should ignore loaders that return empty config', () => { + const loader1 = new MockLoader({}); + const loader2 = new MockLoader({ data: 'value' }); + + manager = new ConfigManager({ + loaders: [loader1, loader2], + }); + + const config = manager.initialize(); + expect(config).toEqual({ data: 'value', environment: 'development' }); + }); + + it('should respect loader priority order', () => { + const loader1 = new MockLoader({ value: 'first' }, 1); + const loader2 = new MockLoader({ value: 'second' }, 2); + const loader3 = new MockLoader({ value: 'third' }, 0); + + manager = new ConfigManager({ + loaders: [loader1, loader2, loader3], + }); + + const config = manager.initialize(); + // Priority order: 0, 1, 2 (lowest to highest) + // So 'second' should win + expect(config.value).toBe('second'); + }); + + it('should handle validation errors with detailed error info', () => { + const schema = z.object({ + name: z.string(), + port: z.number().min(1).max(65535), + features: z.object({ + enabled: z.boolean(), + }), + }); + + const loader = new MockLoader({ + name: 123, // Should be string + port: 99999, // Out of range + features: { + enabled: 'yes', // Should be boolean + }, + }); + + manager = new ConfigManager({ + loaders: [loader], + }); + + try { + manager.initialize(schema); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeInstanceOf(ConfigValidationError); + const validationError = error as ConfigValidationError; + expect(validationError.errors).toBeDefined(); + expect(validationError.errors.length).toBeGreaterThan(0); + } + }); + }); + + describe('get', () => { + it('should return config after initialization', () => { + const loader = new MockLoader({ test: 'data' }); + manager = new ConfigManager({ loaders: [loader] }); + + manager.initialize(); + expect(manager.get()).toEqual({ test: 'data', environment: 'development' }); + }); + + it('should throw error if not initialized', () => { + manager = new ConfigManager(); + + expect(() => manager.get()).toThrow(ConfigError); + expect(() => manager.get()).toThrow('Configuration not initialized'); + }); + }); + + describe('getValue', () => { + beforeEach(() => { + const loader = new MockLoader({ + database: { + host: 'localhost', + port: 5432, + credentials: { + username: 'admin', + password: 'secret', + }, + }, + cache: { + enabled: true, + ttl: 3600, + }, + }); + + manager = new ConfigManager({ loaders: [loader] }); + manager.initialize(); + }); + + it('should get value by path', () => { + expect(manager.getValue('database.host')).toBe('localhost'); + expect(manager.getValue('database.port')).toBe(5432); + expect(manager.getValue('cache.enabled')).toBe(true); + }); + + it('should get nested values', () => { + expect(manager.getValue('database.credentials.username')).toBe('admin'); + expect(manager.getValue('database.credentials.password')).toBe('secret'); + }); + + it('should throw error for non-existent path', () => { + expect(() => manager.getValue('nonexistent.path')).toThrow(ConfigError); + expect(() => manager.getValue('nonexistent.path')).toThrow('Configuration key not found'); + }); + + it('should handle top-level values', () => { + expect(manager.getValue('database')).toEqual({ + host: 'localhost', + port: 5432, + credentials: { + username: 'admin', + password: 'secret', + }, + }); + }); + }); + + describe('has', () => { + beforeEach(() => { + const loader = new MockLoader({ + database: { host: 'localhost' }, + cache: { enabled: true }, + }); + + manager = new ConfigManager({ loaders: [loader] }); + manager.initialize(); + }); + + it('should return true for existing paths', () => { + expect(manager.has('database')).toBe(true); + expect(manager.has('database.host')).toBe(true); + expect(manager.has('cache.enabled')).toBe(true); + }); + + it('should return false for non-existent paths', () => { + expect(manager.has('nonexistent')).toBe(false); + expect(manager.has('database.port')).toBe(false); + expect(manager.has('cache.ttl')).toBe(false); + }); + }); + + describe('set', () => { + beforeEach(() => { + const loader = new MockLoader({ + app: { name: 'test', version: '1.0.0' }, + port: 3000, + }); + + manager = new ConfigManager({ loaders: [loader] }); + manager.initialize(); + }); + + it('should update configuration values', () => { + manager.set({ port: 4000 }); + expect(manager.get().port).toBe(4000); + + manager.set({ app: { version: '2.0.0' } }); + expect(manager.get().app.version).toBe('2.0.0'); + expect(manager.get().app.name).toBe('test'); // Unchanged + }); + + it('should validate updates when schema is present', () => { + const schema = z.object({ + app: z.object({ + name: z.string(), + version: z.string(), + }), + port: z.number().min(1000).max(9999), + environment: z.string(), + }); + + manager = new ConfigManager({ + loaders: [new MockLoader({ app: { name: 'test', version: '1.0.0' }, port: 3000 })], + }); + manager.initialize(schema); + + // Valid update + manager.set({ port: 4000 }); + expect(manager.get().port).toBe(4000); + + // Invalid update + expect(() => manager.set({ port: 99999 })).toThrow(ConfigValidationError); + }); + + it('should throw error if not initialized', () => { + const newManager = new ConfigManager(); + expect(() => newManager.set({ test: 'value' })).toThrow(ConfigError); + }); + }); + + describe('reset', () => { + it('should clear configuration', () => { + const loader = new MockLoader({ test: 'data' }); + manager = new ConfigManager({ loaders: [loader] }); + + manager.initialize(); + expect(manager.get()).toBeDefined(); + + manager.reset(); + expect(() => manager.get()).toThrow(ConfigError); + }); + }); + + describe('validate', () => { + it('should validate current config against schema', () => { + const loader = new MockLoader({ + name: 'test-app', + port: 3000, + }); + + manager = new ConfigManager({ loaders: [loader] }); + manager.initialize(); + + const schema = z.object({ + name: z.string(), + port: z.number(), + environment: z.string(), + }); + + const validated = manager.validate(schema); + expect(validated).toEqual({ + name: 'test-app', + port: 3000, + environment: 'development', + }); + }); + + it('should throw if validation fails', () => { + const loader = new MockLoader({ + name: 'test-app', + port: 'invalid', + }); + + manager = new ConfigManager({ loaders: [loader] }); + manager.initialize(); + + const schema = z.object({ + name: z.string(), + port: z.number(), + }); + + expect(() => manager.validate(schema)).toThrow(); + }); + }); + + describe('createTypedGetter', () => { + it('should create a typed getter function', () => { + const loader = new MockLoader({ + database: { + host: 'localhost', + port: 5432, + }, + }); + + manager = new ConfigManager({ loaders: [loader] }); + manager.initialize(); + + const schema = z.object({ + database: z.object({ + host: z.string(), + port: z.number(), + }), + environment: z.string(), + }); + + const getConfig = manager.createTypedGetter(schema); + const config = getConfig(); + + expect(config.database.host).toBe('localhost'); + expect(config.database.port).toBe(5432); + expect(config.environment).toBe('development'); + }); + }); + + describe('deepMerge', () => { + it('should handle circular references', () => { + const obj1: any = { a: 1 }; + const obj2: any = { b: 2 }; + obj1.circular = obj1; // Create circular reference + obj2.ref = obj1; + + const loader1 = new MockLoader(obj1); + const loader2 = new MockLoader(obj2); + + manager = new ConfigManager({ loaders: [loader1, loader2] }); + + // Should not throw on circular reference + const config = manager.initialize(); + expect(config.a).toBe(1); + expect(config.b).toBe(2); + }); + + it('should handle null and undefined values', () => { + const loader1 = new MockLoader({ a: null, b: 'value' }); + const loader2 = new MockLoader({ a: 'overridden', c: undefined }); + + manager = new ConfigManager({ loaders: [loader1, loader2] }); + const config = manager.initialize(); + + expect(config.a).toBe('overridden'); + expect(config.b).toBe('value'); + expect(config.c).toBeUndefined(); + }); + + it('should handle Date and RegExp objects', () => { + const date = new Date('2024-01-01'); + const regex = /test/gi; + + const loader = new MockLoader({ + date: date, + pattern: regex, + nested: { + date: date, + pattern: regex, + }, + }); + + manager = new ConfigManager({ loaders: [loader] }); + const config = manager.initialize(); + + expect(config.date).toBe(date); + expect(config.pattern).toBe(regex); + expect(config.nested.date).toBe(date); + expect(config.nested.pattern).toBe(regex); + }); + + it('should handle arrays without merging', () => { + const loader1 = new MockLoader({ items: [1, 2, 3] }); + const loader2 = new MockLoader({ items: [4, 5, 6] }); + + manager = new ConfigManager({ loaders: [loader1, loader2] }); + const config = manager.initialize(); + + // Arrays should be replaced, not merged + expect(config.items).toEqual([4, 5, 6]); + }); + }); +}); diff --git a/libs/core/config/test/config.test.ts b/libs/core/config/test/config.test.ts index 1b626fd..8d7f137 100644 --- a/libs/core/config/test/config.test.ts +++ b/libs/core/config/test/config.test.ts @@ -1,10 +1,6 @@ import { beforeEach, describe, expect, it } from 'bun:test'; import { z } from 'zod'; -import { - baseAppSchema, - ConfigManager, - createAppConfig, -} from '../src'; +import { baseAppSchema, ConfigManager, createAppConfig } from '../src'; import { ConfigError, ConfigValidationError } from '../src/errors'; // Mock loader for testing @@ -160,7 +156,6 @@ describe('ConfigManager', () => { expect(validated).toEqual({ name: 'test', port: 3000 }); }); - it('should add environment if not present', () => { const mockManager = new ConfigManager({ environment: 'test', @@ -172,7 +167,6 @@ describe('ConfigManager', () => { }); }); - describe('Config Builders', () => { it('should create app config with schema', () => { const schema = z.object({ diff --git a/libs/core/config/test/env.loader.test.ts b/libs/core/config/test/env.loader.test.ts index f9b8b2a..3f2a238 100644 --- a/libs/core/config/test/env.loader.test.ts +++ b/libs/core/config/test/env.loader.test.ts @@ -1,633 +1,641 @@ -import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; -import { readFileSync } from 'fs'; -import { EnvLoader } from '../src/loaders/env.loader'; -import { ConfigLoaderError } from '../src/errors'; - -// Mock fs module -mock.module('fs', () => ({ - readFileSync: mock(() => '') -})); - -describe('EnvLoader', () => { - let loader: EnvLoader; - const originalEnv = { ...process.env }; - - beforeEach(() => { - // Clear environment - for (const key in process.env) { - delete process.env[key]; - } - }); - - afterEach(() => { - // Restore original environment - for (const key in process.env) { - delete process.env[key]; - } - Object.assign(process.env, originalEnv); - }); - - describe('constructor', () => { - it('should have highest priority', () => { - loader = new EnvLoader(); - expect(loader.priority).toBe(100); - }); - - it('should accept prefix and options', () => { - loader = new EnvLoader('APP_', { - convertCase: true, - parseJson: false, - }); - expect(loader).toBeDefined(); - }); - }); - - describe('load', () => { - it('should load environment variables without prefix', () => { - process.env.TEST_VAR = 'test_value'; - process.env.ANOTHER_VAR = 'another_value'; - - loader = new EnvLoader(); - const config = loader.load(); - - // Environment variables with underscores are converted to nested structure - interface ExpectedConfig { - test?: { var: string }; - another?: { var: string }; - } - expect((config as ExpectedConfig).test?.var).toBe('test_value'); - expect((config as ExpectedConfig).another?.var).toBe('another_value'); - }); - - it('should filter by prefix', () => { - process.env.APP_NAME = 'myapp'; - process.env.APP_VERSION = '1.0.0'; - process.env.OTHER_VAR = 'ignored'; - - loader = new EnvLoader('APP_'); - const config = loader.load(); - - expect(config.NAME).toBe('myapp'); - expect(config.VERSION).toBe('1.0.0'); - expect(config.OTHER_VAR).toBeUndefined(); - }); - - it('should parse values by default', () => { - process.env.BOOL_TRUE = 'true'; - process.env.BOOL_FALSE = 'false'; - process.env.NUMBER = '42'; - process.env.STRING = 'hello'; - process.env.NULL_VAL = 'null'; - - loader = new EnvLoader(); - const config = loader.load(); - - // Values are nested based on underscores - expect((config as any).bool?.true).toBe(true); - expect((config as any).bool?.false).toBe(false); - expect((config as any).NUMBER).toBe(42); // No underscore, keeps original case - expect((config as any).STRING).toBe('hello'); // No underscore, keeps original case - expect((config as any).null?.val).toBeNull(); - }); - - it('should parse JSON values', () => { - process.env.JSON_ARRAY = '["a","b","c"]'; - process.env.JSON_OBJECT = '{"key":"value","num":123}'; - - loader = new EnvLoader(); - const config = loader.load(); - - // JSON values are parsed and nested - expect((config as any).json?.array).toEqual(['a', 'b', 'c']); - expect((config as any).json?.object).toEqual({ key: 'value', num: 123 }); - }); - - it('should disable parsing when parseValues is false', () => { - process.env.VALUE = 'true'; - - loader = new EnvLoader('', { parseValues: false, parseJson: false }); - const config = loader.load(); - - expect(config.VALUE).toBe('true'); // String, not boolean - }); - - it('should convert to camelCase when enabled', () => { - process.env.MY_VAR_NAME = 'value'; - process.env.ANOTHER_TEST_VAR = 'test'; - - loader = new EnvLoader('', { convertCase: true }); - const config = loader.load(); - - expect(config.myVarName).toBe('value'); - expect(config.anotherTestVar).toBe('test'); - }); - - it('should handle nested delimiter', () => { - process.env.APP__NAME = 'myapp'; - process.env.APP__CONFIG__PORT = '3000'; - - loader = new EnvLoader('', { nestedDelimiter: '__' }); - const config = loader.load(); - - expect(config).toEqual({ - APP: { - NAME: 'myapp', - CONFIG: { - PORT: 3000 - } - } - }); - }); - - it('should convert underscores to nested structure by default', () => { - process.env.DATABASE_HOST = 'localhost'; - process.env.DATABASE_PORT = '5432'; - process.env.DATABASE_CREDENTIALS_USER = 'admin'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect(config).toEqual({ - database: { - host: 'localhost', - port: 5432, - credentials: { - user: 'admin' - } - } - }); - }); - - it('should handle single keys without underscores', () => { - process.env.PORT = '3000'; - process.env.NAME = 'app'; - - loader = new EnvLoader(); - const config = loader.load(); - - // Single keys without underscores keep their original case - expect((config as any).PORT).toBe(3000); - // NAME has a special mapping to 'name' - expect((config as any).name).toBe('app'); - }); - }); - - describe('provider mappings', () => { - it('should map WebShare environment variables', () => { - process.env.WEBSHARE_API_KEY = 'secret-key'; - process.env.WEBSHARE_ENABLED = 'true'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect(config.webshare).toEqual({ - apiKey: 'secret-key', - enabled: true, - }); - }); - - it('should map EOD provider variables', () => { - process.env.EOD_API_KEY = 'eod-key'; - process.env.EOD_BASE_URL = 'https://api.eod.com'; - process.env.EOD_TIER = 'premium'; - process.env.EOD_ENABLED = 'true'; - process.env.EOD_PRIORITY = '1'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect(config.providers).toEqual({ - eod: { - apiKey: 'eod-key', - baseUrl: 'https://api.eod.com', - tier: 'premium', - enabled: true, - priority: 1, - }, - }); - }); - - it('should map Interactive Brokers variables', () => { - process.env.IB_GATEWAY_HOST = 'localhost'; - process.env.IB_GATEWAY_PORT = '7497'; - process.env.IB_CLIENT_ID = '1'; - process.env.IB_ENABLED = 'false'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect(config.providers).toEqual({ - ib: { - gateway: { - host: 'localhost', - port: 7497, - clientId: 1, - }, - enabled: false, - }, - }); - }); - - it('should map log configuration', () => { - process.env.LOG_LEVEL = 'debug'; - process.env.LOG_FORMAT = 'json'; - process.env.LOG_HIDE_OBJECT = 'true'; - process.env.LOG_LOKI_ENABLED = 'true'; - process.env.LOG_LOKI_HOST = 'loki.example.com'; - process.env.LOG_LOKI_PORT = '3100'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect(config.log).toEqual({ - level: 'debug', - format: 'json', - hideObject: true, - loki: { - enabled: true, - host: 'loki.example.com', - port: 3100, - }, - }); - }); - - it('should not apply provider mappings when prefix is set', () => { - process.env.APP_WEBSHARE_API_KEY = 'key'; - - loader = new EnvLoader('APP_'); - const config = loader.load(); - - // Should not map to webshare.apiKey, but still converts underscores to nested - expect((config as any).webshare?.api?.key).toBe('key'); - expect((config as any).webshare?.apiKey).toBeUndefined(); - }); - - it('should not apply provider mappings when convertCase is true', () => { - process.env.WEBSHARE_API_KEY = 'key'; - - loader = new EnvLoader('', { convertCase: true }); - const config = loader.load(); - - // Should convert to camelCase instead of mapping - expect(config.webshareApiKey).toBe('key'); - expect(config.webshare).toBeUndefined(); - }); - }); - - describe('loadEnvFile', () => { - it('should load .env file', () => { - const envContent = ` -# Comment line -TEST_VAR=value1 -ANOTHER_VAR="quoted value" -NUMBER_VAR=42 - -# Another comment -BOOL_VAR=true -`; - - (readFileSync as any).mockReturnValue(envContent); - - loader = new EnvLoader(); - const config = loader.load(); - - expect(process.env.TEST_VAR).toBe('value1'); - expect(process.env.ANOTHER_VAR).toBe('quoted value'); - expect((config as any).test?.var).toBe('value1'); - expect((config as any).another?.var).toBe('quoted value'); - expect((config as any).number?.var).toBe(42); - expect((config as any).bool?.var).toBe(true); - }); - - it('should handle single quoted values', () => { - const envContent = `VAR='single quoted'`; - (readFileSync as any).mockReturnValue(envContent); - - loader = new EnvLoader(); - loader.load(); - - expect(process.env.VAR).toBe('single quoted'); - }); - - it('should skip invalid lines', () => { - const envContent = ` -VALID=value -INVALID_LINE_WITHOUT_EQUALS -ANOTHER_VALID=value2 -=NO_KEY -KEY_WITHOUT_VALUE= -`; - - (readFileSync as any).mockReturnValue(envContent); - - loader = new EnvLoader(); - const config = loader.load(); - - expect((config as any).VALID).toBe('value'); - expect((config as any).another?.valid).toBe('value2'); - expect((config as any).key?.without?.value).toBe(''); // Empty string - }); - - it('should not override existing environment variables', () => { - process.env.EXISTING = 'original'; - - const envContent = `EXISTING=from_file`; - (readFileSync as any).mockReturnValue(envContent); - - loader = new EnvLoader(); - loader.load(); - - expect(process.env.EXISTING).toBe('original'); - }); - - it('should handle file not found gracefully', () => { - (readFileSync as any).mockImplementation(() => { - const error: any = new Error('File not found'); - error.code = 'ENOENT'; - throw error; - }); - - loader = new EnvLoader(); - // Should not throw - expect(() => loader.load()).not.toThrow(); - }); - - it('should warn on other file errors', () => { - const consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {}); - - (readFileSync as any).mockImplementation(() => { - const error: any = new Error('Permission denied'); - error.code = 'EACCES'; - throw error; - }); - - loader = new EnvLoader(); - loader.load(); - - expect(consoleWarnSpy).toHaveBeenCalled(); - }); - - it('should try multiple env file paths', () => { - const readFileSpy = readFileSync as any; - readFileSpy.mockImplementation((path: string) => { - if (path === '../../.env') { - return 'FOUND=true'; - } - const error: any = new Error('Not found'); - error.code = 'ENOENT'; - throw error; - }); - - loader = new EnvLoader(); - const config = loader.load(); - - expect(readFileSpy).toHaveBeenCalledWith('./.env', 'utf-8'); - expect(readFileSpy).toHaveBeenCalledWith('../.env', 'utf-8'); - expect(readFileSpy).toHaveBeenCalledWith('../../.env', 'utf-8'); - expect((config as any).FOUND).toBe(true); - }); - }); - - describe('edge cases', () => { - it('should handle empty values', () => { - process.env.EMPTY = ''; - - loader = new EnvLoader(); - const config = loader.load(); - - expect((config as any).EMPTY).toBe(''); - }); - - it('should handle very long values', () => { - const longValue = 'a'.repeat(10000); - process.env.LONG = longValue; - - loader = new EnvLoader(); - const config = loader.load(); - - expect((config as any).LONG).toBe(longValue); - }); - - it('should handle special characters in values', () => { - process.env.SPECIAL = '!@#$%^&*()_+-=[]{}|;:,.<>?'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect((config as any).SPECIAL).toBe('!@#$%^&*()_+-=[]{}|;:,.<>?'); - }); - - it('should handle readonly properties gracefully', () => { - // Simulate readonly property scenario - const config = { readonly: 'original' }; - Object.defineProperty(config, 'readonly', { - writable: false, - configurable: false - }); - - process.env.READONLY = 'new_value'; - - loader = new EnvLoader(); - // Should not throw when trying to set readonly properties - expect(() => loader.load()).not.toThrow(); - }); - - it('should parse undefined string as undefined', () => { - process.env.UNDEF = 'undefined'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect((config as any).UNDEF).toBeUndefined(); - }); - - it('should handle number-like strings that should remain strings', () => { - process.env.ZIP_CODE = '00123'; // Leading zeros - process.env.PHONE = '+1234567890'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect((config as any).zip?.code).toBe('00123'); // Should remain string - expect((config as any).PHONE).toBe('+1234567890'); // Should remain string - }); - - it('should handle deeply nested structures', () => { - process.env.A_B_C_D_E_F = 'deep'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect(config.a).toEqual({ - b: { - c: { - d: { - e: { - f: 'deep' - } - } - } - } - }); - }); - - it('should throw ConfigLoaderError on unexpected error', () => { - // Mock an error during load - const originalEntries = Object.entries; - Object.entries = () => { - throw new Error('Unexpected error'); - }; - - loader = new EnvLoader(); - - try { - expect(() => loader.load()).toThrow(ConfigLoaderError); - expect(() => loader.load()).toThrow('Failed to load environment variables'); - } finally { - Object.entries = originalEntries; - } - }); - - it('should handle empty path in setNestedValue', () => { - loader = new EnvLoader(); - const config = {}; - - // Test private method indirectly by setting an env var with special key - process.env.EMPTY_PATH_TEST = 'value'; - - // Force an empty path scenario through provider mapping - const privateLoader = loader as any; - const result = privateLoader.setNestedValue(config, [], 'value'); - - expect(result).toBe(false); - }); - - it('should handle QuoteMedia provider mappings', () => { - process.env.QM_USERNAME = 'testuser'; - process.env.QM_PASSWORD = 'testpass'; - process.env.QM_BASE_URL = 'https://api.quotemedia.com'; - process.env.QM_WEBMASTER_ID = '12345'; - process.env.QM_ENABLED = 'true'; - process.env.QM_PRIORITY = '5'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect(config.providers).toEqual(expect.objectContaining({ - qm: { - username: 'testuser', - password: 'testpass', - baseUrl: 'https://api.quotemedia.com', - webmasterId: '12345', - enabled: true, - priority: 5, - }, - })); - }); - - it('should handle Yahoo Finance provider mappings', () => { - process.env.YAHOO_BASE_URL = 'https://finance.yahoo.com'; - process.env.YAHOO_COOKIE_JAR = '/path/to/cookies'; - process.env.YAHOO_CRUMB = 'abc123'; - process.env.YAHOO_ENABLED = 'false'; - process.env.YAHOO_PRIORITY = '10'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect(config.providers).toEqual(expect.objectContaining({ - yahoo: { - baseUrl: 'https://finance.yahoo.com', - cookieJar: '/path/to/cookies', - crumb: 'abc123', - enabled: false, - priority: 10, - }, - })); - }); - - it('should handle additional provider mappings', () => { - process.env.WEBSHARE_API_URL = 'https://api.webshare.io'; - process.env.IB_ACCOUNT = 'DU123456'; - process.env.IB_MARKET_DATA_TYPE = '1'; - process.env.IB_PRIORITY = '3'; - process.env.VERSION = '1.2.3'; - process.env.DEBUG_MODE = 'true'; - - loader = new EnvLoader(); - const config = loader.load(); - - expect(config.webshare).toEqual(expect.objectContaining({ - apiUrl: 'https://api.webshare.io', - })); - expect(config.providers?.ib).toEqual(expect.objectContaining({ - account: 'DU123456', - marketDataType: '1', - priority: 3, - })); - expect(config.version).toBe('1.2.3'); - expect(config.debug).toBe(true); - }); - - it('should handle all .env file paths exhausted', () => { - const readFileSpy = readFileSync as any; - readFileSpy.mockImplementation((path: string) => { - const error: any = new Error('Not found'); - error.code = 'ENOENT'; - throw error; - }); - - loader = new EnvLoader(); - const config = loader.load(); - - // Should try all paths - expect(readFileSpy).toHaveBeenCalledWith('./.env', 'utf-8'); - expect(readFileSpy).toHaveBeenCalledWith('../.env', 'utf-8'); - expect(readFileSpy).toHaveBeenCalledWith('../../.env', 'utf-8'); - expect(readFileSpy).toHaveBeenCalledWith('../../../.env', 'utf-8'); - - // Should return empty config when no env files found - expect(config).toEqual({}); - }); - - it('should handle key without equals in env file', () => { - const envContent = `KEY_WITHOUT_EQUALS`; - (readFileSync as any).mockReturnValue(envContent); - - loader = new EnvLoader(); - const config = loader.load(); - - // Should skip lines without equals - expect(Object.keys(config).length).toBe(0); - }); - - it('should handle nested structure with existing non-object value', () => { - process.env.CONFIG = 'string_value'; - process.env.CONFIG_NESTED = 'nested_value'; - - loader = new EnvLoader(); - const config = loader.load(); - - // CONFIG should be an object with nested value - expect((config as any).config).toEqual({ - nested: 'nested_value' - }); - }); - - it('should skip setNestedValue when path reduction fails', () => { - // Create a scenario where the reduce operation would fail - const testConfig: any = {}; - Object.defineProperty(testConfig, 'protected', { - value: 'immutable', - writable: false, - configurable: false - }); - - process.env.PROTECTED_NESTED_VALUE = 'test'; - - loader = new EnvLoader(); - // Should not throw, but skip the problematic variable - expect(() => loader.load()).not.toThrow(); - }); - }); -}); \ No newline at end of file +import { readFileSync } from 'fs'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { ConfigLoaderError } from '../src/errors'; +import { EnvLoader } from '../src/loaders/env.loader'; + +// Mock fs module +mock.module('fs', () => ({ + readFileSync: mock(() => ''), +})); + +describe('EnvLoader', () => { + let loader: EnvLoader; + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear environment + for (const key in process.env) { + delete process.env[key]; + } + }); + + afterEach(() => { + // Restore original environment + for (const key in process.env) { + delete process.env[key]; + } + Object.assign(process.env, originalEnv); + }); + + describe('constructor', () => { + it('should have highest priority', () => { + loader = new EnvLoader(); + expect(loader.priority).toBe(100); + }); + + it('should accept prefix and options', () => { + loader = new EnvLoader('APP_', { + convertCase: true, + parseJson: false, + }); + expect(loader).toBeDefined(); + }); + }); + + describe('load', () => { + it('should load environment variables without prefix', () => { + process.env.TEST_VAR = 'test_value'; + process.env.ANOTHER_VAR = 'another_value'; + + loader = new EnvLoader(); + const config = loader.load(); + + // Environment variables with underscores are converted to nested structure + interface ExpectedConfig { + test?: { var: string }; + another?: { var: string }; + } + expect((config as ExpectedConfig).test?.var).toBe('test_value'); + expect((config as ExpectedConfig).another?.var).toBe('another_value'); + }); + + it('should filter by prefix', () => { + process.env.APP_NAME = 'myapp'; + process.env.APP_VERSION = '1.0.0'; + process.env.OTHER_VAR = 'ignored'; + + loader = new EnvLoader('APP_'); + const config = loader.load(); + + expect(config.NAME).toBe('myapp'); + expect(config.VERSION).toBe('1.0.0'); + expect(config.OTHER_VAR).toBeUndefined(); + }); + + it('should parse values by default', () => { + process.env.BOOL_TRUE = 'true'; + process.env.BOOL_FALSE = 'false'; + process.env.NUMBER = '42'; + process.env.STRING = 'hello'; + process.env.NULL_VAL = 'null'; + + loader = new EnvLoader(); + const config = loader.load(); + + // Values are nested based on underscores + expect((config as any).bool?.true).toBe(true); + expect((config as any).bool?.false).toBe(false); + expect((config as any).NUMBER).toBe(42); // No underscore, keeps original case + expect((config as any).STRING).toBe('hello'); // No underscore, keeps original case + expect((config as any).null?.val).toBeNull(); + }); + + it('should parse JSON values', () => { + process.env.JSON_ARRAY = '["a","b","c"]'; + process.env.JSON_OBJECT = '{"key":"value","num":123}'; + + loader = new EnvLoader(); + const config = loader.load(); + + // JSON values are parsed and nested + expect((config as any).json?.array).toEqual(['a', 'b', 'c']); + expect((config as any).json?.object).toEqual({ key: 'value', num: 123 }); + }); + + it('should disable parsing when parseValues is false', () => { + process.env.VALUE = 'true'; + + loader = new EnvLoader('', { parseValues: false, parseJson: false }); + const config = loader.load(); + + expect(config.VALUE).toBe('true'); // String, not boolean + }); + + it('should convert to camelCase when enabled', () => { + process.env.MY_VAR_NAME = 'value'; + process.env.ANOTHER_TEST_VAR = 'test'; + + loader = new EnvLoader('', { convertCase: true }); + const config = loader.load(); + + expect(config.myVarName).toBe('value'); + expect(config.anotherTestVar).toBe('test'); + }); + + it('should handle nested delimiter', () => { + process.env.APP__NAME = 'myapp'; + process.env.APP__CONFIG__PORT = '3000'; + + loader = new EnvLoader('', { nestedDelimiter: '__' }); + const config = loader.load(); + + expect(config).toEqual({ + APP: { + NAME: 'myapp', + CONFIG: { + PORT: 3000, + }, + }, + }); + }); + + it('should convert underscores to nested structure by default', () => { + process.env.DATABASE_HOST = 'localhost'; + process.env.DATABASE_PORT = '5432'; + process.env.DATABASE_CREDENTIALS_USER = 'admin'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect(config).toEqual({ + database: { + host: 'localhost', + port: 5432, + credentials: { + user: 'admin', + }, + }, + }); + }); + + it('should handle single keys without underscores', () => { + process.env.PORT = '3000'; + process.env.NAME = 'app'; + + loader = new EnvLoader(); + const config = loader.load(); + + // Single keys without underscores keep their original case + expect((config as any).PORT).toBe(3000); + // NAME has a special mapping to 'name' + expect((config as any).name).toBe('app'); + }); + }); + + describe('provider mappings', () => { + it('should map WebShare environment variables', () => { + process.env.WEBSHARE_API_KEY = 'secret-key'; + process.env.WEBSHARE_ENABLED = 'true'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect(config.webshare).toEqual({ + apiKey: 'secret-key', + enabled: true, + }); + }); + + it('should map EOD provider variables', () => { + process.env.EOD_API_KEY = 'eod-key'; + process.env.EOD_BASE_URL = 'https://api.eod.com'; + process.env.EOD_TIER = 'premium'; + process.env.EOD_ENABLED = 'true'; + process.env.EOD_PRIORITY = '1'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect(config.providers).toEqual({ + eod: { + apiKey: 'eod-key', + baseUrl: 'https://api.eod.com', + tier: 'premium', + enabled: true, + priority: 1, + }, + }); + }); + + it('should map Interactive Brokers variables', () => { + process.env.IB_GATEWAY_HOST = 'localhost'; + process.env.IB_GATEWAY_PORT = '7497'; + process.env.IB_CLIENT_ID = '1'; + process.env.IB_ENABLED = 'false'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect(config.providers).toEqual({ + ib: { + gateway: { + host: 'localhost', + port: 7497, + clientId: 1, + }, + enabled: false, + }, + }); + }); + + it('should map log configuration', () => { + process.env.LOG_LEVEL = 'debug'; + process.env.LOG_FORMAT = 'json'; + process.env.LOG_HIDE_OBJECT = 'true'; + process.env.LOG_LOKI_ENABLED = 'true'; + process.env.LOG_LOKI_HOST = 'loki.example.com'; + process.env.LOG_LOKI_PORT = '3100'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect(config.log).toEqual({ + level: 'debug', + format: 'json', + hideObject: true, + loki: { + enabled: true, + host: 'loki.example.com', + port: 3100, + }, + }); + }); + + it('should not apply provider mappings when prefix is set', () => { + process.env.APP_WEBSHARE_API_KEY = 'key'; + + loader = new EnvLoader('APP_'); + const config = loader.load(); + + // Should not map to webshare.apiKey, but still converts underscores to nested + expect((config as any).webshare?.api?.key).toBe('key'); + expect((config as any).webshare?.apiKey).toBeUndefined(); + }); + + it('should not apply provider mappings when convertCase is true', () => { + process.env.WEBSHARE_API_KEY = 'key'; + + loader = new EnvLoader('', { convertCase: true }); + const config = loader.load(); + + // Should convert to camelCase instead of mapping + expect(config.webshareApiKey).toBe('key'); + expect(config.webshare).toBeUndefined(); + }); + }); + + describe('loadEnvFile', () => { + it('should load .env file', () => { + const envContent = ` +# Comment line +TEST_VAR=value1 +ANOTHER_VAR="quoted value" +NUMBER_VAR=42 + +# Another comment +BOOL_VAR=true +`; + + (readFileSync as any).mockReturnValue(envContent); + + loader = new EnvLoader(); + const config = loader.load(); + + expect(process.env.TEST_VAR).toBe('value1'); + expect(process.env.ANOTHER_VAR).toBe('quoted value'); + expect((config as any).test?.var).toBe('value1'); + expect((config as any).another?.var).toBe('quoted value'); + expect((config as any).number?.var).toBe(42); + expect((config as any).bool?.var).toBe(true); + }); + + it('should handle single quoted values', () => { + const envContent = `VAR='single quoted'`; + (readFileSync as any).mockReturnValue(envContent); + + loader = new EnvLoader(); + loader.load(); + + expect(process.env.VAR).toBe('single quoted'); + }); + + it('should skip invalid lines', () => { + const envContent = ` +VALID=value +INVALID_LINE_WITHOUT_EQUALS +ANOTHER_VALID=value2 +=NO_KEY +KEY_WITHOUT_VALUE= +`; + + (readFileSync as any).mockReturnValue(envContent); + + loader = new EnvLoader(); + const config = loader.load(); + + expect((config as any).VALID).toBe('value'); + expect((config as any).another?.valid).toBe('value2'); + expect((config as any).key?.without?.value).toBe(''); // Empty string + }); + + it('should not override existing environment variables', () => { + process.env.EXISTING = 'original'; + + const envContent = `EXISTING=from_file`; + (readFileSync as any).mockReturnValue(envContent); + + loader = new EnvLoader(); + loader.load(); + + expect(process.env.EXISTING).toBe('original'); + }); + + it('should handle file not found gracefully', () => { + (readFileSync as any).mockImplementation(() => { + const error: any = new Error('File not found'); + error.code = 'ENOENT'; + throw error; + }); + + loader = new EnvLoader(); + // Should not throw + expect(() => loader.load()).not.toThrow(); + }); + + it('should warn on other file errors', () => { + const consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + (readFileSync as any).mockImplementation(() => { + const error: any = new Error('Permission denied'); + error.code = 'EACCES'; + throw error; + }); + + loader = new EnvLoader(); + loader.load(); + + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + it('should try multiple env file paths', () => { + const readFileSpy = readFileSync as any; + readFileSpy.mockImplementation((path: string) => { + if (path === '../../.env') { + return 'FOUND=true'; + } + const error: any = new Error('Not found'); + error.code = 'ENOENT'; + throw error; + }); + + loader = new EnvLoader(); + const config = loader.load(); + + expect(readFileSpy).toHaveBeenCalledWith('./.env', 'utf-8'); + expect(readFileSpy).toHaveBeenCalledWith('../.env', 'utf-8'); + expect(readFileSpy).toHaveBeenCalledWith('../../.env', 'utf-8'); + expect((config as any).FOUND).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle empty values', () => { + process.env.EMPTY = ''; + + loader = new EnvLoader(); + const config = loader.load(); + + expect((config as any).EMPTY).toBe(''); + }); + + it('should handle very long values', () => { + const longValue = 'a'.repeat(10000); + process.env.LONG = longValue; + + loader = new EnvLoader(); + const config = loader.load(); + + expect((config as any).LONG).toBe(longValue); + }); + + it('should handle special characters in values', () => { + process.env.SPECIAL = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect((config as any).SPECIAL).toBe('!@#$%^&*()_+-=[]{}|;:,.<>?'); + }); + + it('should handle readonly properties gracefully', () => { + // Simulate readonly property scenario + const config = { readonly: 'original' }; + Object.defineProperty(config, 'readonly', { + writable: false, + configurable: false, + }); + + process.env.READONLY = 'new_value'; + + loader = new EnvLoader(); + // Should not throw when trying to set readonly properties + expect(() => loader.load()).not.toThrow(); + }); + + it('should parse undefined string as undefined', () => { + process.env.UNDEF = 'undefined'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect((config as any).UNDEF).toBeUndefined(); + }); + + it('should handle number-like strings that should remain strings', () => { + process.env.ZIP_CODE = '00123'; // Leading zeros + process.env.PHONE = '+1234567890'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect((config as any).zip?.code).toBe('00123'); // Should remain string + expect((config as any).PHONE).toBe('+1234567890'); // Should remain string + }); + + it('should handle deeply nested structures', () => { + process.env.A_B_C_D_E_F = 'deep'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect(config.a).toEqual({ + b: { + c: { + d: { + e: { + f: 'deep', + }, + }, + }, + }, + }); + }); + + it('should throw ConfigLoaderError on unexpected error', () => { + // Mock an error during load + const originalEntries = Object.entries; + Object.entries = () => { + throw new Error('Unexpected error'); + }; + + loader = new EnvLoader(); + + try { + expect(() => loader.load()).toThrow(ConfigLoaderError); + expect(() => loader.load()).toThrow('Failed to load environment variables'); + } finally { + Object.entries = originalEntries; + } + }); + + it('should handle empty path in setNestedValue', () => { + loader = new EnvLoader(); + const config = {}; + + // Test private method indirectly by setting an env var with special key + process.env.EMPTY_PATH_TEST = 'value'; + + // Force an empty path scenario through provider mapping + const privateLoader = loader as any; + const result = privateLoader.setNestedValue(config, [], 'value'); + + expect(result).toBe(false); + }); + + it('should handle QuoteMedia provider mappings', () => { + process.env.QM_USERNAME = 'testuser'; + process.env.QM_PASSWORD = 'testpass'; + process.env.QM_BASE_URL = 'https://api.quotemedia.com'; + process.env.QM_WEBMASTER_ID = '12345'; + process.env.QM_ENABLED = 'true'; + process.env.QM_PRIORITY = '5'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect(config.providers).toEqual( + expect.objectContaining({ + qm: { + username: 'testuser', + password: 'testpass', + baseUrl: 'https://api.quotemedia.com', + webmasterId: '12345', + enabled: true, + priority: 5, + }, + }) + ); + }); + + it('should handle Yahoo Finance provider mappings', () => { + process.env.YAHOO_BASE_URL = 'https://finance.yahoo.com'; + process.env.YAHOO_COOKIE_JAR = '/path/to/cookies'; + process.env.YAHOO_CRUMB = 'abc123'; + process.env.YAHOO_ENABLED = 'false'; + process.env.YAHOO_PRIORITY = '10'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect(config.providers).toEqual( + expect.objectContaining({ + yahoo: { + baseUrl: 'https://finance.yahoo.com', + cookieJar: '/path/to/cookies', + crumb: 'abc123', + enabled: false, + priority: 10, + }, + }) + ); + }); + + it('should handle additional provider mappings', () => { + process.env.WEBSHARE_API_URL = 'https://api.webshare.io'; + process.env.IB_ACCOUNT = 'DU123456'; + process.env.IB_MARKET_DATA_TYPE = '1'; + process.env.IB_PRIORITY = '3'; + process.env.VERSION = '1.2.3'; + process.env.DEBUG_MODE = 'true'; + + loader = new EnvLoader(); + const config = loader.load(); + + expect(config.webshare).toEqual( + expect.objectContaining({ + apiUrl: 'https://api.webshare.io', + }) + ); + expect(config.providers?.ib).toEqual( + expect.objectContaining({ + account: 'DU123456', + marketDataType: '1', + priority: 3, + }) + ); + expect(config.version).toBe('1.2.3'); + expect(config.debug).toBe(true); + }); + + it('should handle all .env file paths exhausted', () => { + const readFileSpy = readFileSync as any; + readFileSpy.mockImplementation((path: string) => { + const error: any = new Error('Not found'); + error.code = 'ENOENT'; + throw error; + }); + + loader = new EnvLoader(); + const config = loader.load(); + + // Should try all paths + expect(readFileSpy).toHaveBeenCalledWith('./.env', 'utf-8'); + expect(readFileSpy).toHaveBeenCalledWith('../.env', 'utf-8'); + expect(readFileSpy).toHaveBeenCalledWith('../../.env', 'utf-8'); + expect(readFileSpy).toHaveBeenCalledWith('../../../.env', 'utf-8'); + + // Should return empty config when no env files found + expect(config).toEqual({}); + }); + + it('should handle key without equals in env file', () => { + const envContent = `KEY_WITHOUT_EQUALS`; + (readFileSync as any).mockReturnValue(envContent); + + loader = new EnvLoader(); + const config = loader.load(); + + // Should skip lines without equals + expect(Object.keys(config).length).toBe(0); + }); + + it('should handle nested structure with existing non-object value', () => { + process.env.CONFIG = 'string_value'; + process.env.CONFIG_NESTED = 'nested_value'; + + loader = new EnvLoader(); + const config = loader.load(); + + // CONFIG should be an object with nested value + expect((config as any).config).toEqual({ + nested: 'nested_value', + }); + }); + + it('should skip setNestedValue when path reduction fails', () => { + // Create a scenario where the reduce operation would fail + const testConfig: any = {}; + Object.defineProperty(testConfig, 'protected', { + value: 'immutable', + writable: false, + configurable: false, + }); + + process.env.PROTECTED_NESTED_VALUE = 'test'; + + loader = new EnvLoader(); + // Should not throw, but skip the problematic variable + expect(() => loader.load()).not.toThrow(); + }); + }); +}); diff --git a/libs/core/config/test/file.loader.test.ts b/libs/core/config/test/file.loader.test.ts index a600a6d..798db18 100644 --- a/libs/core/config/test/file.loader.test.ts +++ b/libs/core/config/test/file.loader.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; import { existsSync, readFileSync } from 'fs'; -import { FileLoader } from '../src/loaders/file.loader'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import { ConfigLoaderError } from '../src/errors'; +import { FileLoader } from '../src/loaders/file.loader'; // Mock fs module mock.module('fs', () => ({ existsSync: mock(() => false), - readFileSync: mock(() => '') + readFileSync: mock(() => ''), })); describe('FileLoader', () => { @@ -433,4 +433,4 @@ describe('FileLoader', () => { expect(result).toEqual(config); }); }); -}); \ No newline at end of file +}); diff --git a/libs/core/config/test/schemas.test.ts b/libs/core/config/test/schemas.test.ts index 27dd9bf..fed0753 100644 --- a/libs/core/config/test/schemas.test.ts +++ b/libs/core/config/test/schemas.test.ts @@ -1,27 +1,27 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { z } from 'zod'; import { baseConfigSchema, - environmentSchema, - serviceConfigSchema, - loggingConfigSchema, - queueConfigSchema, - httpConfigSchema, - webshareConfigSchema, - browserConfigSchema, - proxyConfigSchema, - postgresConfigSchema, - questdbConfigSchema, - mongodbConfigSchema, - dragonflyConfigSchema, - databaseConfigSchema, baseProviderConfigSchema, + browserConfigSchema, + databaseConfigSchema, + dragonflyConfigSchema, + environmentSchema, eodProviderConfigSchema, + httpConfigSchema, ibProviderConfigSchema, - qmProviderConfigSchema, - yahooProviderConfigSchema, - webshareProviderConfigSchema, + loggingConfigSchema, + mongodbConfigSchema, + postgresConfigSchema, providerConfigSchema, + proxyConfigSchema, + qmProviderConfigSchema, + questdbConfigSchema, + queueConfigSchema, + serviceConfigSchema, + webshareConfigSchema, + webshareProviderConfigSchema, + yahooProviderConfigSchema, } from '../src/schemas'; describe('Config Schemas', () => { @@ -202,7 +202,7 @@ describe('Config Schemas', () => { describe('queueConfigSchema', () => { it('should accept minimal config with defaults', () => { const config = queueConfigSchema.parse({ - redis: {}, // redis is required, but its properties have defaults + redis: {}, // redis is required, but its properties have defaults }); expect(config).toEqual({ enabled: true, @@ -421,7 +421,7 @@ describe('Config Schemas', () => { // Empty strings are allowed by z.string() unless .min(1) is specified const serviceConfig = serviceConfigSchema.parse({ name: '', port: 3000 }); expect(serviceConfig.name).toBe(''); - + const baseConfig = baseConfigSchema.parse({ name: '' }); expect(baseConfig.name).toBe(''); }); @@ -493,19 +493,23 @@ describe('Config Schemas', () => { }); it('should validate poolSize range', () => { - expect(() => postgresConfigSchema.parse({ - database: 'testdb', - user: 'testuser', - password: 'testpass', - poolSize: 0, - })).toThrow(); - - expect(() => postgresConfigSchema.parse({ - database: 'testdb', - user: 'testuser', - password: 'testpass', - poolSize: 101, - })).toThrow(); + expect(() => + postgresConfigSchema.parse({ + database: 'testdb', + user: 'testuser', + password: 'testpass', + poolSize: 0, + }) + ).toThrow(); + + expect(() => + postgresConfigSchema.parse({ + database: 'testdb', + user: 'testuser', + password: 'testpass', + poolSize: 101, + }) + ).toThrow(); }); }); @@ -574,24 +578,30 @@ describe('Config Schemas', () => { }); it('should validate URI format', () => { - expect(() => mongodbConfigSchema.parse({ - uri: 'invalid-uri', - database: 'testdb', - })).toThrow(); + expect(() => + mongodbConfigSchema.parse({ + uri: 'invalid-uri', + database: 'testdb', + }) + ).toThrow(); }); it('should validate poolSize range', () => { - expect(() => mongodbConfigSchema.parse({ - uri: 'mongodb://localhost', - database: 'testdb', - poolSize: 0, - })).toThrow(); - - expect(() => mongodbConfigSchema.parse({ - uri: 'mongodb://localhost', - database: 'testdb', - poolSize: 101, - })).toThrow(); + expect(() => + mongodbConfigSchema.parse({ + uri: 'mongodb://localhost', + database: 'testdb', + poolSize: 0, + }) + ).toThrow(); + + expect(() => + mongodbConfigSchema.parse({ + uri: 'mongodb://localhost', + database: 'testdb', + poolSize: 101, + }) + ).toThrow(); }); }); @@ -645,7 +655,7 @@ describe('Config Schemas', () => { }, dragonfly: {}, }); - + expect(config.postgres.host).toBe('localhost'); expect(config.questdb.enabled).toBe(true); expect(config.mongodb.poolSize).toBe(10); @@ -703,11 +713,13 @@ describe('Config Schemas', () => { }); it('should validate tier values', () => { - expect(() => eodProviderConfigSchema.parse({ - name: 'eod', - apiKey: 'test-key', - tier: 'premium', - })).toThrow(); + expect(() => + eodProviderConfigSchema.parse({ + name: 'eod', + apiKey: 'test-key', + tier: 'premium', + }) + ).toThrow(); const validTiers = ['free', 'fundamentals', 'all-in-one']; for (const tier of validTiers) { @@ -759,10 +771,12 @@ describe('Config Schemas', () => { }); it('should validate marketDataType', () => { - expect(() => ibProviderConfigSchema.parse({ - name: 'ib', - marketDataType: 'realtime', - })).toThrow(); + expect(() => + ibProviderConfigSchema.parse({ + name: 'ib', + marketDataType: 'realtime', + }) + ).toThrow(); const validTypes = ['live', 'delayed', 'frozen']; for (const type of validTypes) { @@ -777,9 +791,11 @@ describe('Config Schemas', () => { describe('qmProviderConfigSchema', () => { it('should require all credentials', () => { - expect(() => qmProviderConfigSchema.parse({ - name: 'qm', - })).toThrow(); + expect(() => + qmProviderConfigSchema.parse({ + name: 'qm', + }) + ).toThrow(); const config = qmProviderConfigSchema.parse({ name: 'qm', @@ -885,7 +901,7 @@ describe('Config Schemas', () => { apiKey: 'ws-key', }, }); - + expect(config.eod?.tier).toBe('all-in-one'); expect(config.ib?.gateway.port).toBe(7497); expect(config.qm?.username).toBe('user'); @@ -893,4 +909,4 @@ describe('Config Schemas', () => { expect(config.webshare?.apiKey).toBe('ws-key'); }); }); -}); \ No newline at end of file +}); diff --git a/libs/core/config/test/utils.test.ts b/libs/core/config/test/utils.test.ts index 7cb4193..5cd8c56 100644 --- a/libs/core/config/test/utils.test.ts +++ b/libs/core/config/test/utils.test.ts @@ -1,21 +1,21 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { z } from 'zod'; import { - SecretValue, - secret, + checkRequiredEnvVars, + COMMON_SECRET_PATTERNS, + createStrictSchema, + formatValidationResult, isSecret, - redactSecrets, isSecretEnvVar, - wrapSecretEnvVars, + mergeSchemas, + redactSecrets, + secret, secretSchema, secretStringSchema, - COMMON_SECRET_PATTERNS, - validateConfig, - checkRequiredEnvVars, + SecretValue, validateCompleteness, - formatValidationResult, - createStrictSchema, - mergeSchemas, + validateConfig, + wrapSecretEnvVars, type ValidationResult, } from '../src'; @@ -100,7 +100,7 @@ describe('Config Utils', () => { it('should validate SecretValue instances', () => { const schema = secretSchema(z.string()); const secretVal = new SecretValue('test'); - + expect(() => schema.parse(secretVal)).not.toThrow(); expect(() => schema.parse('test')).toThrow(); expect(() => schema.parse(null)).toThrow(); @@ -132,7 +132,7 @@ describe('Config Utils', () => { }; const redacted = redactSecrets(obj, ['password', 'nested.apiKey']); - + expect(redacted).toEqual({ username: 'admin', password: '***REDACTED***', @@ -153,7 +153,7 @@ describe('Config Utils', () => { }; const redacted = redactSecrets(obj); - + expect(redacted).toEqual({ normal: 'value', secret: 'MASKED', @@ -172,7 +172,7 @@ describe('Config Utils', () => { }; const redacted = redactSecrets(obj); - + expect(redacted.items).toEqual([ { name: 'item1', secret: '***' }, { name: 'item2', secret: '***' }, @@ -187,7 +187,7 @@ describe('Config Utils', () => { }; const redacted = redactSecrets(obj); - + expect(redacted).toEqual({ nullValue: null, undefinedValue: undefined, @@ -240,13 +240,13 @@ describe('Config Utils', () => { }; const wrapped = wrapSecretEnvVars(env); - + expect(wrapped.USERNAME).toBe('admin'); expect(wrapped.PORT).toBe('3000'); - + expect(isSecret(wrapped.PASSWORD)).toBe(true); expect(isSecret(wrapped.API_KEY)).toBe(true); - + const passwordSecret = wrapped.PASSWORD as SecretValue; expect(passwordSecret.reveal('test')).toBe('secret123'); expect(passwordSecret.toString()).toBe('***PASSWORD***'); @@ -259,7 +259,7 @@ describe('Config Utils', () => { }; const wrapped = wrapSecretEnvVars(env); - + expect(wrapped.PASSWORD).toBeUndefined(); expect(wrapped.USERNAME).toBe('admin'); }); @@ -443,9 +443,7 @@ describe('Config Utils', () => { it('should format warnings', () => { const result: ValidationResult = { valid: true, - warnings: [ - { path: 'deprecated.feature', message: 'This feature is deprecated' }, - ], + warnings: [{ path: 'deprecated.feature', message: 'This feature is deprecated' }], }; const formatted = formatValidationResult(result); @@ -499,7 +497,7 @@ describe('Config Utils', () => { const schema2 = z.object({ b: z.number(), shared: z.string() }); const merged = mergeSchemas(schema1, schema2); - + // Both schemas require 'shared' to be a string expect(() => merged.parse({ a: 'test', b: 123, shared: 'value' })).not.toThrow(); expect(() => merged.parse({ a: 'test', b: 123, shared: 123 })).toThrow(); @@ -510,10 +508,10 @@ describe('Config Utils', () => { it('should be an array of RegExp', () => { expect(Array.isArray(COMMON_SECRET_PATTERNS)).toBe(true); expect(COMMON_SECRET_PATTERNS.length).toBeGreaterThan(0); - + for (const pattern of COMMON_SECRET_PATTERNS) { expect(pattern).toBeInstanceOf(RegExp); } }); }); -}); \ No newline at end of file +}); diff --git a/libs/core/di/src/index.ts b/libs/core/di/src/index.ts index 417188e..3eff082 100644 --- a/libs/core/di/src/index.ts +++ b/libs/core/di/src/index.ts @@ -1,3 +1,3 @@ // Export only what's actually used export { ServiceApplication } from './service-application'; -export { ServiceContainerBuilder } from './container/builder'; \ No newline at end of file +export { ServiceContainerBuilder } from './container/builder'; diff --git a/libs/core/di/src/service-application.ts b/libs/core/di/src/service-application.ts index 3c25cfc..01eef56 100644 --- a/libs/core/di/src/service-application.ts +++ b/libs/core/di/src/service-application.ts @@ -166,82 +166,102 @@ export class ServiceApplication { private registerShutdownHandlers(): void { // Priority 1: Queue system (highest priority) if (this.serviceConfig.enableScheduledJobs) { - this.shutdown.onShutdown(async () => { - this.logger.info('Shutting down queue system...'); - try { - const queueManager = this.container?.resolve('queueManager'); - if (queueManager) { - await queueManager.shutdown(); + this.shutdown.onShutdown( + async () => { + this.logger.info('Shutting down queue system...'); + try { + const queueManager = this.container?.resolve('queueManager'); + if (queueManager) { + await queueManager.shutdown(); + } + this.logger.info('Queue system shut down'); + } catch (error) { + this.logger.error('Error shutting down queue system', { error }); } - this.logger.info('Queue system shut down'); - } catch (error) { - this.logger.error('Error shutting down queue system', { error }); - } - }, SHUTDOWN_DEFAULTS.HIGH_PRIORITY, 'Queue System'); + }, + SHUTDOWN_DEFAULTS.HIGH_PRIORITY, + 'Queue System' + ); } // Priority 1: HTTP Server (high priority) - this.shutdown.onShutdown(async () => { - if (this.server) { - this.logger.info('Stopping HTTP server...'); - try { - this.server.stop(); - this.logger.info('HTTP server stopped'); - } catch (error) { - this.logger.error('Error stopping HTTP server', { error }); + this.shutdown.onShutdown( + async () => { + if (this.server) { + this.logger.info('Stopping HTTP server...'); + try { + this.server.stop(); + this.logger.info('HTTP server stopped'); + } catch (error) { + this.logger.error('Error stopping HTTP server', { error }); + } } - } - }, SHUTDOWN_DEFAULTS.HIGH_PRIORITY, 'HTTP Server'); + }, + SHUTDOWN_DEFAULTS.HIGH_PRIORITY, + 'HTTP Server' + ); // Custom shutdown hook if (this.hooks.onBeforeShutdown) { - this.shutdown.onShutdown(async () => { - try { - await this.hooks.onBeforeShutdown!(); - } catch (error) { - this.logger.error('Error in custom shutdown hook', { error }); - } - }, SHUTDOWN_DEFAULTS.HIGH_PRIORITY, 'Custom Shutdown'); + this.shutdown.onShutdown( + async () => { + try { + await this.hooks.onBeforeShutdown!(); + } catch (error) { + this.logger.error('Error in custom shutdown hook', { error }); + } + }, + SHUTDOWN_DEFAULTS.HIGH_PRIORITY, + 'Custom Shutdown' + ); } // Priority 2: Services and connections (medium priority) - this.shutdown.onShutdown(async () => { - this.logger.info('Disposing services and connections...'); - try { - if (this.container) { - // Disconnect database clients - const mongoClient = this.container.resolve('mongoClient'); - if (mongoClient?.disconnect) { - await mongoClient.disconnect(); - } + this.shutdown.onShutdown( + async () => { + this.logger.info('Disposing services and connections...'); + try { + if (this.container) { + // Disconnect database clients + const mongoClient = this.container.resolve('mongoClient'); + if (mongoClient?.disconnect) { + await mongoClient.disconnect(); + } - const postgresClient = this.container.resolve('postgresClient'); - if (postgresClient?.disconnect) { - await postgresClient.disconnect(); - } + const postgresClient = this.container.resolve('postgresClient'); + if (postgresClient?.disconnect) { + await postgresClient.disconnect(); + } - const questdbClient = this.container.resolve('questdbClient'); - if (questdbClient?.disconnect) { - await questdbClient.disconnect(); - } + const questdbClient = this.container.resolve('questdbClient'); + if (questdbClient?.disconnect) { + await questdbClient.disconnect(); + } - this.logger.info('All services disposed successfully'); + this.logger.info('All services disposed successfully'); + } + } catch (error) { + this.logger.error('Error disposing services', { error }); } - } catch (error) { - this.logger.error('Error disposing services', { error }); - } - }, SHUTDOWN_DEFAULTS.MEDIUM_PRIORITY, 'Services'); + }, + SHUTDOWN_DEFAULTS.MEDIUM_PRIORITY, + 'Services' + ); // Priority 3: Logger shutdown (lowest priority - runs last) - this.shutdown.onShutdown(async () => { - try { - this.logger.info('Shutting down loggers...'); - await shutdownLoggers(); - // Don't log after shutdown - } catch { - // Silently ignore logger shutdown errors - } - }, SHUTDOWN_DEFAULTS.LOW_PRIORITY, 'Loggers'); + this.shutdown.onShutdown( + async () => { + try { + this.logger.info('Shutting down loggers...'); + await shutdownLoggers(); + // Don't log after shutdown + } catch { + // Silently ignore logger shutdown errors + } + }, + SHUTDOWN_DEFAULTS.LOW_PRIORITY, + 'Loggers' + ); } /** diff --git a/libs/core/di/test/awilix-container.test.ts b/libs/core/di/test/awilix-container.test.ts index cf299e8..1ab8232 100644 --- a/libs/core/di/test/awilix-container.test.ts +++ b/libs/core/di/test/awilix-container.test.ts @@ -1,71 +1,76 @@ -import { describe, it, expect } from 'bun:test'; -import type { ServiceDefinitions, ServiceContainer, ServiceCradle, ServiceContainerOptions } from '../src/awilix-container'; - -describe('Awilix Container Types', () => { - it('should export ServiceDefinitions interface', () => { - // Type test - if this compiles, the type exists - const testDefinitions: Partial = { - config: {} as any, - logger: {} as any, - cache: null, - proxyManager: null, - browser: {} as any, - queueManager: null, - mongoClient: null, - postgresClient: null, - questdbClient: null, - serviceContainer: {} as any, - }; - - expect(testDefinitions).toBeDefined(); - }); - - it('should export ServiceContainer type', () => { - // Type test - if this compiles, the type exists - const testContainer: ServiceContainer | null = null; - expect(testContainer).toBeNull(); - }); - - it('should export ServiceCradle type', () => { - // Type test - if this compiles, the type exists - const testCradle: Partial = { - config: {} as any, - logger: {} as any, - }; - - expect(testCradle).toBeDefined(); - }); - - it('should export ServiceContainerOptions interface', () => { - // Type test - if this compiles, the type exists - const testOptions: ServiceContainerOptions = { - enableQuestDB: true, - enableMongoDB: true, - enablePostgres: true, - enableCache: true, - enableQueue: true, - enableBrowser: true, - enableProxy: true, - }; - - expect(testOptions).toBeDefined(); - expect(testOptions.enableQuestDB).toBe(true); - expect(testOptions.enableMongoDB).toBe(true); - expect(testOptions.enablePostgres).toBe(true); - expect(testOptions.enableCache).toBe(true); - expect(testOptions.enableQueue).toBe(true); - expect(testOptions.enableBrowser).toBe(true); - expect(testOptions.enableProxy).toBe(true); - }); - - it('should allow partial ServiceContainerOptions', () => { - const partialOptions: ServiceContainerOptions = { - enableCache: true, - enableQueue: false, - }; - - expect(partialOptions.enableCache).toBe(true); - expect(partialOptions.enableQueue).toBe(false); - expect(partialOptions.enableQuestDB).toBeUndefined(); - }); -}); \ No newline at end of file +import { describe, expect, it } from 'bun:test'; +import type { + ServiceContainer, + ServiceContainerOptions, + ServiceCradle, + ServiceDefinitions, +} from '../src/awilix-container'; + +describe('Awilix Container Types', () => { + it('should export ServiceDefinitions interface', () => { + // Type test - if this compiles, the type exists + const testDefinitions: Partial = { + config: {} as any, + logger: {} as any, + cache: null, + proxyManager: null, + browser: {} as any, + queueManager: null, + mongoClient: null, + postgresClient: null, + questdbClient: null, + serviceContainer: {} as any, + }; + + expect(testDefinitions).toBeDefined(); + }); + + it('should export ServiceContainer type', () => { + // Type test - if this compiles, the type exists + const testContainer: ServiceContainer | null = null; + expect(testContainer).toBeNull(); + }); + + it('should export ServiceCradle type', () => { + // Type test - if this compiles, the type exists + const testCradle: Partial = { + config: {} as any, + logger: {} as any, + }; + + expect(testCradle).toBeDefined(); + }); + + it('should export ServiceContainerOptions interface', () => { + // Type test - if this compiles, the type exists + const testOptions: ServiceContainerOptions = { + enableQuestDB: true, + enableMongoDB: true, + enablePostgres: true, + enableCache: true, + enableQueue: true, + enableBrowser: true, + enableProxy: true, + }; + + expect(testOptions).toBeDefined(); + expect(testOptions.enableQuestDB).toBe(true); + expect(testOptions.enableMongoDB).toBe(true); + expect(testOptions.enablePostgres).toBe(true); + expect(testOptions.enableCache).toBe(true); + expect(testOptions.enableQueue).toBe(true); + expect(testOptions.enableBrowser).toBe(true); + expect(testOptions.enableProxy).toBe(true); + }); + + it('should allow partial ServiceContainerOptions', () => { + const partialOptions: ServiceContainerOptions = { + enableCache: true, + enableQueue: false, + }; + + expect(partialOptions.enableCache).toBe(true); + expect(partialOptions.enableQueue).toBe(false); + expect(partialOptions.enableQuestDB).toBeUndefined(); + }); +}); diff --git a/libs/core/di/test/container-builder.test.ts b/libs/core/di/test/container-builder.test.ts index 8ecddce..aae8e28 100644 --- a/libs/core/di/test/container-builder.test.ts +++ b/libs/core/di/test/container-builder.test.ts @@ -31,10 +31,18 @@ mock.module('@stock-bot/config', () => ({ } // Copy flat configs to nested if they exist - if (result.redis) {result.database.dragonfly = result.redis;} - if (result.mongodb) {result.database.mongodb = result.mongodb;} - if (result.postgres) {result.database.postgres = result.postgres;} - if (result.questdb) {result.database.questdb = result.questdb;} + if (result.redis) { + result.database.dragonfly = result.redis; + } + if (result.mongodb) { + result.database.mongodb = result.mongodb; + } + if (result.postgres) { + result.database.postgres = result.postgres; + } + if (result.questdb) { + result.database.questdb = result.questdb; + } return result; }, diff --git a/libs/core/di/test/index.test.ts b/libs/core/di/test/index.test.ts index f144dfe..2851656 100644 --- a/libs/core/di/test/index.test.ts +++ b/libs/core/di/test/index.test.ts @@ -1,52 +1,52 @@ -import { describe, it, expect } from 'bun:test'; -import * as diExports from '../src/index'; - -describe('DI Package Exports', () => { - it('should export OperationContext', () => { - expect(diExports.OperationContext).toBeDefined(); - }); - - it('should export pool size calculator', () => { - expect(diExports.calculatePoolSize).toBeDefined(); - expect(diExports.getServicePoolSize).toBeDefined(); - expect(diExports.getHandlerPoolSize).toBeDefined(); - }); - - it('should export ServiceContainerBuilder', () => { - expect(diExports.ServiceContainerBuilder).toBeDefined(); - }); - - it('should export ServiceLifecycleManager', () => { - expect(diExports.ServiceLifecycleManager).toBeDefined(); - }); - - it('should export ServiceApplication', () => { - expect(diExports.ServiceApplication).toBeDefined(); - }); - - it('should export HandlerScanner', () => { - expect(diExports.HandlerScanner).toBeDefined(); - }); - - it('should export factories', () => { - expect(diExports.CacheFactory).toBeDefined(); - }); - - it('should export schemas', () => { - expect(diExports.appConfigSchema).toBeDefined(); - expect(diExports.redisConfigSchema).toBeDefined(); - expect(diExports.mongodbConfigSchema).toBeDefined(); - expect(diExports.postgresConfigSchema).toBeDefined(); - expect(diExports.questdbConfigSchema).toBeDefined(); - expect(diExports.proxyConfigSchema).toBeDefined(); - expect(diExports.browserConfigSchema).toBeDefined(); - expect(diExports.queueConfigSchema).toBeDefined(); - }); - - it('should export type definitions', () => { - // These are type exports - check that the awilix-container module is re-exported - expect(diExports).toBeDefined(); - // The types AppConfig, ServiceCradle, etc. are TypeScript types and not runtime values - // We can't test them directly, but we've verified they're exported in the source - }); -}); \ No newline at end of file +import { describe, expect, it } from 'bun:test'; +import * as diExports from '../src/index'; + +describe('DI Package Exports', () => { + it('should export OperationContext', () => { + expect(diExports.OperationContext).toBeDefined(); + }); + + it('should export pool size calculator', () => { + expect(diExports.calculatePoolSize).toBeDefined(); + expect(diExports.getServicePoolSize).toBeDefined(); + expect(diExports.getHandlerPoolSize).toBeDefined(); + }); + + it('should export ServiceContainerBuilder', () => { + expect(diExports.ServiceContainerBuilder).toBeDefined(); + }); + + it('should export ServiceLifecycleManager', () => { + expect(diExports.ServiceLifecycleManager).toBeDefined(); + }); + + it('should export ServiceApplication', () => { + expect(diExports.ServiceApplication).toBeDefined(); + }); + + it('should export HandlerScanner', () => { + expect(diExports.HandlerScanner).toBeDefined(); + }); + + it('should export factories', () => { + expect(diExports.CacheFactory).toBeDefined(); + }); + + it('should export schemas', () => { + expect(diExports.appConfigSchema).toBeDefined(); + expect(diExports.redisConfigSchema).toBeDefined(); + expect(diExports.mongodbConfigSchema).toBeDefined(); + expect(diExports.postgresConfigSchema).toBeDefined(); + expect(diExports.questdbConfigSchema).toBeDefined(); + expect(diExports.proxyConfigSchema).toBeDefined(); + expect(diExports.browserConfigSchema).toBeDefined(); + expect(diExports.queueConfigSchema).toBeDefined(); + }); + + it('should export type definitions', () => { + // These are type exports - check that the awilix-container module is re-exported + expect(diExports).toBeDefined(); + // The types AppConfig, ServiceCradle, etc. are TypeScript types and not runtime values + // We can't test them directly, but we've verified they're exported in the source + }); +}); diff --git a/libs/core/di/test/registration.test.ts b/libs/core/di/test/registration.test.ts index 3169671..2114dcc 100644 --- a/libs/core/di/test/registration.test.ts +++ b/libs/core/di/test/registration.test.ts @@ -107,14 +107,14 @@ describe('DI Registrations', () => { describe('registerDatabaseServices', () => { it('should register MongoDB when config exists', () => { const container = createContainer(); - + // Mock MongoDB client const mockMongoClient = { connect: mock(() => Promise.resolve()), disconnect: mock(() => Promise.resolve()), getDb: mock(() => ({})), }; - + // Mock the MongoDB factory mock.module('@stock-bot/mongodb', () => ({ MongoDBClient: class { @@ -123,7 +123,7 @@ describe('DI Registrations', () => { } }, })); - + const config = { mongodb: { enabled: true, @@ -139,14 +139,14 @@ describe('DI Registrations', () => { it('should register PostgreSQL when config exists', () => { const container = createContainer(); - + // Mock Postgres client const mockPostgresClient = { connect: mock(() => Promise.resolve()), disconnect: mock(() => Promise.resolve()), query: mock(() => Promise.resolve({ rows: [] })), }; - + // Mock the Postgres factory mock.module('@stock-bot/postgres', () => ({ PostgresClient: class { @@ -155,7 +155,7 @@ describe('DI Registrations', () => { } }, })); - + const config = { postgres: { enabled: true, @@ -174,14 +174,14 @@ describe('DI Registrations', () => { it('should register QuestDB when config exists', () => { const container = createContainer(); - + // Mock QuestDB client const mockQuestdbClient = { connect: mock(() => Promise.resolve()), disconnect: mock(() => Promise.resolve()), query: mock(() => Promise.resolve({ data: [] })), }; - + // Mock the QuestDB factory mock.module('@stock-bot/questdb', () => ({ QuestDBClient: class { @@ -190,7 +190,7 @@ describe('DI Registrations', () => { } }, })); - + const config = { questdb: { enabled: true, @@ -209,7 +209,7 @@ describe('DI Registrations', () => { it('should not register disabled databases', () => { const container = createContainer(); - + const config = { mongodb: { enabled: false }, postgres: { enabled: false }, @@ -222,7 +222,7 @@ describe('DI Registrations', () => { expect(container.hasRegistration('mongoClient')).toBe(true); expect(container.hasRegistration('postgresClient')).toBe(true); expect(container.hasRegistration('questdbClient')).toBe(true); - + // Verify they resolve to null expect(container.resolve('mongoClient')).toBeNull(); expect(container.resolve('postgresClient')).toBeNull(); @@ -233,17 +233,17 @@ describe('DI Registrations', () => { describe('registerApplicationServices', () => { it('should register browser when config exists', () => { const container = createContainer(); - + // Mock browser factory const mockBrowser = { launch: mock(() => Promise.resolve()), close: mock(() => Promise.resolve()), }; - + mock.module('@stock-bot/browser', () => ({ createBrowser: () => mockBrowser, })); - + const config = { browser: { headless: true, @@ -258,16 +258,16 @@ describe('DI Registrations', () => { it('should register proxy when config exists', () => { const container = createContainer(); - + // Mock proxy factory const mockProxy = { getProxy: mock(() => 'http://proxy:8080'), }; - + mock.module('@stock-bot/proxy', () => ({ createProxyManager: () => mockProxy, })); - + const config = { proxy: { enabled: true, @@ -282,7 +282,7 @@ describe('DI Registrations', () => { it('should register queue manager when queue config exists', () => { const container = createContainer(); - + // Mock dependencies container.register({ cache: asValue({ @@ -300,14 +300,14 @@ describe('DI Registrations', () => { debug: mock(() => {}), }), }); - + // Mock queue manager const mockQueueManager = { getQueue: mock(() => ({})), startAllWorkers: mock(() => {}), shutdown: mock(() => Promise.resolve()), }; - + mock.module('@stock-bot/queue', () => ({ QueueManager: class { constructor() { @@ -315,7 +315,7 @@ describe('DI Registrations', () => { } }, })); - + const config = { service: { name: 'test-service', @@ -335,7 +335,7 @@ describe('DI Registrations', () => { it('should not register services when configs are missing', () => { const container = createContainer(); - + const config = {} as any; registerApplicationServices(container, config); @@ -343,12 +343,12 @@ describe('DI Registrations', () => { expect(container.hasRegistration('browser')).toBe(true); expect(container.hasRegistration('proxyManager')).toBe(true); expect(container.hasRegistration('queueManager')).toBe(true); - + // They should be registered as null const browser = container.resolve('browser'); const proxyManager = container.resolve('proxyManager'); const queueManager = container.resolve('queueManager'); - + expect(browser).toBe(null); expect(proxyManager).toBe(null); expect(queueManager).toBe(null); @@ -358,7 +358,7 @@ describe('DI Registrations', () => { describe('dependency resolution', () => { it('should properly resolve cache dependencies', () => { const container = createContainer(); - + const config = { service: { name: 'test-service', @@ -373,7 +373,7 @@ describe('DI Registrations', () => { } as any; registerCacheServices(container, config); - + // Should have registered cache expect(container.hasRegistration('cache')).toBe(true); expect(container.hasRegistration('globalCache')).toBe(true); @@ -381,13 +381,13 @@ describe('DI Registrations', () => { it('should handle circular dependencies gracefully', () => { const container = createContainer(); - + // Register services with potential circular deps container.register({ serviceA: asFunction(({ serviceB }) => ({ b: serviceB })).singleton(), serviceB: asFunction(({ serviceA }) => ({ a: serviceA })).singleton(), }); - + // This should throw or handle gracefully expect(() => container.resolve('serviceA')).toThrow(); }); @@ -396,7 +396,7 @@ describe('DI Registrations', () => { describe('registration options', () => { it('should register services as singletons', () => { const container = createContainer(); - + const config = { browser: { headless: true, @@ -405,7 +405,7 @@ describe('DI Registrations', () => { } as any; registerApplicationServices(container, config); - + // Check that browser was registered as singleton const registration = container.getRegistration('browser'); expect(registration).toBeDefined(); diff --git a/libs/core/di/test/service-application.test.ts b/libs/core/di/test/service-application.test.ts index dbf9150..e28fc26 100644 --- a/libs/core/di/test/service-application.test.ts +++ b/libs/core/di/test/service-application.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import type { BaseAppConfig } from '@stock-bot/config'; import { ServiceApplication } from '../src/service-application'; import type { ServiceApplicationConfig, ServiceLifecycleHooks } from '../src/service-application'; -import type { BaseAppConfig } from '@stock-bot/config'; // Mock logger module const mockLogger = { @@ -18,7 +18,7 @@ mock.module('@stock-bot/logger', () => ({ shutdownLoggers: mock(() => Promise.resolve()), })); -// Mock shutdown module +// Mock shutdown module const mockShutdownInstance = { onShutdown: mock(() => {}), onShutdownHigh: mock(() => {}), @@ -89,7 +89,7 @@ describe.skip('ServiceApplication', () => { mockShutdownInstance.registerAsync.mockReset(); mockShutdownInstance.handleTermination.mockReset(); mockShutdownInstance.executeCallbacks.mockReset(); - + // Clean up app if it exists if (app) { app.stop().catch(() => {}); @@ -193,7 +193,6 @@ describe.skip('ServiceApplication', () => { app = new ServiceApplication(configWithoutServiceName as any, serviceConfig); expect(app).toBeDefined(); }); - }); describe('start method', () => { @@ -228,7 +227,7 @@ describe.skip('ServiceApplication', () => { const { Hono } = require('hono'); const routes = new Hono(); // Add a simple test route - routes.get('/test', (c) => c.json({ test: true })); + routes.get('/test', c => c.json({ test: true })); return routes; }); const mockHandlerInitializer = mock(() => Promise.resolve()); @@ -240,12 +239,14 @@ describe.skip('ServiceApplication', () => { }; app = new ServiceApplication(mockConfig, serviceConfig); - + await app.start(mockContainerFactory, mockRouteFactory); - expect(mockContainerFactory).toHaveBeenCalledWith(expect.objectContaining({ - service: expect.objectContaining({ serviceName: 'test-service' }), - })); + expect(mockContainerFactory).toHaveBeenCalledWith( + expect.objectContaining({ + service: expect.objectContaining({ serviceName: 'test-service' }), + }) + ); expect(mockRouteFactory).toHaveBeenCalledWith({ test: 'container' }); expect(mockLogger.info).toHaveBeenCalledWith('test-service service started on port 3000'); }); @@ -257,13 +258,15 @@ describe.skip('ServiceApplication', () => { }; app = new ServiceApplication(mockConfig, serviceConfig); - + await app.start(mockContainerFactory, mockRouteFactory, mockHandlerInitializer); - expect(mockHandlerInitializer).toHaveBeenCalledWith(expect.objectContaining({ - test: 'container', - _diContainer: mockContainer, - })); + expect(mockHandlerInitializer).toHaveBeenCalledWith( + expect.objectContaining({ + test: 'container', + _diContainer: mockContainer, + }) + ); expect(mockLogger.info).toHaveBeenCalledWith('Handlers initialized'); }); @@ -280,7 +283,7 @@ describe.skip('ServiceApplication', () => { }; app = new ServiceApplication(mockConfig, serviceConfig, hooks); - + await app.start(mockContainerFactory, mockRouteFactory); expect(hooks.onContainerReady).toHaveBeenCalledWith({ test: 'container' }); @@ -299,8 +302,10 @@ describe.skip('ServiceApplication', () => { }; app = new ServiceApplication(mockConfig, serviceConfig); - - await expect(app.start(errorFactory, mockRouteFactory)).rejects.toThrow('Container creation failed'); + + await expect(app.start(errorFactory, mockRouteFactory)).rejects.toThrow( + 'Container creation failed' + ); expect(mockLogger.error).toHaveBeenCalledWith('DETAILED ERROR:', expect.any(Error)); }); @@ -311,17 +316,23 @@ describe.skip('ServiceApplication', () => { }; const mockHandlerRegistry = { - getAllHandlersWithSchedule: () => new Map([ - ['testHandler', { - scheduledJobs: [{ - operation: 'processData', - cronPattern: '0 * * * *', - priority: 5, - immediately: false, - payload: { test: true }, - }], - }], - ]), + getAllHandlersWithSchedule: () => + new Map([ + [ + 'testHandler', + { + scheduledJobs: [ + { + operation: 'processData', + cronPattern: '0 * * * *', + priority: 5, + immediately: false, + payload: { test: true }, + }, + ], + }, + ], + ]), getHandlerService: () => 'test-service', getHandlerNames: () => ['testHandler'], getOperation: () => ({ name: 'processData' }), @@ -339,9 +350,15 @@ describe.skip('ServiceApplication', () => { const containerWithJobs = { resolve: mock((name: string) => { - if (name === 'serviceContainer') {return { test: 'container' };} - if (name === 'handlerRegistry') {return mockHandlerRegistry;} - if (name === 'queueManager') {return mockQueueManager;} + if (name === 'serviceContainer') { + return { test: 'container' }; + } + if (name === 'handlerRegistry') { + return mockHandlerRegistry; + } + if (name === 'queueManager') { + return mockQueueManager; + } return null; }), }; @@ -349,7 +366,7 @@ describe.skip('ServiceApplication', () => { const jobContainerFactory = mock(async () => containerWithJobs); app = new ServiceApplication(mockConfig, serviceConfig); - + await app.start(jobContainerFactory, mockRouteFactory); expect(mockQueueManager.getQueue).toHaveBeenCalledWith('testHandler', { @@ -359,7 +376,7 @@ describe.skip('ServiceApplication', () => { 'processData', { handler: 'testHandler', operation: 'processData', payload: { test: true } }, '0 * * * *', - expect.objectContaining({ priority: 5, repeat: { immediately: false } }), + expect.objectContaining({ priority: 5, repeat: { immediately: false } }) ); expect(mockQueueManager.startAllWorkers).toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith('Scheduled jobs created', { totalJobs: 1 }); @@ -386,7 +403,7 @@ describe.skip('ServiceApplication', () => { }; app = new ServiceApplication(mockConfig, serviceConfig); - + await app.stop(); expect(mockShutdownInstance.shutdown).toHaveBeenCalled(); @@ -401,7 +418,7 @@ describe.skip('ServiceApplication', () => { }; app = new ServiceApplication(mockConfig, serviceConfig); - + // Before start expect(app.getServiceContainer()).toBeNull(); expect(app.getApp()).toBeNull(); @@ -451,18 +468,30 @@ describe.skip('ServiceApplication', () => { const mockContainer = { resolve: mock((name: string) => { - if (name === 'serviceContainer') {return { test: 'container' };} - if (name === 'handlerRegistry') {return { - getAllHandlersWithSchedule: () => new Map(), - getHandlerNames: () => [], - };} - if (name === 'queueManager') {return { - shutdown: mock(() => Promise.resolve()), - startAllWorkers: mock(() => {}), - };} - if (name === 'mongoClient') {return { disconnect: mock(() => Promise.resolve()) };} - if (name === 'postgresClient') {return { disconnect: mock(() => Promise.resolve()) };} - if (name === 'questdbClient') {return { disconnect: mock(() => Promise.resolve()) };} + if (name === 'serviceContainer') { + return { test: 'container' }; + } + if (name === 'handlerRegistry') { + return { + getAllHandlersWithSchedule: () => new Map(), + getHandlerNames: () => [], + }; + } + if (name === 'queueManager') { + return { + shutdown: mock(() => Promise.resolve()), + startAllWorkers: mock(() => {}), + }; + } + if (name === 'mongoClient') { + return { disconnect: mock(() => Promise.resolve()) }; + } + if (name === 'postgresClient') { + return { disconnect: mock(() => Promise.resolve()) }; + } + if (name === 'questdbClient') { + return { disconnect: mock(() => Promise.resolve()) }; + } return null; }), }; @@ -486,7 +515,7 @@ describe.skip('ServiceApplication', () => { await highHandlers[0][0](); expect(mockContainer.resolve).toHaveBeenCalledWith('queueManager'); - // Execute services shutdown handler + // Execute services shutdown handler await mediumHandlers[0][0](); expect(mockContainer.resolve).toHaveBeenCalledWith('mongoClient'); expect(mockContainer.resolve).toHaveBeenCalledWith('postgresClient'); @@ -566,4 +595,4 @@ describe.skip('ServiceApplication', () => { expect(response.status).toBe(404); }); }); -}); \ No newline at end of file +}); diff --git a/libs/core/di/test/types.test.ts b/libs/core/di/test/types.test.ts index 28513d5..8eff378 100644 --- a/libs/core/di/test/types.test.ts +++ b/libs/core/di/test/types.test.ts @@ -1,270 +1,270 @@ -import { describe, it, expect } from 'bun:test'; -import type { - GenericClientConfig, - ConnectionPoolConfig, - MongoDBPoolConfig, - PostgreSQLPoolConfig, - CachePoolConfig, - QueuePoolConfig, - ConnectionFactoryConfig, - ConnectionPool, - PoolMetrics, - ConnectionFactory, -} from '../src/types'; - -describe('DI Types', () => { - describe('GenericClientConfig', () => { - it('should allow any key-value pairs', () => { - const config: GenericClientConfig = { - host: 'localhost', - port: 5432, - username: 'test', - password: 'test', - customOption: true, - }; - - expect(config.host).toBe('localhost'); - expect(config.port).toBe(5432); - expect(config.customOption).toBe(true); - }); - }); - - describe('ConnectionPoolConfig', () => { - it('should have required and optional fields', () => { - const config: ConnectionPoolConfig = { - name: 'test-pool', - poolSize: 10, - minConnections: 2, - maxConnections: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 5000, - enableMetrics: true, - }; - - expect(config.name).toBe('test-pool'); - expect(config.poolSize).toBe(10); - expect(config.enableMetrics).toBe(true); - }); - - it('should allow minimal configuration', () => { - const config: ConnectionPoolConfig = { - name: 'minimal-pool', - }; - - expect(config.name).toBe('minimal-pool'); - expect(config.poolSize).toBeUndefined(); - }); - }); - - describe('Specific Pool Configs', () => { - it('should extend ConnectionPoolConfig for MongoDB', () => { - const config: MongoDBPoolConfig = { - name: 'mongo-pool', - poolSize: 5, - config: { - uri: 'mongodb://localhost:27017', - database: 'test', - }, - }; - - expect(config.name).toBe('mongo-pool'); - expect(config.config.uri).toBe('mongodb://localhost:27017'); - }); - - it('should extend ConnectionPoolConfig for PostgreSQL', () => { - const config: PostgreSQLPoolConfig = { - name: 'postgres-pool', - config: { - host: 'localhost', - port: 5432, - database: 'test', - }, - }; - - expect(config.name).toBe('postgres-pool'); - expect(config.config.host).toBe('localhost'); - }); - - it('should extend ConnectionPoolConfig for Cache', () => { - const config: CachePoolConfig = { - name: 'cache-pool', - config: { - host: 'localhost', - port: 6379, - }, - }; - - expect(config.name).toBe('cache-pool'); - expect(config.config.port).toBe(6379); - }); - - it('should extend ConnectionPoolConfig for Queue', () => { - const config: QueuePoolConfig = { - name: 'queue-pool', - config: { - redis: { - host: 'localhost', - port: 6379, - }, - }, - }; - - expect(config.name).toBe('queue-pool'); - expect(config.config.redis.host).toBe('localhost'); - }); - }); - - describe('ConnectionFactoryConfig', () => { - it('should define factory configuration', () => { - const config: ConnectionFactoryConfig = { - service: 'test-service', - environment: 'development', - pools: { - mongodb: { - poolSize: 10, - }, - postgres: { - maxConnections: 20, - }, - cache: { - idleTimeoutMillis: 60000, - }, - queue: { - enableMetrics: true, - }, - }, - }; - - expect(config.service).toBe('test-service'); - expect(config.environment).toBe('development'); - expect(config.pools?.mongodb?.poolSize).toBe(10); - expect(config.pools?.postgres?.maxConnections).toBe(20); - }); - - it('should allow minimal factory config', () => { - const config: ConnectionFactoryConfig = { - service: 'minimal-service', - environment: 'test', - }; - - expect(config.service).toBe('minimal-service'); - expect(config.pools).toBeUndefined(); - }); - }); - - describe('ConnectionPool', () => { - it('should define connection pool interface', () => { - const mockPool: ConnectionPool = { - name: 'test-pool', - client: { connected: true }, - metrics: { - created: new Date(), - totalConnections: 10, - activeConnections: 5, - idleConnections: 5, - waitingRequests: 0, - errors: 0, - }, - health: async () => true, - dispose: async () => {}, - }; - - expect(mockPool.name).toBe('test-pool'); - expect(mockPool.client.connected).toBe(true); - expect(mockPool.metrics.totalConnections).toBe(10); - }); - }); - - describe('PoolMetrics', () => { - it('should define pool metrics structure', () => { - const metrics: PoolMetrics = { - created: new Date('2024-01-01'), - totalConnections: 100, - activeConnections: 25, - idleConnections: 75, - waitingRequests: 2, - errors: 3, - }; - - expect(metrics.totalConnections).toBe(100); - expect(metrics.activeConnections).toBe(25); - expect(metrics.idleConnections).toBe(75); - expect(metrics.waitingRequests).toBe(2); - expect(metrics.errors).toBe(3); - }); - }); - - describe('ConnectionFactory', () => { - it('should define connection factory interface', () => { - const mockFactory: ConnectionFactory = { - createMongoDB: async (config) => ({ - name: config.name, - client: {}, - metrics: { - created: new Date(), - totalConnections: 0, - activeConnections: 0, - idleConnections: 0, - waitingRequests: 0, - errors: 0, - }, - health: async () => true, - dispose: async () => {}, - }), - createPostgreSQL: async (config) => ({ - name: config.name, - client: {}, - metrics: { - created: new Date(), - totalConnections: 0, - activeConnections: 0, - idleConnections: 0, - waitingRequests: 0, - errors: 0, - }, - health: async () => true, - dispose: async () => {}, - }), - createCache: async (config) => ({ - name: config.name, - client: {}, - metrics: { - created: new Date(), - totalConnections: 0, - activeConnections: 0, - idleConnections: 0, - waitingRequests: 0, - errors: 0, - }, - health: async () => true, - dispose: async () => {}, - }), - createQueue: async (config) => ({ - name: config.name, - client: {}, - metrics: { - created: new Date(), - totalConnections: 0, - activeConnections: 0, - idleConnections: 0, - waitingRequests: 0, - errors: 0, - }, - health: async () => true, - dispose: async () => {}, - }), - getPool: (type, name) => undefined, - listPools: () => [], - disposeAll: async () => {}, - }; - - expect(mockFactory.createMongoDB).toBeDefined(); - expect(mockFactory.createPostgreSQL).toBeDefined(); - expect(mockFactory.createCache).toBeDefined(); - expect(mockFactory.createQueue).toBeDefined(); - expect(mockFactory.getPool).toBeDefined(); - expect(mockFactory.listPools).toBeDefined(); - expect(mockFactory.disposeAll).toBeDefined(); - }); - }); -}); \ No newline at end of file +import { describe, expect, it } from 'bun:test'; +import type { + CachePoolConfig, + ConnectionFactory, + ConnectionFactoryConfig, + ConnectionPool, + ConnectionPoolConfig, + GenericClientConfig, + MongoDBPoolConfig, + PoolMetrics, + PostgreSQLPoolConfig, + QueuePoolConfig, +} from '../src/types'; + +describe('DI Types', () => { + describe('GenericClientConfig', () => { + it('should allow any key-value pairs', () => { + const config: GenericClientConfig = { + host: 'localhost', + port: 5432, + username: 'test', + password: 'test', + customOption: true, + }; + + expect(config.host).toBe('localhost'); + expect(config.port).toBe(5432); + expect(config.customOption).toBe(true); + }); + }); + + describe('ConnectionPoolConfig', () => { + it('should have required and optional fields', () => { + const config: ConnectionPoolConfig = { + name: 'test-pool', + poolSize: 10, + minConnections: 2, + maxConnections: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + enableMetrics: true, + }; + + expect(config.name).toBe('test-pool'); + expect(config.poolSize).toBe(10); + expect(config.enableMetrics).toBe(true); + }); + + it('should allow minimal configuration', () => { + const config: ConnectionPoolConfig = { + name: 'minimal-pool', + }; + + expect(config.name).toBe('minimal-pool'); + expect(config.poolSize).toBeUndefined(); + }); + }); + + describe('Specific Pool Configs', () => { + it('should extend ConnectionPoolConfig for MongoDB', () => { + const config: MongoDBPoolConfig = { + name: 'mongo-pool', + poolSize: 5, + config: { + uri: 'mongodb://localhost:27017', + database: 'test', + }, + }; + + expect(config.name).toBe('mongo-pool'); + expect(config.config.uri).toBe('mongodb://localhost:27017'); + }); + + it('should extend ConnectionPoolConfig for PostgreSQL', () => { + const config: PostgreSQLPoolConfig = { + name: 'postgres-pool', + config: { + host: 'localhost', + port: 5432, + database: 'test', + }, + }; + + expect(config.name).toBe('postgres-pool'); + expect(config.config.host).toBe('localhost'); + }); + + it('should extend ConnectionPoolConfig for Cache', () => { + const config: CachePoolConfig = { + name: 'cache-pool', + config: { + host: 'localhost', + port: 6379, + }, + }; + + expect(config.name).toBe('cache-pool'); + expect(config.config.port).toBe(6379); + }); + + it('should extend ConnectionPoolConfig for Queue', () => { + const config: QueuePoolConfig = { + name: 'queue-pool', + config: { + redis: { + host: 'localhost', + port: 6379, + }, + }, + }; + + expect(config.name).toBe('queue-pool'); + expect(config.config.redis.host).toBe('localhost'); + }); + }); + + describe('ConnectionFactoryConfig', () => { + it('should define factory configuration', () => { + const config: ConnectionFactoryConfig = { + service: 'test-service', + environment: 'development', + pools: { + mongodb: { + poolSize: 10, + }, + postgres: { + maxConnections: 20, + }, + cache: { + idleTimeoutMillis: 60000, + }, + queue: { + enableMetrics: true, + }, + }, + }; + + expect(config.service).toBe('test-service'); + expect(config.environment).toBe('development'); + expect(config.pools?.mongodb?.poolSize).toBe(10); + expect(config.pools?.postgres?.maxConnections).toBe(20); + }); + + it('should allow minimal factory config', () => { + const config: ConnectionFactoryConfig = { + service: 'minimal-service', + environment: 'test', + }; + + expect(config.service).toBe('minimal-service'); + expect(config.pools).toBeUndefined(); + }); + }); + + describe('ConnectionPool', () => { + it('should define connection pool interface', () => { + const mockPool: ConnectionPool = { + name: 'test-pool', + client: { connected: true }, + metrics: { + created: new Date(), + totalConnections: 10, + activeConnections: 5, + idleConnections: 5, + waitingRequests: 0, + errors: 0, + }, + health: async () => true, + dispose: async () => {}, + }; + + expect(mockPool.name).toBe('test-pool'); + expect(mockPool.client.connected).toBe(true); + expect(mockPool.metrics.totalConnections).toBe(10); + }); + }); + + describe('PoolMetrics', () => { + it('should define pool metrics structure', () => { + const metrics: PoolMetrics = { + created: new Date('2024-01-01'), + totalConnections: 100, + activeConnections: 25, + idleConnections: 75, + waitingRequests: 2, + errors: 3, + }; + + expect(metrics.totalConnections).toBe(100); + expect(metrics.activeConnections).toBe(25); + expect(metrics.idleConnections).toBe(75); + expect(metrics.waitingRequests).toBe(2); + expect(metrics.errors).toBe(3); + }); + }); + + describe('ConnectionFactory', () => { + it('should define connection factory interface', () => { + const mockFactory: ConnectionFactory = { + createMongoDB: async config => ({ + name: config.name, + client: {}, + metrics: { + created: new Date(), + totalConnections: 0, + activeConnections: 0, + idleConnections: 0, + waitingRequests: 0, + errors: 0, + }, + health: async () => true, + dispose: async () => {}, + }), + createPostgreSQL: async config => ({ + name: config.name, + client: {}, + metrics: { + created: new Date(), + totalConnections: 0, + activeConnections: 0, + idleConnections: 0, + waitingRequests: 0, + errors: 0, + }, + health: async () => true, + dispose: async () => {}, + }), + createCache: async config => ({ + name: config.name, + client: {}, + metrics: { + created: new Date(), + totalConnections: 0, + activeConnections: 0, + idleConnections: 0, + waitingRequests: 0, + errors: 0, + }, + health: async () => true, + dispose: async () => {}, + }), + createQueue: async config => ({ + name: config.name, + client: {}, + metrics: { + created: new Date(), + totalConnections: 0, + activeConnections: 0, + idleConnections: 0, + waitingRequests: 0, + errors: 0, + }, + health: async () => true, + dispose: async () => {}, + }), + getPool: (type, name) => undefined, + listPools: () => [], + disposeAll: async () => {}, + }; + + expect(mockFactory.createMongoDB).toBeDefined(); + expect(mockFactory.createPostgreSQL).toBeDefined(); + expect(mockFactory.createCache).toBeDefined(); + expect(mockFactory.createQueue).toBeDefined(); + expect(mockFactory.getPool).toBeDefined(); + expect(mockFactory.listPools).toBeDefined(); + expect(mockFactory.disposeAll).toBeDefined(); + }); + }); +}); diff --git a/libs/core/handler-registry/src/registry.ts b/libs/core/handler-registry/src/registry.ts index 31b7581..8461c51 100644 --- a/libs/core/handler-registry/src/registry.ts +++ b/libs/core/handler-registry/src/registry.ts @@ -80,7 +80,6 @@ export class HandlerRegistry { return this.handlers.has(handlerName); } - /** * Set service ownership for a handler */ @@ -107,7 +106,10 @@ export class HandlerRegistry { getServiceHandlers(serviceName: string): HandlerMetadata[] { const handlers: HandlerMetadata[] = []; for (const [handlerName, metadata] of this.handlers) { - if (this.handlerServices.get(handlerName) === serviceName || metadata.service === serviceName) { + if ( + this.handlerServices.get(handlerName) === serviceName || + metadata.service === serviceName + ) { handlers.push(metadata); } } diff --git a/libs/core/handler-registry/test/index.test.ts b/libs/core/handler-registry/test/index.test.ts index bf2188c..3d65907 100644 --- a/libs/core/handler-registry/test/index.test.ts +++ b/libs/core/handler-registry/test/index.test.ts @@ -1,77 +1,77 @@ -import { describe, it, expect } from 'bun:test'; -import * as handlerRegistryExports from '../src'; -import { HandlerRegistry } from '../src'; - -describe('Handler Registry Package Exports', () => { - it('should export HandlerRegistry class', () => { - expect(handlerRegistryExports.HandlerRegistry).toBeDefined(); - expect(handlerRegistryExports.HandlerRegistry).toBe(HandlerRegistry); - }); - - it('should export correct types', () => { - // Type tests - compile-time checks - type TestHandlerMetadata = handlerRegistryExports.HandlerMetadata; - type TestOperationMetadata = handlerRegistryExports.OperationMetadata; - type TestScheduleMetadata = handlerRegistryExports.ScheduleMetadata; - type TestHandlerConfiguration = handlerRegistryExports.HandlerConfiguration; - type TestRegistryStats = handlerRegistryExports.RegistryStats; - type TestHandlerDiscoveryResult = handlerRegistryExports.HandlerDiscoveryResult; - - // Runtime type usage tests - const testHandler: TestHandlerMetadata = { - name: 'TestHandler', - serviceName: 'test-service', - operations: [], - }; - - const testOperation: TestOperationMetadata = { - operationName: 'testOperation', - handlerName: 'TestHandler', - operationPath: 'test.operation', - serviceName: 'test-service', - }; - - const testSchedule: TestScheduleMetadata = { - handlerName: 'TestHandler', - scheduleName: 'test-schedule', - expression: '*/5 * * * *', - serviceName: 'test-service', - }; - - const testConfig: TestHandlerConfiguration = { - handlerName: 'TestHandler', - batchSize: 10, - timeout: 5000, - retries: 3, - }; - - const testStats: TestRegistryStats = { - totalHandlers: 5, - totalOperations: 10, - totalSchedules: 3, - handlersByService: { - 'service1': 2, - 'service2': 3, - }, - }; - - const testDiscoveryResult: TestHandlerDiscoveryResult = { - handlers: [testHandler], - operations: [testOperation], - schedules: [testSchedule], - configurations: [testConfig], - }; - - expect(testHandler).toBeDefined(); - expect(testOperation).toBeDefined(); - expect(testSchedule).toBeDefined(); - expect(testConfig).toBeDefined(); - expect(testStats).toBeDefined(); - expect(testDiscoveryResult).toBeDefined(); - }); - - it('should create HandlerRegistry instance', () => { - const registry = new HandlerRegistry(); - expect(registry).toBeInstanceOf(HandlerRegistry); - }); -}); \ No newline at end of file +import { describe, expect, it } from 'bun:test'; +import * as handlerRegistryExports from '../src'; +import { HandlerRegistry } from '../src'; + +describe('Handler Registry Package Exports', () => { + it('should export HandlerRegistry class', () => { + expect(handlerRegistryExports.HandlerRegistry).toBeDefined(); + expect(handlerRegistryExports.HandlerRegistry).toBe(HandlerRegistry); + }); + + it('should export correct types', () => { + // Type tests - compile-time checks + type TestHandlerMetadata = handlerRegistryExports.HandlerMetadata; + type TestOperationMetadata = handlerRegistryExports.OperationMetadata; + type TestScheduleMetadata = handlerRegistryExports.ScheduleMetadata; + type TestHandlerConfiguration = handlerRegistryExports.HandlerConfiguration; + type TestRegistryStats = handlerRegistryExports.RegistryStats; + type TestHandlerDiscoveryResult = handlerRegistryExports.HandlerDiscoveryResult; + + // Runtime type usage tests + const testHandler: TestHandlerMetadata = { + name: 'TestHandler', + serviceName: 'test-service', + operations: [], + }; + + const testOperation: TestOperationMetadata = { + operationName: 'testOperation', + handlerName: 'TestHandler', + operationPath: 'test.operation', + serviceName: 'test-service', + }; + + const testSchedule: TestScheduleMetadata = { + handlerName: 'TestHandler', + scheduleName: 'test-schedule', + expression: '*/5 * * * *', + serviceName: 'test-service', + }; + + const testConfig: TestHandlerConfiguration = { + handlerName: 'TestHandler', + batchSize: 10, + timeout: 5000, + retries: 3, + }; + + const testStats: TestRegistryStats = { + totalHandlers: 5, + totalOperations: 10, + totalSchedules: 3, + handlersByService: { + service1: 2, + service2: 3, + }, + }; + + const testDiscoveryResult: TestHandlerDiscoveryResult = { + handlers: [testHandler], + operations: [testOperation], + schedules: [testSchedule], + configurations: [testConfig], + }; + + expect(testHandler).toBeDefined(); + expect(testOperation).toBeDefined(); + expect(testSchedule).toBeDefined(); + expect(testConfig).toBeDefined(); + expect(testStats).toBeDefined(); + expect(testDiscoveryResult).toBeDefined(); + }); + + it('should create HandlerRegistry instance', () => { + const registry = new HandlerRegistry(); + expect(registry).toBeInstanceOf(HandlerRegistry); + }); +}); diff --git a/libs/core/handler-registry/test/registry-edge-cases.test.ts b/libs/core/handler-registry/test/registry-edge-cases.test.ts index b2b1bc2..5282da5 100644 --- a/libs/core/handler-registry/test/registry-edge-cases.test.ts +++ b/libs/core/handler-registry/test/registry-edge-cases.test.ts @@ -1,382 +1,380 @@ -import { beforeEach, describe, expect, it, mock } from 'bun:test'; -import { HandlerRegistry } from '../src/registry'; -import type { - HandlerConfiguration, - HandlerMetadata, - OperationMetadata, - ScheduleMetadata, -} from '../src/types'; -import type { JobHandler, ScheduledJob } from '@stock-bot/types'; - -describe('HandlerRegistry Edge Cases', () => { - let registry: HandlerRegistry; - - beforeEach(() => { - registry = new HandlerRegistry(); - }); - - describe('Metadata Edge Cases', () => { - it('should handle metadata without service', () => { - const metadata: HandlerMetadata = { - name: 'NoServiceHandler', - operations: [], - }; - - registry.registerMetadata(metadata); - - expect(registry.getMetadata('NoServiceHandler')).toEqual(metadata); - expect(registry.getHandlerService('NoServiceHandler')).toBeUndefined(); - }); - - it('should handle metadata with optional fields', () => { - const metadata: HandlerMetadata = { - name: 'FullHandler', - service: 'test-service', - operations: [ - { - name: 'op1', - method: 'method1', - description: 'Operation 1', - }, - ], - schedules: [ - { - operation: 'op1', - cronPattern: '*/5 * * * *', - priority: 10, - immediately: true, - description: 'Every 5 minutes', - }, - ], - version: '1.0.0', - description: 'Full handler with all fields', - }; - - registry.registerMetadata(metadata); - - const retrieved = registry.getMetadata('FullHandler'); - expect(retrieved).toEqual(metadata); - expect(retrieved?.version).toBe('1.0.0'); - expect(retrieved?.description).toBe('Full handler with all fields'); - expect(retrieved?.schedules?.[0].immediately).toBe(true); - }); - - it('should handle empty operations array', () => { - const metadata: HandlerMetadata = { - name: 'EmptyHandler', - operations: [], - }; - - registry.registerMetadata(metadata); - - const stats = registry.getStats(); - expect(stats.handlers).toBe(1); - expect(stats.operations).toBe(0); - }); - }); - - describe('Configuration Edge Cases', () => { - it('should handle configuration without scheduled jobs', () => { - const config: HandlerConfiguration = { - name: 'SimpleHandler', - operations: { - process: mock(async () => {}) as JobHandler, - }, - }; - - registry.registerConfiguration(config); - - const scheduledJobs = registry.getScheduledJobs('SimpleHandler'); - expect(scheduledJobs).toEqual([]); - }); - - it('should handle empty operations object', () => { - const config: HandlerConfiguration = { - name: 'EmptyOpsHandler', - operations: {}, - }; - - registry.registerConfiguration(config); - - expect(registry.getOperation('EmptyOpsHandler', 'nonexistent')).toBeUndefined(); - }); - - it('should handle configuration with empty scheduled jobs array', () => { - const config: HandlerConfiguration = { - name: 'NoScheduleHandler', - operations: {}, - scheduledJobs: [], - }; - - registry.registerConfiguration(config); - - const scheduled = registry.getScheduledJobs('NoScheduleHandler'); - expect(scheduled).toEqual([]); - }); - }); - - describe('Service Management Edge Cases', () => { - it('should update metadata when setting handler service', () => { - const metadata: HandlerMetadata = { - name: 'UpdateableHandler', - operations: [], - service: 'old-service', - }; - - registry.registerMetadata(metadata); - registry.setHandlerService('UpdateableHandler', 'new-service'); - - const updated = registry.getMetadata('UpdateableHandler'); - expect(updated?.service).toBe('new-service'); - expect(registry.getHandlerService('UpdateableHandler')).toBe('new-service'); - }); - - it('should set service for non-existent handler', () => { - registry.setHandlerService('NonExistentHandler', 'some-service'); - - expect(registry.getHandlerService('NonExistentHandler')).toBe('some-service'); - expect(registry.getMetadata('NonExistentHandler')).toBeUndefined(); - }); - - it('should return empty array for service with no handlers', () => { - const handlers = registry.getServiceHandlers('non-existent-service'); - expect(handlers).toEqual([]); - }); - - it('should handle multiple handlers for same service', () => { - const metadata1: HandlerMetadata = { - name: 'Handler1', - service: 'shared-service', - operations: [], - }; - const metadata2: HandlerMetadata = { - name: 'Handler2', - service: 'shared-service', - operations: [], - }; - const metadata3: HandlerMetadata = { - name: 'Handler3', - service: 'other-service', - operations: [], - }; - - registry.registerMetadata(metadata1); - registry.registerMetadata(metadata2); - registry.registerMetadata(metadata3); - - const sharedHandlers = registry.getServiceHandlers('shared-service'); - expect(sharedHandlers).toHaveLength(2); - expect(sharedHandlers.map(h => h.name).sort()).toEqual(['Handler1', 'Handler2']); - }); - }); - - describe('Operation Access Edge Cases', () => { - it('should return undefined for non-existent handler operation', () => { - const op = registry.getOperation('NonExistent', 'operation'); - expect(op).toBeUndefined(); - }); - - it('should return undefined for non-existent operation name', () => { - const config: HandlerConfiguration = { - name: 'TestHandler', - operations: { - exists: mock(async () => {}) as JobHandler, - }, - }; - - registry.registerConfiguration(config); - - const op = registry.getOperation('TestHandler', 'notexists'); - expect(op).toBeUndefined(); - }); - }); - - describe('getAllHandlersWithSchedule Edge Cases', () => { - it('should handle mix of handlers with and without schedules', () => { - const metadata1: HandlerMetadata = { - name: 'WithSchedule', - operations: [], - }; - const config1: HandlerConfiguration = { - name: 'WithSchedule', - operations: {}, - scheduledJobs: [ - { - name: 'job1', - handler: mock(async () => {}) as JobHandler, - pattern: '* * * * *', - } as ScheduledJob, - ], - }; - - const metadata2: HandlerMetadata = { - name: 'WithoutSchedule', - operations: [], - }; - const config2: HandlerConfiguration = { - name: 'WithoutSchedule', - operations: {}, - }; - - registry.register(metadata1, config1); - registry.register(metadata2, config2); - - const allWithSchedule = registry.getAllHandlersWithSchedule(); - expect(allWithSchedule.size).toBe(2); - - const withSchedule = allWithSchedule.get('WithSchedule'); - expect(withSchedule?.scheduledJobs).toHaveLength(1); - - const withoutSchedule = allWithSchedule.get('WithoutSchedule'); - expect(withoutSchedule?.scheduledJobs).toEqual([]); - }); - - it('should handle handler with metadata but no configuration', () => { - const metadata: HandlerMetadata = { - name: 'MetadataOnly', - operations: [], - }; - - registry.registerMetadata(metadata); - - const allWithSchedule = registry.getAllHandlersWithSchedule(); - const handler = allWithSchedule.get('MetadataOnly'); - - expect(handler?.metadata).toEqual(metadata); - expect(handler?.scheduledJobs).toEqual([]); - }); - }); - - describe('Import/Export Edge Cases', () => { - it('should handle empty export', () => { - const exported = registry.export(); - - expect(exported.handlers).toEqual([]); - expect(exported.configurations).toEqual([]); - expect(exported.services).toEqual([]); - }); - - it('should handle empty import', () => { - // Add some data first - registry.registerMetadata({ - name: 'ExistingHandler', - operations: [], - }); - - // Import empty data - registry.import({ - handlers: [], - configurations: [], - services: [], - }); - - expect(registry.getHandlerNames()).toEqual([]); - }); - - it('should preserve complex data through export/import cycle', () => { - const metadata: HandlerMetadata = { - name: 'ComplexHandler', - service: 'complex-service', - operations: [ - { name: 'op1', method: 'method1' }, - { name: 'op2', method: 'method2' }, - ], - schedules: [ - { - operation: 'op1', - cronPattern: '0 * * * *', - }, - ], - }; - - const handler = mock(async () => {}) as JobHandler; - const config: HandlerConfiguration = { - name: 'ComplexHandler', - operations: { - op1: handler, - op2: handler, - }, - scheduledJobs: [ - { - name: 'scheduled1', - handler, - pattern: '0 * * * *', - } as ScheduledJob, - ], - }; - - registry.register(metadata, config); - registry.setHandlerService('ComplexHandler', 'overridden-service'); - - const exported = registry.export(); - - // Create new registry and import - const newRegistry = new HandlerRegistry(); - newRegistry.import(exported); - - expect(newRegistry.getMetadata('ComplexHandler')).toEqual(metadata); - expect(newRegistry.getConfiguration('ComplexHandler')).toEqual(config); - expect(newRegistry.getHandlerService('ComplexHandler')).toBe('overridden-service'); - }); - }); - - describe('Statistics Edge Cases', () => { - it('should count schedules from metadata', () => { - const metadata: HandlerMetadata = { - name: 'ScheduledHandler', - operations: [ - { name: 'op1', method: 'method1' }, - ], - schedules: [ - { operation: 'op1', cronPattern: '* * * * *' }, - { operation: 'op1', cronPattern: '0 * * * *' }, - ], - }; - - registry.registerMetadata(metadata); - - const stats = registry.getStats(); - expect(stats.handlers).toBe(1); - expect(stats.operations).toBe(1); - expect(stats.scheduledJobs).toBe(2); - expect(stats.services).toBe(0); // No service specified - }); - - it('should not double count services', () => { - registry.registerMetadata({ - name: 'Handler1', - service: 'service1', - operations: [], - }); - - registry.registerMetadata({ - name: 'Handler2', - service: 'service1', // Same service - operations: [], - }); - - registry.registerMetadata({ - name: 'Handler3', - service: 'service2', - operations: [], - }); - - const stats = registry.getStats(); - expect(stats.services).toBe(2); // Only 2 unique services - }); - }); - - describe('Error Scenarios', () => { - it('should handle undefined values gracefully', () => { - expect(registry.getMetadata(undefined as any)).toBeUndefined(); - expect(registry.getConfiguration(undefined as any)).toBeUndefined(); - expect(registry.getOperation(undefined as any, 'op')).toBeUndefined(); - expect(registry.hasHandler(undefined as any)).toBe(false); - }); - - it('should handle null service lookup', () => { - const handlers = registry.getServiceHandlers(null as any); - expect(handlers).toEqual([]); - }); - }); -}); \ No newline at end of file +import { beforeEach, describe, expect, it, mock } from 'bun:test'; +import type { JobHandler, ScheduledJob } from '@stock-bot/types'; +import { HandlerRegistry } from '../src/registry'; +import type { + HandlerConfiguration, + HandlerMetadata, + OperationMetadata, + ScheduleMetadata, +} from '../src/types'; + +describe('HandlerRegistry Edge Cases', () => { + let registry: HandlerRegistry; + + beforeEach(() => { + registry = new HandlerRegistry(); + }); + + describe('Metadata Edge Cases', () => { + it('should handle metadata without service', () => { + const metadata: HandlerMetadata = { + name: 'NoServiceHandler', + operations: [], + }; + + registry.registerMetadata(metadata); + + expect(registry.getMetadata('NoServiceHandler')).toEqual(metadata); + expect(registry.getHandlerService('NoServiceHandler')).toBeUndefined(); + }); + + it('should handle metadata with optional fields', () => { + const metadata: HandlerMetadata = { + name: 'FullHandler', + service: 'test-service', + operations: [ + { + name: 'op1', + method: 'method1', + description: 'Operation 1', + }, + ], + schedules: [ + { + operation: 'op1', + cronPattern: '*/5 * * * *', + priority: 10, + immediately: true, + description: 'Every 5 minutes', + }, + ], + version: '1.0.0', + description: 'Full handler with all fields', + }; + + registry.registerMetadata(metadata); + + const retrieved = registry.getMetadata('FullHandler'); + expect(retrieved).toEqual(metadata); + expect(retrieved?.version).toBe('1.0.0'); + expect(retrieved?.description).toBe('Full handler with all fields'); + expect(retrieved?.schedules?.[0].immediately).toBe(true); + }); + + it('should handle empty operations array', () => { + const metadata: HandlerMetadata = { + name: 'EmptyHandler', + operations: [], + }; + + registry.registerMetadata(metadata); + + const stats = registry.getStats(); + expect(stats.handlers).toBe(1); + expect(stats.operations).toBe(0); + }); + }); + + describe('Configuration Edge Cases', () => { + it('should handle configuration without scheduled jobs', () => { + const config: HandlerConfiguration = { + name: 'SimpleHandler', + operations: { + process: mock(async () => {}) as JobHandler, + }, + }; + + registry.registerConfiguration(config); + + const scheduledJobs = registry.getScheduledJobs('SimpleHandler'); + expect(scheduledJobs).toEqual([]); + }); + + it('should handle empty operations object', () => { + const config: HandlerConfiguration = { + name: 'EmptyOpsHandler', + operations: {}, + }; + + registry.registerConfiguration(config); + + expect(registry.getOperation('EmptyOpsHandler', 'nonexistent')).toBeUndefined(); + }); + + it('should handle configuration with empty scheduled jobs array', () => { + const config: HandlerConfiguration = { + name: 'NoScheduleHandler', + operations: {}, + scheduledJobs: [], + }; + + registry.registerConfiguration(config); + + const scheduled = registry.getScheduledJobs('NoScheduleHandler'); + expect(scheduled).toEqual([]); + }); + }); + + describe('Service Management Edge Cases', () => { + it('should update metadata when setting handler service', () => { + const metadata: HandlerMetadata = { + name: 'UpdateableHandler', + operations: [], + service: 'old-service', + }; + + registry.registerMetadata(metadata); + registry.setHandlerService('UpdateableHandler', 'new-service'); + + const updated = registry.getMetadata('UpdateableHandler'); + expect(updated?.service).toBe('new-service'); + expect(registry.getHandlerService('UpdateableHandler')).toBe('new-service'); + }); + + it('should set service for non-existent handler', () => { + registry.setHandlerService('NonExistentHandler', 'some-service'); + + expect(registry.getHandlerService('NonExistentHandler')).toBe('some-service'); + expect(registry.getMetadata('NonExistentHandler')).toBeUndefined(); + }); + + it('should return empty array for service with no handlers', () => { + const handlers = registry.getServiceHandlers('non-existent-service'); + expect(handlers).toEqual([]); + }); + + it('should handle multiple handlers for same service', () => { + const metadata1: HandlerMetadata = { + name: 'Handler1', + service: 'shared-service', + operations: [], + }; + const metadata2: HandlerMetadata = { + name: 'Handler2', + service: 'shared-service', + operations: [], + }; + const metadata3: HandlerMetadata = { + name: 'Handler3', + service: 'other-service', + operations: [], + }; + + registry.registerMetadata(metadata1); + registry.registerMetadata(metadata2); + registry.registerMetadata(metadata3); + + const sharedHandlers = registry.getServiceHandlers('shared-service'); + expect(sharedHandlers).toHaveLength(2); + expect(sharedHandlers.map(h => h.name).sort()).toEqual(['Handler1', 'Handler2']); + }); + }); + + describe('Operation Access Edge Cases', () => { + it('should return undefined for non-existent handler operation', () => { + const op = registry.getOperation('NonExistent', 'operation'); + expect(op).toBeUndefined(); + }); + + it('should return undefined for non-existent operation name', () => { + const config: HandlerConfiguration = { + name: 'TestHandler', + operations: { + exists: mock(async () => {}) as JobHandler, + }, + }; + + registry.registerConfiguration(config); + + const op = registry.getOperation('TestHandler', 'notexists'); + expect(op).toBeUndefined(); + }); + }); + + describe('getAllHandlersWithSchedule Edge Cases', () => { + it('should handle mix of handlers with and without schedules', () => { + const metadata1: HandlerMetadata = { + name: 'WithSchedule', + operations: [], + }; + const config1: HandlerConfiguration = { + name: 'WithSchedule', + operations: {}, + scheduledJobs: [ + { + name: 'job1', + handler: mock(async () => {}) as JobHandler, + pattern: '* * * * *', + } as ScheduledJob, + ], + }; + + const metadata2: HandlerMetadata = { + name: 'WithoutSchedule', + operations: [], + }; + const config2: HandlerConfiguration = { + name: 'WithoutSchedule', + operations: {}, + }; + + registry.register(metadata1, config1); + registry.register(metadata2, config2); + + const allWithSchedule = registry.getAllHandlersWithSchedule(); + expect(allWithSchedule.size).toBe(2); + + const withSchedule = allWithSchedule.get('WithSchedule'); + expect(withSchedule?.scheduledJobs).toHaveLength(1); + + const withoutSchedule = allWithSchedule.get('WithoutSchedule'); + expect(withoutSchedule?.scheduledJobs).toEqual([]); + }); + + it('should handle handler with metadata but no configuration', () => { + const metadata: HandlerMetadata = { + name: 'MetadataOnly', + operations: [], + }; + + registry.registerMetadata(metadata); + + const allWithSchedule = registry.getAllHandlersWithSchedule(); + const handler = allWithSchedule.get('MetadataOnly'); + + expect(handler?.metadata).toEqual(metadata); + expect(handler?.scheduledJobs).toEqual([]); + }); + }); + + describe('Import/Export Edge Cases', () => { + it('should handle empty export', () => { + const exported = registry.export(); + + expect(exported.handlers).toEqual([]); + expect(exported.configurations).toEqual([]); + expect(exported.services).toEqual([]); + }); + + it('should handle empty import', () => { + // Add some data first + registry.registerMetadata({ + name: 'ExistingHandler', + operations: [], + }); + + // Import empty data + registry.import({ + handlers: [], + configurations: [], + services: [], + }); + + expect(registry.getHandlerNames()).toEqual([]); + }); + + it('should preserve complex data through export/import cycle', () => { + const metadata: HandlerMetadata = { + name: 'ComplexHandler', + service: 'complex-service', + operations: [ + { name: 'op1', method: 'method1' }, + { name: 'op2', method: 'method2' }, + ], + schedules: [ + { + operation: 'op1', + cronPattern: '0 * * * *', + }, + ], + }; + + const handler = mock(async () => {}) as JobHandler; + const config: HandlerConfiguration = { + name: 'ComplexHandler', + operations: { + op1: handler, + op2: handler, + }, + scheduledJobs: [ + { + name: 'scheduled1', + handler, + pattern: '0 * * * *', + } as ScheduledJob, + ], + }; + + registry.register(metadata, config); + registry.setHandlerService('ComplexHandler', 'overridden-service'); + + const exported = registry.export(); + + // Create new registry and import + const newRegistry = new HandlerRegistry(); + newRegistry.import(exported); + + expect(newRegistry.getMetadata('ComplexHandler')).toEqual(metadata); + expect(newRegistry.getConfiguration('ComplexHandler')).toEqual(config); + expect(newRegistry.getHandlerService('ComplexHandler')).toBe('overridden-service'); + }); + }); + + describe('Statistics Edge Cases', () => { + it('should count schedules from metadata', () => { + const metadata: HandlerMetadata = { + name: 'ScheduledHandler', + operations: [{ name: 'op1', method: 'method1' }], + schedules: [ + { operation: 'op1', cronPattern: '* * * * *' }, + { operation: 'op1', cronPattern: '0 * * * *' }, + ], + }; + + registry.registerMetadata(metadata); + + const stats = registry.getStats(); + expect(stats.handlers).toBe(1); + expect(stats.operations).toBe(1); + expect(stats.scheduledJobs).toBe(2); + expect(stats.services).toBe(0); // No service specified + }); + + it('should not double count services', () => { + registry.registerMetadata({ + name: 'Handler1', + service: 'service1', + operations: [], + }); + + registry.registerMetadata({ + name: 'Handler2', + service: 'service1', // Same service + operations: [], + }); + + registry.registerMetadata({ + name: 'Handler3', + service: 'service2', + operations: [], + }); + + const stats = registry.getStats(); + expect(stats.services).toBe(2); // Only 2 unique services + }); + }); + + describe('Error Scenarios', () => { + it('should handle undefined values gracefully', () => { + expect(registry.getMetadata(undefined as any)).toBeUndefined(); + expect(registry.getConfiguration(undefined as any)).toBeUndefined(); + expect(registry.getOperation(undefined as any, 'op')).toBeUndefined(); + expect(registry.hasHandler(undefined as any)).toBe(false); + }); + + it('should handle null service lookup', () => { + const handlers = registry.getServiceHandlers(null as any); + expect(handlers).toEqual([]); + }); + }); +}); diff --git a/libs/core/handlers/src/index.ts b/libs/core/handlers/src/index.ts index 73c3d19..95e2f0a 100644 --- a/libs/core/handlers/src/index.ts +++ b/libs/core/handlers/src/index.ts @@ -6,4 +6,4 @@ export { QueueSchedule, ScheduledOperation, Disabled, -} from './decorators/decorators'; \ No newline at end of file +} from './decorators/decorators'; diff --git a/libs/core/handlers/test/auto-register-simple.test.ts b/libs/core/handlers/test/auto-register-simple.test.ts index 0560aac..9763c4f 100644 --- a/libs/core/handlers/test/auto-register-simple.test.ts +++ b/libs/core/handlers/test/auto-register-simple.test.ts @@ -1,78 +1,75 @@ -import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; -import { autoRegisterHandlers, createAutoHandlerRegistry } from '../src/registry/auto-register'; -import { BaseHandler } from '../src/base/BaseHandler'; -import type { IServiceContainer } from '@stock-bot/types'; - -describe('Auto Registration - Simple Tests', () => { - describe('autoRegisterHandlers', () => { - it('should return empty results for non-existent directory', async () => { - const mockServices = {} as IServiceContainer; - const result = await autoRegisterHandlers('./non-existent-directory', mockServices); - - expect(result.registered).toEqual([]); - expect(result.failed).toEqual([]); - }); - - it('should handle directory with no handler files', async () => { - const mockServices = {} as IServiceContainer; - // Use the test directory itself which has no handler files - const result = await autoRegisterHandlers('./test', mockServices); - - expect(result.registered).toEqual([]); - expect(result.failed).toEqual([]); - }); - - it('should support dry run mode', async () => { - const mockServices = {} as IServiceContainer; - const result = await autoRegisterHandlers('./non-existent', mockServices, { dryRun: true }); - - expect(result.registered).toEqual([]); - expect(result.failed).toEqual([]); - }); - - it('should handle excluded patterns', async () => { - const mockServices = {} as IServiceContainer; - const result = await autoRegisterHandlers('./test', mockServices, { - exclude: ['test'] - }); - - expect(result.registered).toEqual([]); - expect(result.failed).toEqual([]); - }); - - it('should accept custom pattern', async () => { - const mockServices = {} as IServiceContainer; - const result = await autoRegisterHandlers('./test', mockServices, { - pattern: '.custom.' - }); - - expect(result.registered).toEqual([]); - expect(result.failed).toEqual([]); - }); - }); - - describe('createAutoHandlerRegistry', () => { - it('should create registry with registerDirectory method', () => { - const mockServices = {} as IServiceContainer; - const registry = createAutoHandlerRegistry(mockServices); - - expect(registry).toHaveProperty('registerDirectory'); - expect(registry).toHaveProperty('registerDirectories'); - expect(typeof registry.registerDirectory).toBe('function'); - expect(typeof registry.registerDirectories).toBe('function'); - }); - - it('should register from multiple directories', async () => { - const mockServices = {} as IServiceContainer; - const registry = createAutoHandlerRegistry(mockServices); - - const result = await registry.registerDirectories([ - './non-existent-1', - './non-existent-2' - ]); - - expect(result.registered).toEqual([]); - expect(result.failed).toEqual([]); - }); - }); -}); \ No newline at end of file +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import type { IServiceContainer } from '@stock-bot/types'; +import { BaseHandler } from '../src/base/BaseHandler'; +import { autoRegisterHandlers, createAutoHandlerRegistry } from '../src/registry/auto-register'; + +describe('Auto Registration - Simple Tests', () => { + describe('autoRegisterHandlers', () => { + it('should return empty results for non-existent directory', async () => { + const mockServices = {} as IServiceContainer; + const result = await autoRegisterHandlers('./non-existent-directory', mockServices); + + expect(result.registered).toEqual([]); + expect(result.failed).toEqual([]); + }); + + it('should handle directory with no handler files', async () => { + const mockServices = {} as IServiceContainer; + // Use the test directory itself which has no handler files + const result = await autoRegisterHandlers('./test', mockServices); + + expect(result.registered).toEqual([]); + expect(result.failed).toEqual([]); + }); + + it('should support dry run mode', async () => { + const mockServices = {} as IServiceContainer; + const result = await autoRegisterHandlers('./non-existent', mockServices, { dryRun: true }); + + expect(result.registered).toEqual([]); + expect(result.failed).toEqual([]); + }); + + it('should handle excluded patterns', async () => { + const mockServices = {} as IServiceContainer; + const result = await autoRegisterHandlers('./test', mockServices, { + exclude: ['test'], + }); + + expect(result.registered).toEqual([]); + expect(result.failed).toEqual([]); + }); + + it('should accept custom pattern', async () => { + const mockServices = {} as IServiceContainer; + const result = await autoRegisterHandlers('./test', mockServices, { + pattern: '.custom.', + }); + + expect(result.registered).toEqual([]); + expect(result.failed).toEqual([]); + }); + }); + + describe('createAutoHandlerRegistry', () => { + it('should create registry with registerDirectory method', () => { + const mockServices = {} as IServiceContainer; + const registry = createAutoHandlerRegistry(mockServices); + + expect(registry).toHaveProperty('registerDirectory'); + expect(registry).toHaveProperty('registerDirectories'); + expect(typeof registry.registerDirectory).toBe('function'); + expect(typeof registry.registerDirectories).toBe('function'); + }); + + it('should register from multiple directories', async () => { + const mockServices = {} as IServiceContainer; + const registry = createAutoHandlerRegistry(mockServices); + + const result = await registry.registerDirectories(['./non-existent-1', './non-existent-2']); + + expect(result.registered).toEqual([]); + expect(result.failed).toEqual([]); + }); + }); +}); diff --git a/libs/core/handlers/test/auto-register-unit.test.ts b/libs/core/handlers/test/auto-register-unit.test.ts index 0d1ba28..b382e89 100644 --- a/libs/core/handlers/test/auto-register-unit.test.ts +++ b/libs/core/handlers/test/auto-register-unit.test.ts @@ -1,219 +1,204 @@ -import { describe, expect, it, mock } from 'bun:test'; -import { BaseHandler } from '../src/base/BaseHandler'; - -// Test the internal functions by mocking module imports -describe('Auto Registration Unit Tests', () => { - describe('extractHandlerClasses', () => { - it('should extract handler classes from module', () => { - // Test handler class - class TestHandler extends BaseHandler {} - class AnotherHandler extends BaseHandler {} - class NotAHandler {} - - const module = { - TestHandler, - AnotherHandler, - NotAHandler, - someFunction: () => {}, - someVariable: 42, - }; - - // Access the private function through module internals - const autoRegister = require('../src/registry/auto-register'); - - // Mock the extractHandlerClasses function behavior - const handlers = []; - for (const key of Object.keys(module)) { - const exported = module[key]; - if ( - typeof exported === 'function' && - exported.prototype && - exported.prototype instanceof BaseHandler - ) { - handlers.push(exported); - } - } - - expect(handlers).toHaveLength(2); - expect(handlers).toContain(TestHandler); - expect(handlers).toContain(AnotherHandler); - expect(handlers).not.toContain(NotAHandler); - }); - }); - - describe('findHandlerFiles', () => { - it('should filter files by pattern', () => { - const files = [ - 'test.handler.ts', - 'test.service.ts', - 'another.handler.ts', - 'test.handler.js', - '.hidden.handler.ts', - ]; - - const pattern = '.handler.'; - const filtered = files.filter(file => - file.includes(pattern) && - file.endsWith('.ts') && - !file.startsWith('.') - ); - - expect(filtered).toEqual(['test.handler.ts', 'another.handler.ts']); - }); - - it('should handle different patterns', () => { - const files = [ - 'test.handler.ts', - 'test.custom.ts', - 'another.custom.ts', - ]; - - const customPattern = '.custom.'; - const filtered = files.filter(file => - file.includes(customPattern) && - file.endsWith('.ts') - ); - - expect(filtered).toEqual(['test.custom.ts', 'another.custom.ts']); - }); - }); - - describe('Handler Registration Logic', () => { - it('should skip disabled handlers', () => { - class DisabledHandler extends BaseHandler { - static __disabled = true; - } - - class EnabledHandler extends BaseHandler {} - - const handlers = [DisabledHandler, EnabledHandler]; - const registered = handlers.filter(h => !(h as any).__disabled); - - expect(registered).toHaveLength(1); - expect(registered).toContain(EnabledHandler); - expect(registered).not.toContain(DisabledHandler); - }); - - it('should handle handler with auto-registration flag', () => { - class AutoRegisterHandler extends BaseHandler { - static __handlerName = 'auto-handler'; - static __needsAutoRegistration = true; - } - - expect((AutoRegisterHandler as any).__needsAutoRegistration).toBe(true); - expect((AutoRegisterHandler as any).__handlerName).toBe('auto-handler'); - }); - - it('should create handler instance with services', () => { - const mockServices = { - cache: null, - globalCache: null, - queueManager: null, - proxy: null, - browser: null, - mongodb: null, - postgres: null, - questdb: null, - } as any; - - class TestHandler extends BaseHandler {} - - const instance = new TestHandler(mockServices); - expect(instance).toBeInstanceOf(BaseHandler); - }); - }); - - describe('Error Handling', () => { - it('should handle module import errors gracefully', () => { - const errors = []; - const modules = ['valid', 'error', 'another']; - - for (const mod of modules) { - try { - if (mod === 'error') { - throw new Error('Module not found'); - } - // Process module - } catch (error) { - errors.push(mod); - } - } - - expect(errors).toEqual(['error']); - }); - - it('should handle filesystem errors', () => { - let result; - try { - // Simulate filesystem error - throw new Error('EACCES: permission denied'); - } catch (error) { - // Should handle gracefully - result = { registered: [], failed: [] }; - } - - expect(result).toEqual({ registered: [], failed: [] }); - }); - }); - - describe('Options Handling', () => { - it('should apply exclude patterns', () => { - const files = [ - 'test.handler.ts', - 'excluded.handler.ts', - 'another.handler.ts', - ]; - const exclude = ['excluded']; - - const filtered = files.filter(file => - !exclude.some(ex => file.includes(ex)) - ); - - expect(filtered).toEqual(['test.handler.ts', 'another.handler.ts']); - }); - - it('should handle service name option', () => { - const options = { - pattern: '.handler.', - exclude: [], - dryRun: false, - serviceName: 'test-service', - }; - - expect(options.serviceName).toBe('test-service'); - }); - - it('should handle dry run mode', () => { - const options = { dryRun: true }; - const actions = []; - - if (options.dryRun) { - actions.push('[DRY RUN] Would register handler'); - } else { - actions.push('Registering handler'); - } - - expect(actions).toEqual(['[DRY RUN] Would register handler']); - }); - }); - - describe('Registry Methods', () => { - it('should handle multiple directories', () => { - const directories = ['./dir1', './dir2', './dir3']; - const results = { - registered: [] as string[], - failed: [] as string[], - }; - - for (const dir of directories) { - // Simulate processing each directory - results.registered.push(`${dir}-handler`); - } - - expect(results.registered).toHaveLength(3); - expect(results.registered).toContain('./dir1-handler'); - expect(results.registered).toContain('./dir2-handler'); - expect(results.registered).toContain('./dir3-handler'); - }); - }); -}); \ No newline at end of file +import { describe, expect, it, mock } from 'bun:test'; +import { BaseHandler } from '../src/base/BaseHandler'; + +// Test the internal functions by mocking module imports +describe('Auto Registration Unit Tests', () => { + describe('extractHandlerClasses', () => { + it('should extract handler classes from module', () => { + // Test handler class + class TestHandler extends BaseHandler {} + class AnotherHandler extends BaseHandler {} + class NotAHandler {} + + const module = { + TestHandler, + AnotherHandler, + NotAHandler, + someFunction: () => {}, + someVariable: 42, + }; + + // Access the private function through module internals + const autoRegister = require('../src/registry/auto-register'); + + // Mock the extractHandlerClasses function behavior + const handlers = []; + for (const key of Object.keys(module)) { + const exported = module[key]; + if ( + typeof exported === 'function' && + exported.prototype && + exported.prototype instanceof BaseHandler + ) { + handlers.push(exported); + } + } + + expect(handlers).toHaveLength(2); + expect(handlers).toContain(TestHandler); + expect(handlers).toContain(AnotherHandler); + expect(handlers).not.toContain(NotAHandler); + }); + }); + + describe('findHandlerFiles', () => { + it('should filter files by pattern', () => { + const files = [ + 'test.handler.ts', + 'test.service.ts', + 'another.handler.ts', + 'test.handler.js', + '.hidden.handler.ts', + ]; + + const pattern = '.handler.'; + const filtered = files.filter( + file => file.includes(pattern) && file.endsWith('.ts') && !file.startsWith('.') + ); + + expect(filtered).toEqual(['test.handler.ts', 'another.handler.ts']); + }); + + it('should handle different patterns', () => { + const files = ['test.handler.ts', 'test.custom.ts', 'another.custom.ts']; + + const customPattern = '.custom.'; + const filtered = files.filter(file => file.includes(customPattern) && file.endsWith('.ts')); + + expect(filtered).toEqual(['test.custom.ts', 'another.custom.ts']); + }); + }); + + describe('Handler Registration Logic', () => { + it('should skip disabled handlers', () => { + class DisabledHandler extends BaseHandler { + static __disabled = true; + } + + class EnabledHandler extends BaseHandler {} + + const handlers = [DisabledHandler, EnabledHandler]; + const registered = handlers.filter(h => !(h as any).__disabled); + + expect(registered).toHaveLength(1); + expect(registered).toContain(EnabledHandler); + expect(registered).not.toContain(DisabledHandler); + }); + + it('should handle handler with auto-registration flag', () => { + class AutoRegisterHandler extends BaseHandler { + static __handlerName = 'auto-handler'; + static __needsAutoRegistration = true; + } + + expect((AutoRegisterHandler as any).__needsAutoRegistration).toBe(true); + expect((AutoRegisterHandler as any).__handlerName).toBe('auto-handler'); + }); + + it('should create handler instance with services', () => { + const mockServices = { + cache: null, + globalCache: null, + queueManager: null, + proxy: null, + browser: null, + mongodb: null, + postgres: null, + questdb: null, + } as any; + + class TestHandler extends BaseHandler {} + + const instance = new TestHandler(mockServices); + expect(instance).toBeInstanceOf(BaseHandler); + }); + }); + + describe('Error Handling', () => { + it('should handle module import errors gracefully', () => { + const errors = []; + const modules = ['valid', 'error', 'another']; + + for (const mod of modules) { + try { + if (mod === 'error') { + throw new Error('Module not found'); + } + // Process module + } catch (error) { + errors.push(mod); + } + } + + expect(errors).toEqual(['error']); + }); + + it('should handle filesystem errors', () => { + let result; + try { + // Simulate filesystem error + throw new Error('EACCES: permission denied'); + } catch (error) { + // Should handle gracefully + result = { registered: [], failed: [] }; + } + + expect(result).toEqual({ registered: [], failed: [] }); + }); + }); + + describe('Options Handling', () => { + it('should apply exclude patterns', () => { + const files = ['test.handler.ts', 'excluded.handler.ts', 'another.handler.ts']; + const exclude = ['excluded']; + + const filtered = files.filter(file => !exclude.some(ex => file.includes(ex))); + + expect(filtered).toEqual(['test.handler.ts', 'another.handler.ts']); + }); + + it('should handle service name option', () => { + const options = { + pattern: '.handler.', + exclude: [], + dryRun: false, + serviceName: 'test-service', + }; + + expect(options.serviceName).toBe('test-service'); + }); + + it('should handle dry run mode', () => { + const options = { dryRun: true }; + const actions = []; + + if (options.dryRun) { + actions.push('[DRY RUN] Would register handler'); + } else { + actions.push('Registering handler'); + } + + expect(actions).toEqual(['[DRY RUN] Would register handler']); + }); + }); + + describe('Registry Methods', () => { + it('should handle multiple directories', () => { + const directories = ['./dir1', './dir2', './dir3']; + const results = { + registered: [] as string[], + failed: [] as string[], + }; + + for (const dir of directories) { + // Simulate processing each directory + results.registered.push(`${dir}-handler`); + } + + expect(results.registered).toHaveLength(3); + expect(results.registered).toContain('./dir1-handler'); + expect(results.registered).toContain('./dir2-handler'); + expect(results.registered).toContain('./dir3-handler'); + }); + }); +}); diff --git a/libs/core/handlers/test/auto-register.test.ts b/libs/core/handlers/test/auto-register.test.ts index 0b7a54c..460c2e3 100644 --- a/libs/core/handlers/test/auto-register.test.ts +++ b/libs/core/handlers/test/auto-register.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeEach, mock } from 'bun:test'; -import { autoRegisterHandlers, createAutoHandlerRegistry } from '../src/registry/auto-register'; -import { BaseHandler } from '../src/base/BaseHandler'; +import { beforeEach, describe, expect, it, mock } from 'bun:test'; import type { IServiceContainer } from '@stock-bot/types'; +import { BaseHandler } from '../src/base/BaseHandler'; +import { autoRegisterHandlers, createAutoHandlerRegistry } from '../src/registry/auto-register'; describe('Auto Registration', () => { describe('autoRegisterHandlers', () => { @@ -9,7 +9,7 @@ describe('Auto Registration', () => { const mockServices = {} as IServiceContainer; // Using a directory that doesn't exist - the function handles this gracefully const result = await autoRegisterHandlers('./non-existent', mockServices); - + expect(result).toHaveProperty('registered'); expect(result).toHaveProperty('failed'); expect(result.registered).toEqual([]); @@ -19,7 +19,7 @@ describe('Auto Registration', () => { it('should use default options when not provided', async () => { const mockServices = {} as IServiceContainer; const result = await autoRegisterHandlers('./test', mockServices); - + expect(result).toBeDefined(); expect(result.registered).toBeInstanceOf(Array); expect(result.failed).toBeInstanceOf(Array); @@ -27,7 +27,7 @@ describe('Auto Registration', () => { it('should handle directory not found gracefully', async () => { const mockServices = {} as IServiceContainer; - + // Should not throw for non-existent directory const result = await autoRegisterHandlers('./non-existent-directory', mockServices); expect(result.registered).toEqual([]); @@ -39,7 +39,7 @@ describe('Auto Registration', () => { it('should create a registry with registerDirectory method', () => { const mockServices = {} as IServiceContainer; const registry = createAutoHandlerRegistry(mockServices); - + expect(registry).toHaveProperty('registerDirectory'); expect(typeof registry.registerDirectory).toBe('function'); }); @@ -47,7 +47,7 @@ describe('Auto Registration', () => { it('should register from a directory', async () => { const mockServices = {} as IServiceContainer; const registry = createAutoHandlerRegistry(mockServices); - + const result = await registry.registerDirectory('./non-existent-dir'); expect(result).toHaveProperty('registered'); expect(result).toHaveProperty('failed'); @@ -56,7 +56,7 @@ describe('Auto Registration', () => { it('should register from multiple directories', async () => { const mockServices = {} as IServiceContainer; const registry = createAutoHandlerRegistry(mockServices); - + const result = await registry.registerDirectories(['./dir1', './dir2']); expect(result).toHaveProperty('registered'); expect(result).toHaveProperty('failed'); @@ -68,7 +68,7 @@ describe('Auto Registration', () => { describe('Edge Cases', () => { it('should handle non-existent directories gracefully', async () => { const mockServices = {} as any; - + // Should not throw, just return empty results const result = await autoRegisterHandlers('./definitely-does-not-exist-12345', mockServices); expect(result.registered).toEqual([]); @@ -77,7 +77,7 @@ describe('Auto Registration', () => { it('should handle empty options', async () => { const mockServices = {} as any; - + // Should use default options const result = await autoRegisterHandlers('./test', mockServices, {}); expect(result).toBeDefined(); @@ -87,18 +87,18 @@ describe('Auto Registration', () => { it('should support service name in options', async () => { const mockServices = {} as any; - + const result = await autoRegisterHandlers('./test', mockServices, { - serviceName: 'test-service' + serviceName: 'test-service', }); - + expect(result).toBeDefined(); }); it('should handle dry run mode', async () => { const mockServices = {} as any; const result = await autoRegisterHandlers('./test', mockServices, { dryRun: true }); - + expect(result).toBeDefined(); expect(result.registered).toBeInstanceOf(Array); expect(result.failed).toBeInstanceOf(Array); @@ -106,10 +106,10 @@ describe('Auto Registration', () => { it('should handle excluded files', async () => { const mockServices = {} as any; - const result = await autoRegisterHandlers('./test', mockServices, { - exclude: ['test'] + const result = await autoRegisterHandlers('./test', mockServices, { + exclude: ['test'], }); - + expect(result).toBeDefined(); expect(result.registered).toBeInstanceOf(Array); expect(result.failed).toBeInstanceOf(Array); @@ -118,7 +118,7 @@ describe('Auto Registration', () => { it('should handle custom pattern', async () => { const mockServices = {} as any; const result = await autoRegisterHandlers('./test', mockServices, { pattern: '.custom.' }); - + expect(result).toBeDefined(); expect(result.registered).toBeInstanceOf(Array); expect(result.failed).toBeInstanceOf(Array); @@ -126,13 +126,13 @@ describe('Auto Registration', () => { it('should handle errors gracefully', async () => { const mockServices = {} as any; - + // Even with a protected directory, it should handle gracefully const result = await autoRegisterHandlers('./protected-dir', mockServices); - + expect(result).toBeDefined(); expect(result.registered).toBeInstanceOf(Array); expect(result.failed).toBeInstanceOf(Array); }); }); -}); \ No newline at end of file +}); diff --git a/libs/core/handlers/test/base-handler-config.test.ts b/libs/core/handlers/test/base-handler-config.test.ts index 595c800..df091c1 100644 --- a/libs/core/handlers/test/base-handler-config.test.ts +++ b/libs/core/handlers/test/base-handler-config.test.ts @@ -1,215 +1,215 @@ -import { describe, it, expect, beforeEach, mock } from 'bun:test'; -import { BaseHandler } from '../src/base/BaseHandler'; -import type { IServiceContainer, ExecutionContext } from '@stock-bot/types'; - -// Test handler with metadata -class ConfigTestHandler extends BaseHandler { - static __handlerName = 'config-test'; - static __operations = [ - { name: 'process', method: 'processData' }, - { name: 'validate', method: 'validateData' }, - ]; - static __schedules = [ - { - operation: 'processData', - cronPattern: '0 * * * *', - priority: 5, - immediately: false, - description: 'Hourly processing', - payload: { type: 'scheduled' }, - batch: { size: 100 }, - }, - ]; - static __description = 'Test handler for configuration'; - - async processData(input: any, context: ExecutionContext) { - return { processed: true, input }; - } - - async validateData(input: any, context: ExecutionContext) { - return { valid: true, input }; - } -} - -// Handler without metadata -class NoMetadataHandler extends BaseHandler {} - -describe('BaseHandler Configuration', () => { - let mockServices: IServiceContainer; - - beforeEach(() => { - mockServices = { - cache: null, - globalCache: null, - queueManager: null, - proxy: null, - browser: null, - mongodb: null, - postgres: null, - questdb: null, - } as any; - }); - - describe('createHandlerConfig', () => { - it('should create handler config from metadata', () => { - const handler = new ConfigTestHandler(mockServices); - const config = handler.createHandlerConfig(); - - expect(config.name).toBe('config-test'); - expect(Object.keys(config.operations)).toEqual(['process', 'validate']); - expect(config.scheduledJobs).toHaveLength(1); - }); - - it('should create job handlers for operations', () => { - const handler = new ConfigTestHandler(mockServices); - const config = handler.createHandlerConfig(); - - expect(typeof config.operations.process).toBe('function'); - expect(typeof config.operations.validate).toBe('function'); - }); - - it('should include scheduled job details', () => { - const handler = new ConfigTestHandler(mockServices); - const config = handler.createHandlerConfig(); - - const scheduledJob = config.scheduledJobs[0]; - expect(scheduledJob.type).toBe('config-test-processData'); - expect(scheduledJob.operation).toBe('process'); - expect(scheduledJob.cronPattern).toBe('0 * * * *'); - expect(scheduledJob.priority).toBe(5); - expect(scheduledJob.immediately).toBe(false); - expect(scheduledJob.description).toBe('Hourly processing'); - expect(scheduledJob.payload).toEqual({ type: 'scheduled' }); - expect(scheduledJob.batch).toEqual({ size: 100 }); - }); - - it('should execute operations through job handlers', async () => { - const handler = new ConfigTestHandler(mockServices); - const config = handler.createHandlerConfig(); - - // Mock the job execution - const processJob = config.operations.process; - const result = await processJob({ data: 'test' }, {} as any); - - expect(result).toEqual({ processed: true, input: { data: 'test' } }); - }); - - it('should throw error when no metadata found', () => { - const handler = new NoMetadataHandler(mockServices); - - expect(() => handler.createHandlerConfig()).toThrow('Handler metadata not found'); - }); - - it('should handle schedule without matching operation', () => { - class ScheduleOnlyHandler extends BaseHandler { - static __handlerName = 'schedule-only'; - static __operations = []; - static __schedules = [ - { - operation: 'nonExistentMethod', - cronPattern: '* * * * *', - }, - ]; - } - - const handler = new ScheduleOnlyHandler(mockServices); - const config = handler.createHandlerConfig(); - - expect(config.operations).toEqual({}); - expect(config.scheduledJobs).toHaveLength(1); - expect(config.scheduledJobs[0].operation).toBe('nonExistentMethod'); - }); - - it('should handle empty schedules array', () => { - class NoScheduleHandler extends BaseHandler { - static __handlerName = 'no-schedule'; - static __operations = [{ name: 'test', method: 'testMethod' }]; - static __schedules = []; - - testMethod() {} - } - - const handler = new NoScheduleHandler(mockServices); - const config = handler.createHandlerConfig(); - - expect(config.scheduledJobs).toEqual([]); - expect(config.operations).toHaveProperty('test'); - }); - - it('should create execution context with proper metadata', async () => { - const handler = new ConfigTestHandler(mockServices); - const config = handler.createHandlerConfig(); - - // Spy on execute method - const executeSpy = mock(); - handler.execute = executeSpy; - executeSpy.mockResolvedValue({ result: 'test' }); - - // Execute through job handler - await config.operations.process({ input: 'data' }, {} as any); - - expect(executeSpy).toHaveBeenCalledWith( - 'process', - { input: 'data' }, - expect.objectContaining({ - type: 'queue', - metadata: expect.objectContaining({ - source: 'queue', - timestamp: expect.any(Number), - }), - }) - ); - }); - }); - - describe('extractMetadata', () => { - it('should extract complete metadata', () => { - const metadata = ConfigTestHandler.extractMetadata(); - - expect(metadata).not.toBeNull(); - expect(metadata?.name).toBe('config-test'); - expect(metadata?.operations).toEqual(['process', 'validate']); - expect(metadata?.description).toBe('Test handler for configuration'); - expect(metadata?.scheduledJobs).toHaveLength(1); - }); - - it('should return null for handler without metadata', () => { - const metadata = NoMetadataHandler.extractMetadata(); - expect(metadata).toBeNull(); - }); - - it('should handle missing optional fields', () => { - class MinimalHandler extends BaseHandler { - static __handlerName = 'minimal'; - static __operations = []; - } - - const metadata = MinimalHandler.extractMetadata(); - - expect(metadata).not.toBeNull(); - expect(metadata?.name).toBe('minimal'); - expect(metadata?.operations).toEqual([]); - expect(metadata?.scheduledJobs).toEqual([]); - expect(metadata?.description).toBeUndefined(); - }); - - it('should map schedule operations correctly', () => { - class MappedScheduleHandler extends BaseHandler { - static __handlerName = 'mapped'; - static __operations = [ - { name: 'op1', method: 'method1' }, - { name: 'op2', method: 'method2' }, - ]; - static __schedules = [ - { operation: 'method1', cronPattern: '* * * * *' }, - { operation: 'method2', cronPattern: '0 * * * *' }, - ]; - } - - const metadata = MappedScheduleHandler.extractMetadata(); - - expect(metadata?.scheduledJobs[0].operation).toBe('op1'); - expect(metadata?.scheduledJobs[1].operation).toBe('op2'); - }); - }); -}); \ No newline at end of file +import { beforeEach, describe, expect, it, mock } from 'bun:test'; +import type { ExecutionContext, IServiceContainer } from '@stock-bot/types'; +import { BaseHandler } from '../src/base/BaseHandler'; + +// Test handler with metadata +class ConfigTestHandler extends BaseHandler { + static __handlerName = 'config-test'; + static __operations = [ + { name: 'process', method: 'processData' }, + { name: 'validate', method: 'validateData' }, + ]; + static __schedules = [ + { + operation: 'processData', + cronPattern: '0 * * * *', + priority: 5, + immediately: false, + description: 'Hourly processing', + payload: { type: 'scheduled' }, + batch: { size: 100 }, + }, + ]; + static __description = 'Test handler for configuration'; + + async processData(input: any, context: ExecutionContext) { + return { processed: true, input }; + } + + async validateData(input: any, context: ExecutionContext) { + return { valid: true, input }; + } +} + +// Handler without metadata +class NoMetadataHandler extends BaseHandler {} + +describe('BaseHandler Configuration', () => { + let mockServices: IServiceContainer; + + beforeEach(() => { + mockServices = { + cache: null, + globalCache: null, + queueManager: null, + proxy: null, + browser: null, + mongodb: null, + postgres: null, + questdb: null, + } as any; + }); + + describe('createHandlerConfig', () => { + it('should create handler config from metadata', () => { + const handler = new ConfigTestHandler(mockServices); + const config = handler.createHandlerConfig(); + + expect(config.name).toBe('config-test'); + expect(Object.keys(config.operations)).toEqual(['process', 'validate']); + expect(config.scheduledJobs).toHaveLength(1); + }); + + it('should create job handlers for operations', () => { + const handler = new ConfigTestHandler(mockServices); + const config = handler.createHandlerConfig(); + + expect(typeof config.operations.process).toBe('function'); + expect(typeof config.operations.validate).toBe('function'); + }); + + it('should include scheduled job details', () => { + const handler = new ConfigTestHandler(mockServices); + const config = handler.createHandlerConfig(); + + const scheduledJob = config.scheduledJobs[0]; + expect(scheduledJob.type).toBe('config-test-processData'); + expect(scheduledJob.operation).toBe('process'); + expect(scheduledJob.cronPattern).toBe('0 * * * *'); + expect(scheduledJob.priority).toBe(5); + expect(scheduledJob.immediately).toBe(false); + expect(scheduledJob.description).toBe('Hourly processing'); + expect(scheduledJob.payload).toEqual({ type: 'scheduled' }); + expect(scheduledJob.batch).toEqual({ size: 100 }); + }); + + it('should execute operations through job handlers', async () => { + const handler = new ConfigTestHandler(mockServices); + const config = handler.createHandlerConfig(); + + // Mock the job execution + const processJob = config.operations.process; + const result = await processJob({ data: 'test' }, {} as any); + + expect(result).toEqual({ processed: true, input: { data: 'test' } }); + }); + + it('should throw error when no metadata found', () => { + const handler = new NoMetadataHandler(mockServices); + + expect(() => handler.createHandlerConfig()).toThrow('Handler metadata not found'); + }); + + it('should handle schedule without matching operation', () => { + class ScheduleOnlyHandler extends BaseHandler { + static __handlerName = 'schedule-only'; + static __operations = []; + static __schedules = [ + { + operation: 'nonExistentMethod', + cronPattern: '* * * * *', + }, + ]; + } + + const handler = new ScheduleOnlyHandler(mockServices); + const config = handler.createHandlerConfig(); + + expect(config.operations).toEqual({}); + expect(config.scheduledJobs).toHaveLength(1); + expect(config.scheduledJobs[0].operation).toBe('nonExistentMethod'); + }); + + it('should handle empty schedules array', () => { + class NoScheduleHandler extends BaseHandler { + static __handlerName = 'no-schedule'; + static __operations = [{ name: 'test', method: 'testMethod' }]; + static __schedules = []; + + testMethod() {} + } + + const handler = new NoScheduleHandler(mockServices); + const config = handler.createHandlerConfig(); + + expect(config.scheduledJobs).toEqual([]); + expect(config.operations).toHaveProperty('test'); + }); + + it('should create execution context with proper metadata', async () => { + const handler = new ConfigTestHandler(mockServices); + const config = handler.createHandlerConfig(); + + // Spy on execute method + const executeSpy = mock(); + handler.execute = executeSpy; + executeSpy.mockResolvedValue({ result: 'test' }); + + // Execute through job handler + await config.operations.process({ input: 'data' }, {} as any); + + expect(executeSpy).toHaveBeenCalledWith( + 'process', + { input: 'data' }, + expect.objectContaining({ + type: 'queue', + metadata: expect.objectContaining({ + source: 'queue', + timestamp: expect.any(Number), + }), + }) + ); + }); + }); + + describe('extractMetadata', () => { + it('should extract complete metadata', () => { + const metadata = ConfigTestHandler.extractMetadata(); + + expect(metadata).not.toBeNull(); + expect(metadata?.name).toBe('config-test'); + expect(metadata?.operations).toEqual(['process', 'validate']); + expect(metadata?.description).toBe('Test handler for configuration'); + expect(metadata?.scheduledJobs).toHaveLength(1); + }); + + it('should return null for handler without metadata', () => { + const metadata = NoMetadataHandler.extractMetadata(); + expect(metadata).toBeNull(); + }); + + it('should handle missing optional fields', () => { + class MinimalHandler extends BaseHandler { + static __handlerName = 'minimal'; + static __operations = []; + } + + const metadata = MinimalHandler.extractMetadata(); + + expect(metadata).not.toBeNull(); + expect(metadata?.name).toBe('minimal'); + expect(metadata?.operations).toEqual([]); + expect(metadata?.scheduledJobs).toEqual([]); + expect(metadata?.description).toBeUndefined(); + }); + + it('should map schedule operations correctly', () => { + class MappedScheduleHandler extends BaseHandler { + static __handlerName = 'mapped'; + static __operations = [ + { name: 'op1', method: 'method1' }, + { name: 'op2', method: 'method2' }, + ]; + static __schedules = [ + { operation: 'method1', cronPattern: '* * * * *' }, + { operation: 'method2', cronPattern: '0 * * * *' }, + ]; + } + + const metadata = MappedScheduleHandler.extractMetadata(); + + expect(metadata?.scheduledJobs[0].operation).toBe('op1'); + expect(metadata?.scheduledJobs[1].operation).toBe('op2'); + }); + }); +}); diff --git a/libs/core/handlers/test/base-handler-edge-cases.test.ts b/libs/core/handlers/test/base-handler-edge-cases.test.ts index 997747d..8c08818 100644 --- a/libs/core/handlers/test/base-handler-edge-cases.test.ts +++ b/libs/core/handlers/test/base-handler-edge-cases.test.ts @@ -1,364 +1,366 @@ -import { describe, it, expect, beforeEach, mock } from 'bun:test'; -import { BaseHandler, ScheduledHandler } from '../src/base/BaseHandler'; -import type { IServiceContainer, ExecutionContext } from '@stock-bot/types'; - -// Test handler implementation -class TestHandler extends BaseHandler { - testMethod(input: any, context: ExecutionContext) { - return { result: 'test', input, context }; - } - - async onInit() { - // Lifecycle hook - } - - protected getScheduledJobPayload(operation: string) { - return { scheduled: true, operation }; - } -} - -// Handler with no operations -class EmptyHandler extends BaseHandler {} - -// Handler with missing method -class BrokenHandler extends BaseHandler { - constructor(services: IServiceContainer) { - super(services); - const ctor = this.constructor as any; - ctor.__operations = [{ name: 'missing', method: 'nonExistentMethod' }]; - } -} - -describe('BaseHandler Edge Cases', () => { - let mockServices: IServiceContainer; - - beforeEach(() => { - mockServices = { - cache: { - get: mock(async () => null), - set: mock(async () => {}), - del: mock(async () => {}), - has: mock(async () => false), - clear: mock(async () => {}), - keys: mock(async () => []), - mget: mock(async () => []), - mset: mock(async () => {}), - mdel: mock(async () => {}), - ttl: mock(async () => -1), - expire: mock(async () => true), - getClientType: () => 'redis', - isConnected: () => true, - }, - globalCache: null, - queueManager: { - getQueue: mock(() => ({ - add: mock(async () => ({})), - addBulk: mock(async () => []), - pause: mock(async () => {}), - resume: mock(async () => {}), - clean: mock(async () => []), - drain: mock(async () => {}), - obliterate: mock(async () => {}), - close: mock(async () => {}), - isReady: mock(async () => true), - isClosed: () => false, - name: 'test-queue', - })), - }, - proxy: null, - browser: null, - mongodb: null, - postgres: null, - questdb: null, - } as any; - }); - - describe('Constructor Edge Cases', () => { - it('should handle handler without decorator metadata', () => { - const handler = new TestHandler(mockServices); - expect(handler).toBeInstanceOf(BaseHandler); - }); - - it('should use provided handler name', () => { - const handler = new TestHandler(mockServices, 'custom-handler'); - expect(handler).toBeInstanceOf(BaseHandler); - }); - - it('should handle null queue manager', () => { - const servicesWithoutQueue = { ...mockServices, queueManager: null }; - const handler = new TestHandler(servicesWithoutQueue); - expect(handler.queue).toBeUndefined(); - }); - }); - - describe('Execute Method Edge Cases', () => { - it('should throw for unknown operation', async () => { - const handler = new TestHandler(mockServices); - const context: ExecutionContext = { type: 'queue', metadata: {} }; - - await expect(handler.execute('unknownOp', {}, context)).rejects.toThrow('Unknown operation: unknownOp'); - }); - - it('should handle operation with no operations metadata', async () => { - const handler = new EmptyHandler(mockServices); - const context: ExecutionContext = { type: 'queue', metadata: {} }; - - await expect(handler.execute('anyOp', {}, context)).rejects.toThrow('Unknown operation: anyOp'); - }); - - it('should throw when method is not a function', async () => { - const handler = new BrokenHandler(mockServices); - const context: ExecutionContext = { type: 'queue', metadata: {} }; - - await expect(handler.execute('missing', {}, context)).rejects.toThrow( - "Operation method 'nonExistentMethod' not found on handler" - ); - }); - - it('should execute operation with proper context', async () => { - const handler = new TestHandler(mockServices); - const ctor = handler.constructor as any; - ctor.__operations = [{ name: 'test', method: 'testMethod' }]; - - const context: ExecutionContext = { - type: 'queue', - metadata: { source: 'test' } - }; - - const result = await handler.execute('test', { data: 'test' }, context); - expect(result).toEqual({ - result: 'test', - input: { data: 'test' }, - context, - }); - }); - }); - - describe('Service Helper Methods Edge Cases', () => { - it('should handle missing cache service', async () => { - const servicesWithoutCache = { ...mockServices, cache: null }; - const handler = new TestHandler(servicesWithoutCache); - - // Should not throw, just return gracefully - await handler['cacheSet']('key', 'value'); - const value = await handler['cacheGet']('key'); - expect(value).toBeNull(); - - await handler['cacheDel']('key'); - }); - - it('should handle missing global cache service', async () => { - const handler = new TestHandler(mockServices); // globalCache is already null - - await handler['globalCacheSet']('key', 'value'); - const value = await handler['globalCacheGet']('key'); - expect(value).toBeNull(); - - await handler['globalCacheDel']('key'); - }); - - it('should handle missing MongoDB service', () => { - const handler = new TestHandler(mockServices); - - expect(() => handler['collection']('test')).toThrow('MongoDB service is not available'); - }); - - it('should schedule operation without queue', async () => { - const servicesWithoutQueue = { ...mockServices, queueManager: null }; - const handler = new TestHandler(servicesWithoutQueue); - - await expect(handler.scheduleOperation('test', {})).rejects.toThrow( - 'Queue service is not available for this handler' - ); - }); - }); - - describe('Execution Context Creation', () => { - it('should create execution context with metadata', () => { - const handler = new TestHandler(mockServices); - - const context = handler['createExecutionContext']('http', { custom: 'data' }); - - expect(context.type).toBe('http'); - expect(context.metadata.custom).toBe('data'); - expect(context.metadata.timestamp).toBeDefined(); - expect(context.metadata.traceId).toBeDefined(); - expect(context.metadata.traceId).toContain('TestHandler'); - }); - - it('should create execution context without metadata', () => { - const handler = new TestHandler(mockServices); - - const context = handler['createExecutionContext']('queue'); - - expect(context.type).toBe('queue'); - expect(context.metadata.timestamp).toBeDefined(); - expect(context.metadata.traceId).toBeDefined(); - }); - }); - - describe('HTTP Helper Edge Cases', () => { - it('should provide HTTP methods', () => { - const handler = new TestHandler(mockServices); - const http = handler['http']; - - expect(http.get).toBeDefined(); - expect(http.post).toBeDefined(); - expect(http.put).toBeDefined(); - expect(http.delete).toBeDefined(); - - // All should be functions - expect(typeof http.get).toBe('function'); - expect(typeof http.post).toBe('function'); - expect(typeof http.put).toBe('function'); - expect(typeof http.delete).toBe('function'); - }); - }); - - describe('Static Methods Edge Cases', () => { - it('should return null for handler without metadata', () => { - const metadata = TestHandler.extractMetadata(); - expect(metadata).toBeNull(); - }); - - it('should extract metadata with all fields', () => { - const HandlerWithMeta = class extends BaseHandler { - static __handlerName = 'meta-handler'; - static __operations = [ - { name: 'op1', method: 'method1' }, - { name: 'op2', method: 'method2' }, - ]; - static __schedules = [ - { - operation: 'method1', - cronPattern: '* * * * *', - priority: 10, - immediately: true, - description: 'Test schedule', - payload: { test: true }, - batch: { size: 10 }, - }, - ]; - static __description = 'Test handler description'; - }; - - const metadata = HandlerWithMeta.extractMetadata(); - - expect(metadata).toBeDefined(); - expect(metadata?.name).toBe('meta-handler'); - expect(metadata?.operations).toEqual(['op1', 'op2']); - expect(metadata?.description).toBe('Test handler description'); - expect(metadata?.scheduledJobs).toHaveLength(1); - - const job = metadata?.scheduledJobs[0]; - expect(job?.type).toBe('meta-handler-method1'); - expect(job?.operation).toBe('op1'); - expect(job?.cronPattern).toBe('* * * * *'); - expect(job?.priority).toBe(10); - expect(job?.immediately).toBe(true); - expect(job?.payload).toEqual({ test: true }); - expect(job?.batch).toEqual({ size: 10 }); - }); - }); - - describe('Handler Configuration Creation', () => { - it('should throw when no metadata found', () => { - const handler = new TestHandler(mockServices); - - expect(() => handler.createHandlerConfig()).toThrow('Handler metadata not found'); - }); - - it('should create handler config with operations', () => { - const HandlerWithMeta = class extends BaseHandler { - static __handlerName = 'config-handler'; - static __operations = [ - { name: 'process', method: 'processData' }, - ]; - static __schedules = []; - }; - - const handler = new HandlerWithMeta(mockServices); - const config = handler.createHandlerConfig(); - - expect(config.name).toBe('config-handler'); - expect(config.operations.process).toBeDefined(); - expect(typeof config.operations.process).toBe('function'); - expect(config.scheduledJobs).toEqual([]); - }); - }); - - describe('Service Availability Check', () => { - it('should correctly identify available services', () => { - const handler = new TestHandler(mockServices); - - expect(handler['hasService']('cache')).toBe(true); - expect(handler['hasService']('queueManager')).toBe(true); - expect(handler['hasService']('globalCache')).toBe(false); - expect(handler['hasService']('mongodb')).toBe(false); - }); - }); - - describe('Scheduled Handler Edge Cases', () => { - it('should be instance of BaseHandler', () => { - const handler = new ScheduledHandler(mockServices); - expect(handler).toBeInstanceOf(BaseHandler); - expect(handler).toBeInstanceOf(ScheduledHandler); - }); - }); - - describe('Cache Helpers with Namespacing', () => { - it('should create namespaced cache', () => { - const handler = new TestHandler(mockServices); - const nsCache = handler['createNamespacedCache']('api'); - - expect(nsCache).toBeDefined(); - }); - - it('should prefix cache keys with handler name', async () => { - const TestHandlerWithName = class extends BaseHandler { - static __handlerName = 'test-handler'; - }; - - const handler = new TestHandlerWithName(mockServices); - - await handler['cacheSet']('mykey', 'value', 3600); - - expect(mockServices.cache?.set).toHaveBeenCalledWith('test-handler:mykey', 'value', 3600); - }); - }); - - describe('Schedule Helper Methods', () => { - it('should schedule with delay in seconds', async () => { - const handler = new TestHandler(mockServices); - - // The queue is already set in the handler constructor - const mockAdd = handler.queue?.add; - - await handler['scheduleIn']('test-op', { data: 'test' }, 30, { priority: 10 }); - - expect(mockAdd).toHaveBeenCalledWith( - 'test-op', - { - handler: 'testhandler', - operation: 'test-op', - payload: { data: 'test' }, - }, - { delay: 30000, priority: 10 } - ); - }); - }); - - describe('Logging Helper', () => { - it('should log with handler context', () => { - const handler = new TestHandler(mockServices); - - // The log method should exist - expect(typeof handler['log']).toBe('function'); - - // It should be callable without errors - expect(() => { - handler['log']('info', 'Test message', { extra: 'data' }); - }).not.toThrow(); - }); - }); -}); \ No newline at end of file +import { beforeEach, describe, expect, it, mock } from 'bun:test'; +import type { ExecutionContext, IServiceContainer } from '@stock-bot/types'; +import { BaseHandler, ScheduledHandler } from '../src/base/BaseHandler'; + +// Test handler implementation +class TestHandler extends BaseHandler { + testMethod(input: any, context: ExecutionContext) { + return { result: 'test', input, context }; + } + + async onInit() { + // Lifecycle hook + } + + protected getScheduledJobPayload(operation: string) { + return { scheduled: true, operation }; + } +} + +// Handler with no operations +class EmptyHandler extends BaseHandler {} + +// Handler with missing method +class BrokenHandler extends BaseHandler { + constructor(services: IServiceContainer) { + super(services); + const ctor = this.constructor as any; + ctor.__operations = [{ name: 'missing', method: 'nonExistentMethod' }]; + } +} + +describe('BaseHandler Edge Cases', () => { + let mockServices: IServiceContainer; + + beforeEach(() => { + mockServices = { + cache: { + get: mock(async () => null), + set: mock(async () => {}), + del: mock(async () => {}), + has: mock(async () => false), + clear: mock(async () => {}), + keys: mock(async () => []), + mget: mock(async () => []), + mset: mock(async () => {}), + mdel: mock(async () => {}), + ttl: mock(async () => -1), + expire: mock(async () => true), + getClientType: () => 'redis', + isConnected: () => true, + }, + globalCache: null, + queueManager: { + getQueue: mock(() => ({ + add: mock(async () => ({})), + addBulk: mock(async () => []), + pause: mock(async () => {}), + resume: mock(async () => {}), + clean: mock(async () => []), + drain: mock(async () => {}), + obliterate: mock(async () => {}), + close: mock(async () => {}), + isReady: mock(async () => true), + isClosed: () => false, + name: 'test-queue', + })), + }, + proxy: null, + browser: null, + mongodb: null, + postgres: null, + questdb: null, + } as any; + }); + + describe('Constructor Edge Cases', () => { + it('should handle handler without decorator metadata', () => { + const handler = new TestHandler(mockServices); + expect(handler).toBeInstanceOf(BaseHandler); + }); + + it('should use provided handler name', () => { + const handler = new TestHandler(mockServices, 'custom-handler'); + expect(handler).toBeInstanceOf(BaseHandler); + }); + + it('should handle null queue manager', () => { + const servicesWithoutQueue = { ...mockServices, queueManager: null }; + const handler = new TestHandler(servicesWithoutQueue); + expect(handler.queue).toBeUndefined(); + }); + }); + + describe('Execute Method Edge Cases', () => { + it('should throw for unknown operation', async () => { + const handler = new TestHandler(mockServices); + const context: ExecutionContext = { type: 'queue', metadata: {} }; + + await expect(handler.execute('unknownOp', {}, context)).rejects.toThrow( + 'Unknown operation: unknownOp' + ); + }); + + it('should handle operation with no operations metadata', async () => { + const handler = new EmptyHandler(mockServices); + const context: ExecutionContext = { type: 'queue', metadata: {} }; + + await expect(handler.execute('anyOp', {}, context)).rejects.toThrow( + 'Unknown operation: anyOp' + ); + }); + + it('should throw when method is not a function', async () => { + const handler = new BrokenHandler(mockServices); + const context: ExecutionContext = { type: 'queue', metadata: {} }; + + await expect(handler.execute('missing', {}, context)).rejects.toThrow( + "Operation method 'nonExistentMethod' not found on handler" + ); + }); + + it('should execute operation with proper context', async () => { + const handler = new TestHandler(mockServices); + const ctor = handler.constructor as any; + ctor.__operations = [{ name: 'test', method: 'testMethod' }]; + + const context: ExecutionContext = { + type: 'queue', + metadata: { source: 'test' }, + }; + + const result = await handler.execute('test', { data: 'test' }, context); + expect(result).toEqual({ + result: 'test', + input: { data: 'test' }, + context, + }); + }); + }); + + describe('Service Helper Methods Edge Cases', () => { + it('should handle missing cache service', async () => { + const servicesWithoutCache = { ...mockServices, cache: null }; + const handler = new TestHandler(servicesWithoutCache); + + // Should not throw, just return gracefully + await handler['cacheSet']('key', 'value'); + const value = await handler['cacheGet']('key'); + expect(value).toBeNull(); + + await handler['cacheDel']('key'); + }); + + it('should handle missing global cache service', async () => { + const handler = new TestHandler(mockServices); // globalCache is already null + + await handler['globalCacheSet']('key', 'value'); + const value = await handler['globalCacheGet']('key'); + expect(value).toBeNull(); + + await handler['globalCacheDel']('key'); + }); + + it('should handle missing MongoDB service', () => { + const handler = new TestHandler(mockServices); + + expect(() => handler['collection']('test')).toThrow('MongoDB service is not available'); + }); + + it('should schedule operation without queue', async () => { + const servicesWithoutQueue = { ...mockServices, queueManager: null }; + const handler = new TestHandler(servicesWithoutQueue); + + await expect(handler.scheduleOperation('test', {})).rejects.toThrow( + 'Queue service is not available for this handler' + ); + }); + }); + + describe('Execution Context Creation', () => { + it('should create execution context with metadata', () => { + const handler = new TestHandler(mockServices); + + const context = handler['createExecutionContext']('http', { custom: 'data' }); + + expect(context.type).toBe('http'); + expect(context.metadata.custom).toBe('data'); + expect(context.metadata.timestamp).toBeDefined(); + expect(context.metadata.traceId).toBeDefined(); + expect(context.metadata.traceId).toContain('TestHandler'); + }); + + it('should create execution context without metadata', () => { + const handler = new TestHandler(mockServices); + + const context = handler['createExecutionContext']('queue'); + + expect(context.type).toBe('queue'); + expect(context.metadata.timestamp).toBeDefined(); + expect(context.metadata.traceId).toBeDefined(); + }); + }); + + describe('HTTP Helper Edge Cases', () => { + it('should provide HTTP methods', () => { + const handler = new TestHandler(mockServices); + const http = handler['http']; + + expect(http.get).toBeDefined(); + expect(http.post).toBeDefined(); + expect(http.put).toBeDefined(); + expect(http.delete).toBeDefined(); + + // All should be functions + expect(typeof http.get).toBe('function'); + expect(typeof http.post).toBe('function'); + expect(typeof http.put).toBe('function'); + expect(typeof http.delete).toBe('function'); + }); + }); + + describe('Static Methods Edge Cases', () => { + it('should return null for handler without metadata', () => { + const metadata = TestHandler.extractMetadata(); + expect(metadata).toBeNull(); + }); + + it('should extract metadata with all fields', () => { + const HandlerWithMeta = class extends BaseHandler { + static __handlerName = 'meta-handler'; + static __operations = [ + { name: 'op1', method: 'method1' }, + { name: 'op2', method: 'method2' }, + ]; + static __schedules = [ + { + operation: 'method1', + cronPattern: '* * * * *', + priority: 10, + immediately: true, + description: 'Test schedule', + payload: { test: true }, + batch: { size: 10 }, + }, + ]; + static __description = 'Test handler description'; + }; + + const metadata = HandlerWithMeta.extractMetadata(); + + expect(metadata).toBeDefined(); + expect(metadata?.name).toBe('meta-handler'); + expect(metadata?.operations).toEqual(['op1', 'op2']); + expect(metadata?.description).toBe('Test handler description'); + expect(metadata?.scheduledJobs).toHaveLength(1); + + const job = metadata?.scheduledJobs[0]; + expect(job?.type).toBe('meta-handler-method1'); + expect(job?.operation).toBe('op1'); + expect(job?.cronPattern).toBe('* * * * *'); + expect(job?.priority).toBe(10); + expect(job?.immediately).toBe(true); + expect(job?.payload).toEqual({ test: true }); + expect(job?.batch).toEqual({ size: 10 }); + }); + }); + + describe('Handler Configuration Creation', () => { + it('should throw when no metadata found', () => { + const handler = new TestHandler(mockServices); + + expect(() => handler.createHandlerConfig()).toThrow('Handler metadata not found'); + }); + + it('should create handler config with operations', () => { + const HandlerWithMeta = class extends BaseHandler { + static __handlerName = 'config-handler'; + static __operations = [{ name: 'process', method: 'processData' }]; + static __schedules = []; + }; + + const handler = new HandlerWithMeta(mockServices); + const config = handler.createHandlerConfig(); + + expect(config.name).toBe('config-handler'); + expect(config.operations.process).toBeDefined(); + expect(typeof config.operations.process).toBe('function'); + expect(config.scheduledJobs).toEqual([]); + }); + }); + + describe('Service Availability Check', () => { + it('should correctly identify available services', () => { + const handler = new TestHandler(mockServices); + + expect(handler['hasService']('cache')).toBe(true); + expect(handler['hasService']('queueManager')).toBe(true); + expect(handler['hasService']('globalCache')).toBe(false); + expect(handler['hasService']('mongodb')).toBe(false); + }); + }); + + describe('Scheduled Handler Edge Cases', () => { + it('should be instance of BaseHandler', () => { + const handler = new ScheduledHandler(mockServices); + expect(handler).toBeInstanceOf(BaseHandler); + expect(handler).toBeInstanceOf(ScheduledHandler); + }); + }); + + describe('Cache Helpers with Namespacing', () => { + it('should create namespaced cache', () => { + const handler = new TestHandler(mockServices); + const nsCache = handler['createNamespacedCache']('api'); + + expect(nsCache).toBeDefined(); + }); + + it('should prefix cache keys with handler name', async () => { + const TestHandlerWithName = class extends BaseHandler { + static __handlerName = 'test-handler'; + }; + + const handler = new TestHandlerWithName(mockServices); + + await handler['cacheSet']('mykey', 'value', 3600); + + expect(mockServices.cache?.set).toHaveBeenCalledWith('test-handler:mykey', 'value', 3600); + }); + }); + + describe('Schedule Helper Methods', () => { + it('should schedule with delay in seconds', async () => { + const handler = new TestHandler(mockServices); + + // The queue is already set in the handler constructor + const mockAdd = handler.queue?.add; + + await handler['scheduleIn']('test-op', { data: 'test' }, 30, { priority: 10 }); + + expect(mockAdd).toHaveBeenCalledWith( + 'test-op', + { + handler: 'testhandler', + operation: 'test-op', + payload: { data: 'test' }, + }, + { delay: 30000, priority: 10 } + ); + }); + }); + + describe('Logging Helper', () => { + it('should log with handler context', () => { + const handler = new TestHandler(mockServices); + + // The log method should exist + expect(typeof handler['log']).toBe('function'); + + // It should be callable without errors + expect(() => { + handler['log']('info', 'Test message', { extra: 'data' }); + }).not.toThrow(); + }); + }); +}); diff --git a/libs/core/handlers/test/base-handler-http.test.ts b/libs/core/handlers/test/base-handler-http.test.ts index ddd5b0f..d886f4e 100644 --- a/libs/core/handlers/test/base-handler-http.test.ts +++ b/libs/core/handlers/test/base-handler-http.test.ts @@ -1,272 +1,290 @@ -import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test'; -import { BaseHandler } from '../src/base/BaseHandler'; -import type { IServiceContainer, ExecutionContext } from '@stock-bot/types'; -import * as utils from '@stock-bot/utils'; - -// Mock fetch -const mockFetch = mock(); - -class TestHandler extends BaseHandler { - async testGet(url: string, options?: any) { - return this.http.get(url, options); - } - - async testPost(url: string, data?: any, options?: any) { - return this.http.post(url, data, options); - } - - async testPut(url: string, data?: any, options?: any) { - return this.http.put(url, data, options); - } - - async testDelete(url: string, options?: any) { - return this.http.delete(url, options); - } -} - -describe('BaseHandler HTTP Methods', () => { - let handler: TestHandler; - let mockServices: IServiceContainer; - - beforeEach(() => { - mockServices = { - cache: null, - globalCache: null, - queueManager: null, - proxy: null, - browser: null, - mongodb: null, - postgres: null, - questdb: null, - logger: { - info: mock(), - debug: mock(), - error: mock(), - warn: mock(), - } as any, - } as IServiceContainer; - - handler = new TestHandler(mockServices, 'TestHandler'); - - // Mock utils.fetch - spyOn(utils, 'fetch').mockImplementation(mockFetch); - mockFetch.mockReset(); - }); - - afterEach(() => { - // spyOn automatically restores - }); - - describe('GET requests', () => { - it('should make GET requests with fetch', async () => { - const mockResponse = { - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - json: async () => ({ data: 'test' }), - }; - mockFetch.mockResolvedValue(mockResponse); - - await handler.testGet('https://api.example.com/data'); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', - expect.objectContaining({ - method: 'GET', - logger: expect.any(Object), - }) - ); - }); - - it('should pass custom options to GET requests', async () => { - const mockResponse = { - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - }; - mockFetch.mockResolvedValue(mockResponse); - - await handler.testGet('https://api.example.com/data', { - headers: { 'Authorization': 'Bearer token' }, - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', - expect.objectContaining({ - headers: { 'Authorization': 'Bearer token' }, - method: 'GET', - logger: expect.any(Object), - }) - ); - }); - }); - - describe('POST requests', () => { - it('should make POST requests with JSON data', async () => { - const mockResponse = { - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - json: async () => ({ success: true }), - }; - mockFetch.mockResolvedValue(mockResponse); - - const data = { name: 'test', value: 123 }; - await handler.testPost('https://api.example.com/create', data); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify(data), - headers: { 'Content-Type': 'application/json' }, - logger: expect.any(Object), - }) - ); - }); - - it('should merge custom headers in POST requests', async () => { - const mockResponse = { - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - }; - mockFetch.mockResolvedValue(mockResponse); - - await handler.testPost('https://api.example.com/create', { test: 'data' }, { - headers: { 'X-Custom': 'value' }, - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ test: 'data' }), - headers: { - 'Content-Type': 'application/json', - 'X-Custom': 'value', - }, - logger: expect.any(Object), - }) - ); - }); - }); - - describe('PUT requests', () => { - it('should make PUT requests with JSON data', async () => { - const mockResponse = { - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - }; - mockFetch.mockResolvedValue(mockResponse); - - const data = { id: 1, name: 'updated' }; - await handler.testPut('https://api.example.com/update/1', data); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/update/1', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify(data), - headers: { 'Content-Type': 'application/json' }, - logger: expect.any(Object), - }) - ); - }); - - it('should handle PUT requests with custom options', async () => { - const mockResponse = { - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - }; - mockFetch.mockResolvedValue(mockResponse); - - await handler.testPut('https://api.example.com/update', { data: 'test' }, { - headers: { 'If-Match': 'etag' }, - timeout: 5000, - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/update', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ data: 'test' }), - headers: { - 'Content-Type': 'application/json', - 'If-Match': 'etag', - }, - timeout: 5000, - logger: expect.any(Object), - }) - ); - }); - }); - - describe('DELETE requests', () => { - it('should make DELETE requests', async () => { - const mockResponse = { - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - }; - mockFetch.mockResolvedValue(mockResponse); - - await handler.testDelete('https://api.example.com/delete/1'); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/delete/1', - expect.objectContaining({ - method: 'DELETE', - logger: expect.any(Object), - }) - ); - }); - - it('should pass options to DELETE requests', async () => { - const mockResponse = { - ok: true, - status: 200, - statusText: 'OK', - headers: new Headers(), - }; - mockFetch.mockResolvedValue(mockResponse); - - await handler.testDelete('https://api.example.com/delete/1', { - headers: { 'Authorization': 'Bearer token' }, - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/delete/1', - expect.objectContaining({ - headers: { 'Authorization': 'Bearer token' }, - method: 'DELETE', - logger: expect.any(Object), - }) - ); - }); - }); - - describe('Error handling', () => { - it('should propagate fetch errors', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - - await expect(handler.testGet('https://api.example.com/data')).rejects.toThrow('Network error'); - }); - - it('should handle non-ok responses', async () => { - const mockResponse = { - ok: false, - status: 404, - statusText: 'Not Found', - headers: new Headers(), - }; - mockFetch.mockResolvedValue(mockResponse); - - const response = await handler.testGet('https://api.example.com/missing'); - - expect(response.ok).toBe(false); - expect(response.status).toBe(404); - }); - }); -}); \ No newline at end of file +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import type { ExecutionContext, IServiceContainer } from '@stock-bot/types'; +import * as utils from '@stock-bot/utils'; +import { BaseHandler } from '../src/base/BaseHandler'; + +// Mock fetch +const mockFetch = mock(); + +class TestHandler extends BaseHandler { + async testGet(url: string, options?: any) { + return this.http.get(url, options); + } + + async testPost(url: string, data?: any, options?: any) { + return this.http.post(url, data, options); + } + + async testPut(url: string, data?: any, options?: any) { + return this.http.put(url, data, options); + } + + async testDelete(url: string, options?: any) { + return this.http.delete(url, options); + } +} + +describe('BaseHandler HTTP Methods', () => { + let handler: TestHandler; + let mockServices: IServiceContainer; + + beforeEach(() => { + mockServices = { + cache: null, + globalCache: null, + queueManager: null, + proxy: null, + browser: null, + mongodb: null, + postgres: null, + questdb: null, + logger: { + info: mock(), + debug: mock(), + error: mock(), + warn: mock(), + } as any, + } as IServiceContainer; + + handler = new TestHandler(mockServices, 'TestHandler'); + + // Mock utils.fetch + spyOn(utils, 'fetch').mockImplementation(mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + // spyOn automatically restores + }); + + describe('GET requests', () => { + it('should make GET requests with fetch', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => ({ data: 'test' }), + }; + mockFetch.mockResolvedValue(mockResponse); + + await handler.testGet('https://api.example.com/data'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + method: 'GET', + logger: expect.any(Object), + }) + ); + }); + + it('should pass custom options to GET requests', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + }; + mockFetch.mockResolvedValue(mockResponse); + + await handler.testGet('https://api.example.com/data', { + headers: { Authorization: 'Bearer token' }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: { Authorization: 'Bearer token' }, + method: 'GET', + logger: expect.any(Object), + }) + ); + }); + }); + + describe('POST requests', () => { + it('should make POST requests with JSON data', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => ({ success: true }), + }; + mockFetch.mockResolvedValue(mockResponse); + + const data = { name: 'test', value: 123 }; + await handler.testPost('https://api.example.com/create', data); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/create', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' }, + logger: expect.any(Object), + }) + ); + }); + + it('should merge custom headers in POST requests', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + }; + mockFetch.mockResolvedValue(mockResponse); + + await handler.testPost( + 'https://api.example.com/create', + { test: 'data' }, + { + headers: { 'X-Custom': 'value' }, + } + ); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/create', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ test: 'data' }), + headers: { + 'Content-Type': 'application/json', + 'X-Custom': 'value', + }, + logger: expect.any(Object), + }) + ); + }); + }); + + describe('PUT requests', () => { + it('should make PUT requests with JSON data', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + }; + mockFetch.mockResolvedValue(mockResponse); + + const data = { id: 1, name: 'updated' }; + await handler.testPut('https://api.example.com/update/1', data); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/update/1', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' }, + logger: expect.any(Object), + }) + ); + }); + + it('should handle PUT requests with custom options', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + }; + mockFetch.mockResolvedValue(mockResponse); + + await handler.testPut( + 'https://api.example.com/update', + { data: 'test' }, + { + headers: { 'If-Match': 'etag' }, + timeout: 5000, + } + ); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/update', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ data: 'test' }), + headers: { + 'Content-Type': 'application/json', + 'If-Match': 'etag', + }, + timeout: 5000, + logger: expect.any(Object), + }) + ); + }); + }); + + describe('DELETE requests', () => { + it('should make DELETE requests', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + }; + mockFetch.mockResolvedValue(mockResponse); + + await handler.testDelete('https://api.example.com/delete/1'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/delete/1', + expect.objectContaining({ + method: 'DELETE', + logger: expect.any(Object), + }) + ); + }); + + it('should pass options to DELETE requests', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + }; + mockFetch.mockResolvedValue(mockResponse); + + await handler.testDelete('https://api.example.com/delete/1', { + headers: { Authorization: 'Bearer token' }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/delete/1', + expect.objectContaining({ + headers: { Authorization: 'Bearer token' }, + method: 'DELETE', + logger: expect.any(Object), + }) + ); + }); + }); + + describe('Error handling', () => { + it('should propagate fetch errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect(handler.testGet('https://api.example.com/data')).rejects.toThrow( + 'Network error' + ); + }); + + it('should handle non-ok responses', async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + }; + mockFetch.mockResolvedValue(mockResponse); + + const response = await handler.testGet('https://api.example.com/missing'); + + expect(response.ok).toBe(false); + expect(response.status).toBe(404); + }); + }); +}); diff --git a/libs/core/handlers/test/decorators-edge-cases.test.ts b/libs/core/handlers/test/decorators-edge-cases.test.ts index 467830b..964f592 100644 --- a/libs/core/handlers/test/decorators-edge-cases.test.ts +++ b/libs/core/handlers/test/decorators-edge-cases.test.ts @@ -1,378 +1,391 @@ -import { describe, it, expect } from 'bun:test'; -import { Handler, Operation, QueueSchedule, ScheduledOperation, Disabled } from '../src/decorators/decorators'; -import { BaseHandler } from '../src/base/BaseHandler'; - -describe('Decorators Edge Cases', () => { - describe('Handler Decorator', () => { - it('should add metadata to class constructor', () => { - @Handler('test-handler') - class TestHandler extends BaseHandler {} - - const ctor = TestHandler as any; - expect(ctor.__handlerName).toBe('test-handler'); - expect(ctor.__needsAutoRegistration).toBe(true); - }); - - it('should handle empty handler name', () => { - @Handler('') - class EmptyNameHandler extends BaseHandler {} - - const ctor = EmptyNameHandler as any; - expect(ctor.__handlerName).toBe(''); - }); - - it('should work with context parameter', () => { - const HandlerClass = Handler('with-context')( - class TestClass extends BaseHandler {}, - { kind: 'class' } - ); - - const ctor = HandlerClass as any; - expect(ctor.__handlerName).toBe('with-context'); - }); - }); - - describe('Operation Decorator', () => { - it('should add operation metadata', () => { - class TestHandler extends BaseHandler { - @Operation('test-op') - testMethod() {} - } - - const ctor = TestHandler as any; - expect(ctor.__operations).toBeDefined(); - expect(ctor.__operations).toHaveLength(1); - expect(ctor.__operations[0]).toEqual({ - name: 'test-op', - method: 'testMethod', - batch: undefined, - }); - }); - - it('should handle multiple operations', () => { - class TestHandler extends BaseHandler { - @Operation('op1') - method1() {} - - @Operation('op2') - method2() {} - } - - const ctor = TestHandler as any; - expect(ctor.__operations).toHaveLength(2); - expect(ctor.__operations.map((op: any) => op.name)).toEqual(['op1', 'op2']); - }); - - it('should handle batch configuration', () => { - class TestHandler extends BaseHandler { - @Operation('batch-op', { - batch: { - enabled: true, - size: 100, - delayInHours: 24, - priority: 5, - direct: false, - } - }) - batchMethod() {} - } - - const ctor = TestHandler as any; - expect(ctor.__operations[0].batch).toEqual({ - enabled: true, - size: 100, - delayInHours: 24, - priority: 5, - direct: false, - }); - }); - - it('should handle partial batch configuration', () => { - class TestHandler extends BaseHandler { - @Operation('partial-batch', { - batch: { - enabled: true, - size: 50, - } - }) - partialBatchMethod() {} - } - - const ctor = TestHandler as any; - expect(ctor.__operations[0].batch).toEqual({ - enabled: true, - size: 50, - }); - }); - - it('should handle empty operation name', () => { - class TestHandler extends BaseHandler { - @Operation('') - emptyOp() {} - } - - const ctor = TestHandler as any; - expect(ctor.__operations[0].name).toBe(''); - }); - }); - - describe('QueueSchedule Decorator', () => { - it('should add schedule metadata', () => { - class TestHandler extends BaseHandler { - @QueueSchedule('* * * * *') - scheduledMethod() {} - } - - const ctor = TestHandler as any; - expect(ctor.__schedules).toBeDefined(); - expect(ctor.__schedules).toHaveLength(1); - expect(ctor.__schedules[0]).toEqual({ - operation: 'scheduledMethod', - cronPattern: '* * * * *', - }); - }); - - it('should handle full options', () => { - class TestHandler extends BaseHandler { - @QueueSchedule('0 * * * *', { - priority: 10, - immediately: true, - description: 'Hourly job', - payload: { type: 'scheduled' }, - batch: { - enabled: true, - size: 200, - delayInHours: 1, - priority: 8, - direct: true, - }, - }) - hourlyJob() {} - } - - const ctor = TestHandler as any; - const schedule = ctor.__schedules[0]; - expect(schedule.priority).toBe(10); - expect(schedule.immediately).toBe(true); - expect(schedule.description).toBe('Hourly job'); - expect(schedule.payload).toEqual({ type: 'scheduled' }); - expect(schedule.batch).toEqual({ - enabled: true, - size: 200, - delayInHours: 1, - priority: 8, - direct: true, - }); - }); - - it('should handle invalid cron pattern', () => { - // Decorator doesn't validate - it just stores the pattern - class TestHandler extends BaseHandler { - @QueueSchedule('invalid cron') - invalidSchedule() {} - } - - const ctor = TestHandler as any; - expect(ctor.__schedules[0].cronPattern).toBe('invalid cron'); - }); - - it('should handle multiple schedules', () => { - class TestHandler extends BaseHandler { - @QueueSchedule('*/5 * * * *') - every5Minutes() {} - - @QueueSchedule('0 0 * * *') - daily() {} - } - - const ctor = TestHandler as any; - expect(ctor.__schedules).toHaveLength(2); - expect(ctor.__schedules.map((s: any) => s.operation)).toEqual(['every5Minutes', 'daily']); - }); - }); - - describe('ScheduledOperation Decorator', () => { - it('should apply both Operation and QueueSchedule', () => { - class TestHandler extends BaseHandler { - @ScheduledOperation('combined-op', '*/10 * * * *') - combinedMethod() {} - } - - const ctor = TestHandler as any; - - // Check operation was added - expect(ctor.__operations).toBeDefined(); - expect(ctor.__operations).toHaveLength(1); - expect(ctor.__operations[0].name).toBe('combined-op'); - - // Check schedule was added - expect(ctor.__schedules).toBeDefined(); - expect(ctor.__schedules).toHaveLength(1); - expect(ctor.__schedules[0].cronPattern).toBe('*/10 * * * *'); - }); - - it('should pass batch config to both decorators', () => { - class TestHandler extends BaseHandler { - @ScheduledOperation('batch-scheduled', '0 */6 * * *', { - priority: 7, - immediately: false, - description: 'Every 6 hours', - payload: { scheduled: true }, - batch: { - enabled: true, - size: 500, - delayInHours: 6, - }, - }) - batchScheduledMethod() {} - } - - const ctor = TestHandler as any; - - // Check operation has batch config - expect(ctor.__operations[0].batch).toEqual({ - enabled: true, - size: 500, - delayInHours: 6, - }); - - // Check schedule has all options - const schedule = ctor.__schedules[0]; - expect(schedule.priority).toBe(7); - expect(schedule.immediately).toBe(false); - expect(schedule.description).toBe('Every 6 hours'); - expect(schedule.payload).toEqual({ scheduled: true }); - expect(schedule.batch).toEqual({ - enabled: true, - size: 500, - delayInHours: 6, - }); - }); - - it('should handle minimal configuration', () => { - class TestHandler extends BaseHandler { - @ScheduledOperation('minimal', '* * * * *') - minimalMethod() {} - } - - const ctor = TestHandler as any; - expect(ctor.__operations[0]).toEqual({ - name: 'minimal', - method: 'minimalMethod', - batch: undefined, - }); - expect(ctor.__schedules[0]).toEqual({ - operation: 'minimalMethod', - cronPattern: '* * * * *', - }); - }); - }); - - describe('Disabled Decorator', () => { - it('should mark handler as disabled', () => { - @Disabled() - @Handler('disabled-handler') - class DisabledHandler extends BaseHandler {} - - const ctor = DisabledHandler as any; - expect(ctor.__disabled).toBe(true); - expect(ctor.__handlerName).toBe('disabled-handler'); - }); - - it('should work without Handler decorator', () => { - @Disabled() - class JustDisabled extends BaseHandler {} - - const ctor = JustDisabled as any; - expect(ctor.__disabled).toBe(true); - }); - - it('should work with context parameter', () => { - const DisabledClass = Disabled()( - class TestClass extends BaseHandler {}, - { kind: 'class' } - ); - - const ctor = DisabledClass as any; - expect(ctor.__disabled).toBe(true); - }); - }); - - describe('Decorator Combinations', () => { - it('should handle all decorators on one class', () => { - @Handler('full-handler') - class FullHandler extends BaseHandler { - @Operation('simple-op') - simpleMethod() {} - - @Operation('batch-op', { batch: { enabled: true, size: 50 } }) - batchMethod() {} - - @QueueSchedule('*/15 * * * *', { priority: 5 }) - scheduledOnly() {} - - @ScheduledOperation('combined', '0 0 * * *', { - immediately: true, - batch: { enabled: true }, - }) - combinedMethod() {} - } - - const ctor = FullHandler as any; - - // Handler metadata - expect(ctor.__handlerName).toBe('full-handler'); - expect(ctor.__needsAutoRegistration).toBe(true); - - // Operations (3 total - simple, batch, and combined) - expect(ctor.__operations).toHaveLength(3); - expect(ctor.__operations.map((op: any) => op.name)).toEqual(['simple-op', 'batch-op', 'combined']); - - // Schedules (2 total - scheduledOnly and combined) - expect(ctor.__schedules).toHaveLength(2); - expect(ctor.__schedules.map((s: any) => s.operation)).toEqual(['scheduledOnly', 'combinedMethod']); - }); - - it('should handle disabled handler with operations', () => { - @Disabled() - @Handler('disabled-with-ops') - class DisabledWithOps extends BaseHandler { - @Operation('op1') - method1() {} - - @QueueSchedule('* * * * *') - scheduled() {} - } - - const ctor = DisabledWithOps as any; - expect(ctor.__disabled).toBe(true); - expect(ctor.__handlerName).toBe('disabled-with-ops'); - expect(ctor.__operations).toHaveLength(1); - expect(ctor.__schedules).toHaveLength(1); - }); - }); - - describe('Edge Cases with Method Names', () => { - it('should handle special method names', () => { - class TestHandler extends BaseHandler { - @Operation('toString-op') - toString() { - return 'test'; - } - - @Operation('valueOf-op') - valueOf() { - return 42; - } - - @Operation('hasOwnProperty-op') - hasOwnProperty(v: string | symbol): boolean { - return super.hasOwnProperty(v); - } - } - - const ctor = TestHandler as any; - expect(ctor.__operations.map((op: any) => op.method)).toEqual(['toString', 'valueOf', 'hasOwnProperty']); - }); - }); -}); \ No newline at end of file +import { describe, expect, it } from 'bun:test'; +import { BaseHandler } from '../src/base/BaseHandler'; +import { + Disabled, + Handler, + Operation, + QueueSchedule, + ScheduledOperation, +} from '../src/decorators/decorators'; + +describe('Decorators Edge Cases', () => { + describe('Handler Decorator', () => { + it('should add metadata to class constructor', () => { + @Handler('test-handler') + class TestHandler extends BaseHandler {} + + const ctor = TestHandler as any; + expect(ctor.__handlerName).toBe('test-handler'); + expect(ctor.__needsAutoRegistration).toBe(true); + }); + + it('should handle empty handler name', () => { + @Handler('') + class EmptyNameHandler extends BaseHandler {} + + const ctor = EmptyNameHandler as any; + expect(ctor.__handlerName).toBe(''); + }); + + it('should work with context parameter', () => { + const HandlerClass = Handler('with-context')(class TestClass extends BaseHandler {}, { + kind: 'class', + }); + + const ctor = HandlerClass as any; + expect(ctor.__handlerName).toBe('with-context'); + }); + }); + + describe('Operation Decorator', () => { + it('should add operation metadata', () => { + class TestHandler extends BaseHandler { + @Operation('test-op') + testMethod() {} + } + + const ctor = TestHandler as any; + expect(ctor.__operations).toBeDefined(); + expect(ctor.__operations).toHaveLength(1); + expect(ctor.__operations[0]).toEqual({ + name: 'test-op', + method: 'testMethod', + batch: undefined, + }); + }); + + it('should handle multiple operations', () => { + class TestHandler extends BaseHandler { + @Operation('op1') + method1() {} + + @Operation('op2') + method2() {} + } + + const ctor = TestHandler as any; + expect(ctor.__operations).toHaveLength(2); + expect(ctor.__operations.map((op: any) => op.name)).toEqual(['op1', 'op2']); + }); + + it('should handle batch configuration', () => { + class TestHandler extends BaseHandler { + @Operation('batch-op', { + batch: { + enabled: true, + size: 100, + delayInHours: 24, + priority: 5, + direct: false, + }, + }) + batchMethod() {} + } + + const ctor = TestHandler as any; + expect(ctor.__operations[0].batch).toEqual({ + enabled: true, + size: 100, + delayInHours: 24, + priority: 5, + direct: false, + }); + }); + + it('should handle partial batch configuration', () => { + class TestHandler extends BaseHandler { + @Operation('partial-batch', { + batch: { + enabled: true, + size: 50, + }, + }) + partialBatchMethod() {} + } + + const ctor = TestHandler as any; + expect(ctor.__operations[0].batch).toEqual({ + enabled: true, + size: 50, + }); + }); + + it('should handle empty operation name', () => { + class TestHandler extends BaseHandler { + @Operation('') + emptyOp() {} + } + + const ctor = TestHandler as any; + expect(ctor.__operations[0].name).toBe(''); + }); + }); + + describe('QueueSchedule Decorator', () => { + it('should add schedule metadata', () => { + class TestHandler extends BaseHandler { + @QueueSchedule('* * * * *') + scheduledMethod() {} + } + + const ctor = TestHandler as any; + expect(ctor.__schedules).toBeDefined(); + expect(ctor.__schedules).toHaveLength(1); + expect(ctor.__schedules[0]).toEqual({ + operation: 'scheduledMethod', + cronPattern: '* * * * *', + }); + }); + + it('should handle full options', () => { + class TestHandler extends BaseHandler { + @QueueSchedule('0 * * * *', { + priority: 10, + immediately: true, + description: 'Hourly job', + payload: { type: 'scheduled' }, + batch: { + enabled: true, + size: 200, + delayInHours: 1, + priority: 8, + direct: true, + }, + }) + hourlyJob() {} + } + + const ctor = TestHandler as any; + const schedule = ctor.__schedules[0]; + expect(schedule.priority).toBe(10); + expect(schedule.immediately).toBe(true); + expect(schedule.description).toBe('Hourly job'); + expect(schedule.payload).toEqual({ type: 'scheduled' }); + expect(schedule.batch).toEqual({ + enabled: true, + size: 200, + delayInHours: 1, + priority: 8, + direct: true, + }); + }); + + it('should handle invalid cron pattern', () => { + // Decorator doesn't validate - it just stores the pattern + class TestHandler extends BaseHandler { + @QueueSchedule('invalid cron') + invalidSchedule() {} + } + + const ctor = TestHandler as any; + expect(ctor.__schedules[0].cronPattern).toBe('invalid cron'); + }); + + it('should handle multiple schedules', () => { + class TestHandler extends BaseHandler { + @QueueSchedule('*/5 * * * *') + every5Minutes() {} + + @QueueSchedule('0 0 * * *') + daily() {} + } + + const ctor = TestHandler as any; + expect(ctor.__schedules).toHaveLength(2); + expect(ctor.__schedules.map((s: any) => s.operation)).toEqual(['every5Minutes', 'daily']); + }); + }); + + describe('ScheduledOperation Decorator', () => { + it('should apply both Operation and QueueSchedule', () => { + class TestHandler extends BaseHandler { + @ScheduledOperation('combined-op', '*/10 * * * *') + combinedMethod() {} + } + + const ctor = TestHandler as any; + + // Check operation was added + expect(ctor.__operations).toBeDefined(); + expect(ctor.__operations).toHaveLength(1); + expect(ctor.__operations[0].name).toBe('combined-op'); + + // Check schedule was added + expect(ctor.__schedules).toBeDefined(); + expect(ctor.__schedules).toHaveLength(1); + expect(ctor.__schedules[0].cronPattern).toBe('*/10 * * * *'); + }); + + it('should pass batch config to both decorators', () => { + class TestHandler extends BaseHandler { + @ScheduledOperation('batch-scheduled', '0 */6 * * *', { + priority: 7, + immediately: false, + description: 'Every 6 hours', + payload: { scheduled: true }, + batch: { + enabled: true, + size: 500, + delayInHours: 6, + }, + }) + batchScheduledMethod() {} + } + + const ctor = TestHandler as any; + + // Check operation has batch config + expect(ctor.__operations[0].batch).toEqual({ + enabled: true, + size: 500, + delayInHours: 6, + }); + + // Check schedule has all options + const schedule = ctor.__schedules[0]; + expect(schedule.priority).toBe(7); + expect(schedule.immediately).toBe(false); + expect(schedule.description).toBe('Every 6 hours'); + expect(schedule.payload).toEqual({ scheduled: true }); + expect(schedule.batch).toEqual({ + enabled: true, + size: 500, + delayInHours: 6, + }); + }); + + it('should handle minimal configuration', () => { + class TestHandler extends BaseHandler { + @ScheduledOperation('minimal', '* * * * *') + minimalMethod() {} + } + + const ctor = TestHandler as any; + expect(ctor.__operations[0]).toEqual({ + name: 'minimal', + method: 'minimalMethod', + batch: undefined, + }); + expect(ctor.__schedules[0]).toEqual({ + operation: 'minimalMethod', + cronPattern: '* * * * *', + }); + }); + }); + + describe('Disabled Decorator', () => { + it('should mark handler as disabled', () => { + @Disabled() + @Handler('disabled-handler') + class DisabledHandler extends BaseHandler {} + + const ctor = DisabledHandler as any; + expect(ctor.__disabled).toBe(true); + expect(ctor.__handlerName).toBe('disabled-handler'); + }); + + it('should work without Handler decorator', () => { + @Disabled() + class JustDisabled extends BaseHandler {} + + const ctor = JustDisabled as any; + expect(ctor.__disabled).toBe(true); + }); + + it('should work with context parameter', () => { + const DisabledClass = Disabled()(class TestClass extends BaseHandler {}, { kind: 'class' }); + + const ctor = DisabledClass as any; + expect(ctor.__disabled).toBe(true); + }); + }); + + describe('Decorator Combinations', () => { + it('should handle all decorators on one class', () => { + @Handler('full-handler') + class FullHandler extends BaseHandler { + @Operation('simple-op') + simpleMethod() {} + + @Operation('batch-op', { batch: { enabled: true, size: 50 } }) + batchMethod() {} + + @QueueSchedule('*/15 * * * *', { priority: 5 }) + scheduledOnly() {} + + @ScheduledOperation('combined', '0 0 * * *', { + immediately: true, + batch: { enabled: true }, + }) + combinedMethod() {} + } + + const ctor = FullHandler as any; + + // Handler metadata + expect(ctor.__handlerName).toBe('full-handler'); + expect(ctor.__needsAutoRegistration).toBe(true); + + // Operations (3 total - simple, batch, and combined) + expect(ctor.__operations).toHaveLength(3); + expect(ctor.__operations.map((op: any) => op.name)).toEqual([ + 'simple-op', + 'batch-op', + 'combined', + ]); + + // Schedules (2 total - scheduledOnly and combined) + expect(ctor.__schedules).toHaveLength(2); + expect(ctor.__schedules.map((s: any) => s.operation)).toEqual([ + 'scheduledOnly', + 'combinedMethod', + ]); + }); + + it('should handle disabled handler with operations', () => { + @Disabled() + @Handler('disabled-with-ops') + class DisabledWithOps extends BaseHandler { + @Operation('op1') + method1() {} + + @QueueSchedule('* * * * *') + scheduled() {} + } + + const ctor = DisabledWithOps as any; + expect(ctor.__disabled).toBe(true); + expect(ctor.__handlerName).toBe('disabled-with-ops'); + expect(ctor.__operations).toHaveLength(1); + expect(ctor.__schedules).toHaveLength(1); + }); + }); + + describe('Edge Cases with Method Names', () => { + it('should handle special method names', () => { + class TestHandler extends BaseHandler { + @Operation('toString-op') + toString() { + return 'test'; + } + + @Operation('valueOf-op') + valueOf() { + return 42; + } + + @Operation('hasOwnProperty-op') + hasOwnProperty(v: string | symbol): boolean { + return super.hasOwnProperty(v); + } + } + + const ctor = TestHandler as any; + expect(ctor.__operations.map((op: any) => op.method)).toEqual([ + 'toString', + 'valueOf', + 'hasOwnProperty', + ]); + }); + }); +}); diff --git a/libs/core/handlers/test/index.test.ts b/libs/core/handlers/test/index.test.ts index 7e85118..4935d3a 100644 --- a/libs/core/handlers/test/index.test.ts +++ b/libs/core/handlers/test/index.test.ts @@ -1,103 +1,103 @@ -import { describe, it, expect } from 'bun:test'; -import * as handlersExports from '../src'; -import { BaseHandler, ScheduledHandler } from '../src'; - -describe('Handlers Package Exports', () => { - it('should export base handler classes', () => { - expect(handlersExports.BaseHandler).toBeDefined(); - expect(handlersExports.ScheduledHandler).toBeDefined(); - expect(handlersExports.BaseHandler).toBe(BaseHandler); - expect(handlersExports.ScheduledHandler).toBe(ScheduledHandler); - }); - - it('should export utility functions', () => { - expect(handlersExports.createJobHandler).toBeDefined(); - expect(typeof handlersExports.createJobHandler).toBe('function'); - }); - - it('should export decorators', () => { - expect(handlersExports.Handler).toBeDefined(); - expect(handlersExports.Operation).toBeDefined(); - expect(handlersExports.QueueSchedule).toBeDefined(); - expect(handlersExports.ScheduledOperation).toBeDefined(); - expect(handlersExports.Disabled).toBeDefined(); - - // All decorators should be functions - expect(typeof handlersExports.Handler).toBe('function'); - expect(typeof handlersExports.Operation).toBe('function'); - expect(typeof handlersExports.QueueSchedule).toBe('function'); - expect(typeof handlersExports.ScheduledOperation).toBe('function'); - expect(typeof handlersExports.Disabled).toBe('function'); - }); - - it('should export auto-registration utilities', () => { - expect(handlersExports.autoRegisterHandlers).toBeDefined(); - expect(handlersExports.createAutoHandlerRegistry).toBeDefined(); - expect(typeof handlersExports.autoRegisterHandlers).toBe('function'); - expect(typeof handlersExports.createAutoHandlerRegistry).toBe('function'); - }); - - it('should export types', () => { - // Type tests - compile-time checks - type TestJobScheduleOptions = handlersExports.JobScheduleOptions; - type TestExecutionContext = handlersExports.ExecutionContext; - type TestIHandler = handlersExports.IHandler; - type TestJobHandler = handlersExports.JobHandler; - type TestScheduledJob = handlersExports.ScheduledJob; - type TestHandlerConfig = handlersExports.HandlerConfig; - type TestHandlerConfigWithSchedule = handlersExports.HandlerConfigWithSchedule; - type TestTypedJobHandler = handlersExports.TypedJobHandler; - type TestHandlerMetadata = handlersExports.HandlerMetadata; - type TestOperationMetadata = handlersExports.OperationMetadata; - type TestIServiceContainer = handlersExports.IServiceContainer; - - // Runtime type usage tests - const scheduleOptions: TestJobScheduleOptions = { - pattern: '*/5 * * * *', - priority: 10, - }; - - const executionContext: TestExecutionContext = { - jobId: 'test-job', - attemptNumber: 1, - maxAttempts: 3, - }; - - const handlerMetadata: TestHandlerMetadata = { - handlerName: 'TestHandler', - operationName: 'testOperation', - queueName: 'test-queue', - options: {}, - }; - - const operationMetadata: TestOperationMetadata = { - operationName: 'testOp', - handlerName: 'TestHandler', - operationPath: 'test.op', - serviceName: 'test-service', - }; - - expect(scheduleOptions).toBeDefined(); - expect(executionContext).toBeDefined(); - expect(handlerMetadata).toBeDefined(); - expect(operationMetadata).toBeDefined(); - }); - - it('should have correct class inheritance', () => { - // ScheduledHandler should extend BaseHandler - const mockServices = { - cache: null, - globalCache: null, - queueManager: null, - proxy: null, - browser: null, - mongodb: null, - postgres: null, - questdb: null, - } as any; - - const handler = new ScheduledHandler(mockServices); - expect(handler).toBeInstanceOf(BaseHandler); - expect(handler).toBeInstanceOf(ScheduledHandler); - }); -}); \ No newline at end of file +import { describe, expect, it } from 'bun:test'; +import * as handlersExports from '../src'; +import { BaseHandler, ScheduledHandler } from '../src'; + +describe('Handlers Package Exports', () => { + it('should export base handler classes', () => { + expect(handlersExports.BaseHandler).toBeDefined(); + expect(handlersExports.ScheduledHandler).toBeDefined(); + expect(handlersExports.BaseHandler).toBe(BaseHandler); + expect(handlersExports.ScheduledHandler).toBe(ScheduledHandler); + }); + + it('should export utility functions', () => { + expect(handlersExports.createJobHandler).toBeDefined(); + expect(typeof handlersExports.createJobHandler).toBe('function'); + }); + + it('should export decorators', () => { + expect(handlersExports.Handler).toBeDefined(); + expect(handlersExports.Operation).toBeDefined(); + expect(handlersExports.QueueSchedule).toBeDefined(); + expect(handlersExports.ScheduledOperation).toBeDefined(); + expect(handlersExports.Disabled).toBeDefined(); + + // All decorators should be functions + expect(typeof handlersExports.Handler).toBe('function'); + expect(typeof handlersExports.Operation).toBe('function'); + expect(typeof handlersExports.QueueSchedule).toBe('function'); + expect(typeof handlersExports.ScheduledOperation).toBe('function'); + expect(typeof handlersExports.Disabled).toBe('function'); + }); + + it('should export auto-registration utilities', () => { + expect(handlersExports.autoRegisterHandlers).toBeDefined(); + expect(handlersExports.createAutoHandlerRegistry).toBeDefined(); + expect(typeof handlersExports.autoRegisterHandlers).toBe('function'); + expect(typeof handlersExports.createAutoHandlerRegistry).toBe('function'); + }); + + it('should export types', () => { + // Type tests - compile-time checks + type TestJobScheduleOptions = handlersExports.JobScheduleOptions; + type TestExecutionContext = handlersExports.ExecutionContext; + type TestIHandler = handlersExports.IHandler; + type TestJobHandler = handlersExports.JobHandler; + type TestScheduledJob = handlersExports.ScheduledJob; + type TestHandlerConfig = handlersExports.HandlerConfig; + type TestHandlerConfigWithSchedule = handlersExports.HandlerConfigWithSchedule; + type TestTypedJobHandler = handlersExports.TypedJobHandler; + type TestHandlerMetadata = handlersExports.HandlerMetadata; + type TestOperationMetadata = handlersExports.OperationMetadata; + type TestIServiceContainer = handlersExports.IServiceContainer; + + // Runtime type usage tests + const scheduleOptions: TestJobScheduleOptions = { + pattern: '*/5 * * * *', + priority: 10, + }; + + const executionContext: TestExecutionContext = { + jobId: 'test-job', + attemptNumber: 1, + maxAttempts: 3, + }; + + const handlerMetadata: TestHandlerMetadata = { + handlerName: 'TestHandler', + operationName: 'testOperation', + queueName: 'test-queue', + options: {}, + }; + + const operationMetadata: TestOperationMetadata = { + operationName: 'testOp', + handlerName: 'TestHandler', + operationPath: 'test.op', + serviceName: 'test-service', + }; + + expect(scheduleOptions).toBeDefined(); + expect(executionContext).toBeDefined(); + expect(handlerMetadata).toBeDefined(); + expect(operationMetadata).toBeDefined(); + }); + + it('should have correct class inheritance', () => { + // ScheduledHandler should extend BaseHandler + const mockServices = { + cache: null, + globalCache: null, + queueManager: null, + proxy: null, + browser: null, + mongodb: null, + postgres: null, + questdb: null, + } as any; + + const handler = new ScheduledHandler(mockServices); + expect(handler).toBeInstanceOf(BaseHandler); + expect(handler).toBeInstanceOf(ScheduledHandler); + }); +}); diff --git a/libs/core/queue/src/index.ts b/libs/core/queue/src/index.ts index 0426f43..67f6d19 100644 --- a/libs/core/queue/src/index.ts +++ b/libs/core/queue/src/index.ts @@ -10,7 +10,6 @@ export { parseQueueName, } from './service-utils'; - // Batch processing export { processBatchJob, processItems } from './batch-processor'; diff --git a/libs/core/queue/src/queue-manager.ts b/libs/core/queue/src/queue-manager.ts index aeee22a..16bd92e 100644 --- a/libs/core/queue/src/queue-manager.ts +++ b/libs/core/queue/src/queue-manager.ts @@ -219,10 +219,14 @@ export class QueueManager { ttl: 86400, // 24 hours default enableMetrics: true, logger: { - info: (...args: unknown[]) => this.logger.info(String(args[0]), args[1] as Record), - error: (...args: unknown[]) => this.logger.error(String(args[0]), args[1] as Record), - warn: (...args: unknown[]) => this.logger.warn(String(args[0]), args[1] as Record), - debug: (...args: unknown[]) => this.logger.debug(String(args[0]), args[1] as Record), + info: (...args: unknown[]) => + this.logger.info(String(args[0]), args[1] as Record), + error: (...args: unknown[]) => + this.logger.error(String(args[0]), args[1] as Record), + warn: (...args: unknown[]) => + this.logger.warn(String(args[0]), args[1] as Record), + debug: (...args: unknown[]) => + this.logger.debug(String(args[0]), args[1] as Record), }, }); this.caches.set(queueName, cacheProvider); diff --git a/libs/core/shutdown/src/constants.ts b/libs/core/shutdown/src/constants.ts index e3cb0b6..1f744e1 100644 --- a/libs/core/shutdown/src/constants.ts +++ b/libs/core/shutdown/src/constants.ts @@ -1,35 +1,35 @@ -/** - * Core constants used across the stock-bot application - */ - -// Cache constants -export const CACHE_DEFAULTS = { - TTL: 3600, // 1 hour in seconds - KEY_PREFIX: 'cache:', - SCAN_COUNT: 100, -} as const; - -// Redis connection constants -export const REDIS_DEFAULTS = { - DB: 0, - MAX_RETRIES: 3, - RETRY_DELAY: 100, - CONNECT_TIMEOUT: 10000, - COMMAND_TIMEOUT: 5000, - KEEP_ALIVE: 0, -} as const; - -// Shutdown constants -export const SHUTDOWN_DEFAULTS = { - TIMEOUT: 30000, // 30 seconds - HIGH_PRIORITY: 10, - MEDIUM_PRIORITY: 50, - LOW_PRIORITY: 90, -} as const; - -// Pool size constants -export const POOL_SIZE_DEFAULTS = { - MIN_POOL_SIZE: 2, - MAX_POOL_SIZE: 10, - CPU_MULTIPLIER: 2, -} as const; \ No newline at end of file +/** + * Core constants used across the stock-bot application + */ + +// Cache constants +export const CACHE_DEFAULTS = { + TTL: 3600, // 1 hour in seconds + KEY_PREFIX: 'cache:', + SCAN_COUNT: 100, +} as const; + +// Redis connection constants +export const REDIS_DEFAULTS = { + DB: 0, + MAX_RETRIES: 3, + RETRY_DELAY: 100, + CONNECT_TIMEOUT: 10000, + COMMAND_TIMEOUT: 5000, + KEEP_ALIVE: 0, +} as const; + +// Shutdown constants +export const SHUTDOWN_DEFAULTS = { + TIMEOUT: 30000, // 30 seconds + HIGH_PRIORITY: 10, + MEDIUM_PRIORITY: 50, + LOW_PRIORITY: 90, +} as const; + +// Pool size constants +export const POOL_SIZE_DEFAULTS = { + MIN_POOL_SIZE: 2, + MAX_POOL_SIZE: 10, + CPU_MULTIPLIER: 2, +} as const; diff --git a/libs/core/shutdown/src/index.ts b/libs/core/shutdown/src/index.ts index eb0892a..eeb90b7 100644 --- a/libs/core/shutdown/src/index.ts +++ b/libs/core/shutdown/src/index.ts @@ -12,4 +12,4 @@ export function onShutdown( ): void { const shutdown = Shutdown.getInstance(); shutdown.onShutdown(callback, priority, name); -} \ No newline at end of file +} diff --git a/libs/core/shutdown/src/shutdown.ts b/libs/core/shutdown/src/shutdown.ts index 434601a..f2b4cc2 100644 --- a/libs/core/shutdown/src/shutdown.ts +++ b/libs/core/shutdown/src/shutdown.ts @@ -33,8 +33,14 @@ export class Shutdown { /** * Register a cleanup callback */ - onShutdown(callback: ShutdownCallback, priority: number = SHUTDOWN_DEFAULTS.MEDIUM_PRIORITY, name?: string): void { - if (this.isShuttingDown) { return }; + onShutdown( + callback: ShutdownCallback, + priority: number = SHUTDOWN_DEFAULTS.MEDIUM_PRIORITY, + name?: string + ): void { + if (this.isShuttingDown) { + return; + } this.callbacks.push({ callback, priority, name }); } @@ -42,14 +48,16 @@ export class Shutdown { * Initiate graceful shutdown */ async shutdown(): Promise { - if (this.isShuttingDown) { return }; - + if (this.isShuttingDown) { + return; + } + this.isShuttingDown = true; - - const timeout = new Promise((_, reject) => + + const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Shutdown timeout')), this.shutdownTimeout) ); - + try { await Promise.race([this.executeCallbacks(), timeout]); } catch (error) { @@ -60,7 +68,7 @@ export class Shutdown { private async executeCallbacks(): Promise { const sorted = [...this.callbacks].sort((a, b) => a.priority - b.priority); - + for (const { callback, name } of sorted) { try { await callback(); @@ -71,10 +79,12 @@ export class Shutdown { } private setupSignalHandlers(): void { - if (this.signalHandlersRegistered) { return }; - + if (this.signalHandlersRegistered) { + return; + } + const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; - + signals.forEach(signal => { process.once(signal, async () => { if (!this.isShuttingDown) { @@ -87,7 +97,7 @@ export class Shutdown { } }); }); - + this.signalHandlersRegistered = true; } -} \ No newline at end of file +} diff --git a/libs/core/shutdown/test/index.test.ts b/libs/core/shutdown/test/index.test.ts index f7fb37b..4b2dc5c 100644 --- a/libs/core/shutdown/test/index.test.ts +++ b/libs/core/shutdown/test/index.test.ts @@ -1,66 +1,66 @@ -import { describe, it, expect } from 'bun:test'; -import * as shutdownExports from '../src'; -import { Shutdown } from '../src'; - -describe('Shutdown Package Exports', () => { - it('should export all main functions', () => { - expect(shutdownExports.onShutdown).toBeDefined(); - expect(shutdownExports.onShutdownHigh).toBeDefined(); - expect(shutdownExports.onShutdownMedium).toBeDefined(); - expect(shutdownExports.onShutdownLow).toBeDefined(); - expect(shutdownExports.setShutdownTimeout).toBeDefined(); - expect(shutdownExports.isShuttingDown).toBeDefined(); - expect(shutdownExports.isShutdownSignalReceived).toBeDefined(); - expect(shutdownExports.getShutdownCallbackCount).toBeDefined(); - expect(shutdownExports.initiateShutdown).toBeDefined(); - expect(shutdownExports.shutdownAndExit).toBeDefined(); - expect(shutdownExports.resetShutdown).toBeDefined(); - }); - - it('should export Shutdown class', () => { - expect(shutdownExports.Shutdown).toBeDefined(); - expect(shutdownExports.Shutdown).toBe(Shutdown); - }); - - it('should export correct function types', () => { - expect(typeof shutdownExports.onShutdown).toBe('function'); - expect(typeof shutdownExports.onShutdownHigh).toBe('function'); - expect(typeof shutdownExports.onShutdownMedium).toBe('function'); - expect(typeof shutdownExports.onShutdownLow).toBe('function'); - expect(typeof shutdownExports.setShutdownTimeout).toBe('function'); - expect(typeof shutdownExports.isShuttingDown).toBe('function'); - expect(typeof shutdownExports.isShutdownSignalReceived).toBe('function'); - expect(typeof shutdownExports.getShutdownCallbackCount).toBe('function'); - expect(typeof shutdownExports.initiateShutdown).toBe('function'); - expect(typeof shutdownExports.shutdownAndExit).toBe('function'); - expect(typeof shutdownExports.resetShutdown).toBe('function'); - }); - - it('should export type definitions', () => { - // Type tests - these compile-time checks ensure types are exported - type TestShutdownCallback = shutdownExports.ShutdownCallback; - type TestShutdownOptions = shutdownExports.ShutdownOptions; - type TestShutdownResult = shutdownExports.ShutdownResult; - type TestPrioritizedShutdownCallback = shutdownExports.PrioritizedShutdownCallback; - - // Runtime check that types can be used - const testCallback: TestShutdownCallback = async () => {}; - const testOptions: TestShutdownOptions = { timeout: 5000, autoRegister: false }; - const testResult: TestShutdownResult = { - success: true, - callbacksExecuted: 1, - callbacksFailed: 0, - duration: 100, - }; - const testPrioritized: TestPrioritizedShutdownCallback = { - callback: testCallback, - priority: 50, - name: 'test', - }; - - expect(testCallback).toBeDefined(); - expect(testOptions).toBeDefined(); - expect(testResult).toBeDefined(); - expect(testPrioritized).toBeDefined(); - }); -}); \ No newline at end of file +import { describe, expect, it } from 'bun:test'; +import * as shutdownExports from '../src'; +import { Shutdown } from '../src'; + +describe('Shutdown Package Exports', () => { + it('should export all main functions', () => { + expect(shutdownExports.onShutdown).toBeDefined(); + expect(shutdownExports.onShutdownHigh).toBeDefined(); + expect(shutdownExports.onShutdownMedium).toBeDefined(); + expect(shutdownExports.onShutdownLow).toBeDefined(); + expect(shutdownExports.setShutdownTimeout).toBeDefined(); + expect(shutdownExports.isShuttingDown).toBeDefined(); + expect(shutdownExports.isShutdownSignalReceived).toBeDefined(); + expect(shutdownExports.getShutdownCallbackCount).toBeDefined(); + expect(shutdownExports.initiateShutdown).toBeDefined(); + expect(shutdownExports.shutdownAndExit).toBeDefined(); + expect(shutdownExports.resetShutdown).toBeDefined(); + }); + + it('should export Shutdown class', () => { + expect(shutdownExports.Shutdown).toBeDefined(); + expect(shutdownExports.Shutdown).toBe(Shutdown); + }); + + it('should export correct function types', () => { + expect(typeof shutdownExports.onShutdown).toBe('function'); + expect(typeof shutdownExports.onShutdownHigh).toBe('function'); + expect(typeof shutdownExports.onShutdownMedium).toBe('function'); + expect(typeof shutdownExports.onShutdownLow).toBe('function'); + expect(typeof shutdownExports.setShutdownTimeout).toBe('function'); + expect(typeof shutdownExports.isShuttingDown).toBe('function'); + expect(typeof shutdownExports.isShutdownSignalReceived).toBe('function'); + expect(typeof shutdownExports.getShutdownCallbackCount).toBe('function'); + expect(typeof shutdownExports.initiateShutdown).toBe('function'); + expect(typeof shutdownExports.shutdownAndExit).toBe('function'); + expect(typeof shutdownExports.resetShutdown).toBe('function'); + }); + + it('should export type definitions', () => { + // Type tests - these compile-time checks ensure types are exported + type TestShutdownCallback = shutdownExports.ShutdownCallback; + type TestShutdownOptions = shutdownExports.ShutdownOptions; + type TestShutdownResult = shutdownExports.ShutdownResult; + type TestPrioritizedShutdownCallback = shutdownExports.PrioritizedShutdownCallback; + + // Runtime check that types can be used + const testCallback: TestShutdownCallback = async () => {}; + const testOptions: TestShutdownOptions = { timeout: 5000, autoRegister: false }; + const testResult: TestShutdownResult = { + success: true, + callbacksExecuted: 1, + callbacksFailed: 0, + duration: 100, + }; + const testPrioritized: TestPrioritizedShutdownCallback = { + callback: testCallback, + priority: 50, + name: 'test', + }; + + expect(testCallback).toBeDefined(); + expect(testOptions).toBeDefined(); + expect(testResult).toBeDefined(); + expect(testPrioritized).toBeDefined(); + }); +}); diff --git a/libs/core/shutdown/test/shutdown-comprehensive.test.ts b/libs/core/shutdown/test/shutdown-comprehensive.test.ts index d6113ad..8682bd5 100644 --- a/libs/core/shutdown/test/shutdown-comprehensive.test.ts +++ b/libs/core/shutdown/test/shutdown-comprehensive.test.ts @@ -10,8 +10,8 @@ import { onShutdownMedium, resetShutdown, setShutdownTimeout, - shutdownAndExit, Shutdown, + shutdownAndExit, } from '../src'; import type { ShutdownOptions, ShutdownResult } from '../src/types'; @@ -104,7 +104,9 @@ describe('Shutdown Comprehensive Tests', () => { it('should handle negative timeout values', () => { // Should throw for negative values - expect(() => setShutdownTimeout(-1000)).toThrow('Shutdown timeout must be a positive number'); + expect(() => setShutdownTimeout(-1000)).toThrow( + 'Shutdown timeout must be a positive number' + ); }); it('should handle zero timeout', () => { @@ -415,7 +417,7 @@ describe('Shutdown Comprehensive Tests', () => { onShutdown(callback); await expect(shutdownAndExit('SIGTERM', 1)).rejects.toThrow('Process exit called'); - + expect(callback).toHaveBeenCalledTimes(1); expect(exitMock).toHaveBeenCalledWith(1); } finally { @@ -446,7 +448,7 @@ describe('Shutdown Comprehensive Tests', () => { onShutdown(callback); const result = await initiateShutdown('CUSTOM_SIGNAL'); - + expect(result.success).toBe(true); expect(callback).toHaveBeenCalled(); }); @@ -454,7 +456,7 @@ describe('Shutdown Comprehensive Tests', () => { it('should handle shutdown from getInstance without options', () => { const instance = Shutdown.getInstance(); expect(instance).toBeInstanceOf(Shutdown); - + // Call again to test singleton const instance2 = Shutdown.getInstance(); expect(instance2).toBe(instance); @@ -464,11 +466,11 @@ describe('Shutdown Comprehensive Tests', () => { // Start fresh resetShutdown(); expect(getShutdownCallbackCount()).toBe(0); - + // Add callback - this creates global instance onShutdown(async () => {}); expect(getShutdownCallbackCount()).toBe(1); - + // Reset and verify resetShutdown(); expect(getShutdownCallbackCount()).toBe(0); @@ -484,7 +486,7 @@ describe('Shutdown Comprehensive Tests', () => { onShutdown(undefinedRejectCallback, 'undefined-reject'); const result = await initiateShutdown(); - + expect(result.callbacksFailed).toBe(1); expect(result.success).toBe(false); }); @@ -497,7 +499,7 @@ describe('Shutdown Comprehensive Tests', () => { onShutdown(nullRejectCallback, 'null-reject'); const result = await initiateShutdown(); - + expect(result.callbacksFailed).toBe(1); expect(result.success).toBe(false); }); @@ -506,7 +508,7 @@ describe('Shutdown Comprehensive Tests', () => { const syncCallback = mock(() => { // Synchronous - returns void }); - + const asyncCallback = mock(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); @@ -515,7 +517,7 @@ describe('Shutdown Comprehensive Tests', () => { onShutdown(asyncCallback); const result = await initiateShutdown(); - + expect(result.callbacksExecuted).toBe(2); expect(syncCallback).toHaveBeenCalled(); expect(asyncCallback).toHaveBeenCalled(); @@ -525,27 +527,27 @@ describe('Shutdown Comprehensive Tests', () => { describe('Shutdown Method Variants', () => { it('should handle direct priority parameter in onShutdown', () => { const callback = mock(async () => {}); - + // Test with name and priority swapped (legacy support) onShutdown(callback, 75, 'custom-name'); - + expect(getShutdownCallbackCount()).toBe(1); }); it('should handle callback without any parameters', () => { const callback = mock(async () => {}); - + onShutdown(callback); - + expect(getShutdownCallbackCount()).toBe(1); }); it('should validate setTimeout input', () => { const shutdown = new Shutdown(); - + // Valid timeout expect(() => shutdown.setTimeout(5000)).not.toThrow(); - + // Invalid timeouts should throw expect(() => shutdown.setTimeout(-1)).toThrow(); expect(() => shutdown.setTimeout(0)).toThrow(); diff --git a/libs/core/shutdown/test/shutdown-signals.test.ts b/libs/core/shutdown/test/shutdown-signals.test.ts index 63424d9..a30bcc0 100644 --- a/libs/core/shutdown/test/shutdown-signals.test.ts +++ b/libs/core/shutdown/test/shutdown-signals.test.ts @@ -1,254 +1,254 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; -import { Shutdown } from '../src/shutdown'; - -describe('Shutdown Signal Handlers', () => { - let shutdown: Shutdown; - let processOnSpy: any; - let processExitSpy: any; - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); - const originalOn = process.on; - const originalExit = process.exit; - - beforeEach(() => { - // Reset singleton instance - (Shutdown as any).instance = null; - - // Clean up global flag - delete (global as any).__SHUTDOWN_SIGNAL_RECEIVED__; - - // Mock process.on - const listeners: Record = {}; - processOnSpy = mock((event: string, handler: Function) => { - if (!listeners[event]) { - listeners[event] = []; - } - listeners[event].push(handler); - }); - process.on = processOnSpy as any; - - // Mock process.exit - processExitSpy = mock((code?: number) => { - // Just record the call, don't throw - return; - }); - process.exit = processExitSpy as any; - - // Store listeners for manual triggering - (global as any).__testListeners = listeners; - }); - - afterEach(() => { - // Restore original methods - process.on = originalOn; - process.exit = originalExit; - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform); - } - - // Clean up - (Shutdown as any).instance = null; - delete (global as any).__testListeners; - }); - - describe('Signal Handler Registration', () => { - it('should register Unix signal handlers on non-Windows', () => { - Object.defineProperty(process, 'platform', { - value: 'linux', - configurable: true, - }); - - shutdown = new Shutdown({ autoRegister: true }); - - // Check that Unix signals were registered - expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith('SIGUSR2', expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith('uncaughtException', expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith('unhandledRejection', expect.any(Function)); - }); - - it('should register Windows signal handlers on Windows', () => { - Object.defineProperty(process, 'platform', { - value: 'win32', - configurable: true, - }); - - shutdown = new Shutdown({ autoRegister: true }); - - // Check that Windows signals were registered - expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); - expect(processOnSpy).not.toHaveBeenCalledWith('SIGUSR2', expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith('uncaughtException', expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith('unhandledRejection', expect.any(Function)); - }); - - it('should not register handlers when autoRegister is false', () => { - shutdown = new Shutdown({ autoRegister: false }); - - expect(processOnSpy).not.toHaveBeenCalled(); - }); - - it('should not register handlers twice', () => { - shutdown = new Shutdown({ autoRegister: true }); - const callCount = processOnSpy.mock.calls.length; - - // Try to setup handlers again (internally) - shutdown['setupSignalHandlers'](); - - // Should not register additional handlers - expect(processOnSpy.mock.calls.length).toBe(callCount); - }); - }); - - describe('Signal Handler Behavior', () => { - it('should handle SIGTERM signal', async () => { - shutdown = new Shutdown({ autoRegister: true }); - const callback = mock(async () => {}); - shutdown.onShutdown(callback); - - const listeners = (global as any).__testListeners; - const sigtermHandler = listeners['SIGTERM'][0]; - - // Trigger SIGTERM (this starts async shutdown) - sigtermHandler(); - - // Verify flags are set immediately - expect(shutdown.isShutdownSignalReceived()).toBe(true); - expect((global as any).__SHUTDOWN_SIGNAL_RECEIVED__).toBe(true); - - // Wait a bit for async shutdown to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Now process.exit should have been called - expect(processExitSpy).toHaveBeenCalledWith(0); - }); - - it('should handle SIGINT signal', async () => { - shutdown = new Shutdown({ autoRegister: true }); - const callback = mock(async () => {}); - shutdown.onShutdown(callback); - - const listeners = (global as any).__testListeners; - const sigintHandler = listeners['SIGINT'][0]; - - // Trigger SIGINT (this starts async shutdown) - sigintHandler(); - - // Verify flags are set immediately - expect(shutdown.isShutdownSignalReceived()).toBe(true); - - // Wait a bit for async shutdown to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Now process.exit should have been called - expect(processExitSpy).toHaveBeenCalledWith(0); - }); - - it('should handle uncaughtException', async () => { - shutdown = new Shutdown({ autoRegister: true }); - - const listeners = (global as any).__testListeners; - const exceptionHandler = listeners['uncaughtException'][0]; - - // Trigger uncaughtException (this starts async shutdown with exit code 1) - exceptionHandler(new Error('Uncaught error')); - - // Wait a bit for async shutdown to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Should exit with code 1 for uncaught exceptions - expect(processExitSpy).toHaveBeenCalledWith(1); - }); - - it('should handle unhandledRejection', async () => { - shutdown = new Shutdown({ autoRegister: true }); - - const listeners = (global as any).__testListeners; - const rejectionHandler = listeners['unhandledRejection'][0]; - - // Trigger unhandledRejection (this starts async shutdown with exit code 1) - rejectionHandler(new Error('Unhandled rejection')); - - // Wait a bit for async shutdown to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Should exit with code 1 for unhandled rejections - expect(processExitSpy).toHaveBeenCalledWith(1); - }); - - it('should not process signal if already shutting down', async () => { - shutdown = new Shutdown({ autoRegister: true }); - - // Start shutdown - shutdown['isShuttingDown'] = true; - - const listeners = (global as any).__testListeners; - const sigtermHandler = listeners['SIGTERM'][0]; - - // Mock shutdownAndExit to track calls - const shutdownAndExitSpy = mock(() => Promise.resolve()); - shutdown.shutdownAndExit = shutdownAndExitSpy as any; - - // Trigger SIGTERM - sigtermHandler(); - - // Should not call shutdownAndExit since already shutting down - expect(shutdownAndExitSpy).not.toHaveBeenCalled(); - }); - - it('should handle shutdown failure in signal handler', async () => { - shutdown = new Shutdown({ autoRegister: true }); - - // Mock shutdownAndExit to reject - shutdown.shutdownAndExit = mock(async () => { - throw new Error('Shutdown failed'); - }) as any; - - const listeners = (global as any).__testListeners; - const sigtermHandler = listeners['SIGTERM'][0]; - - // Trigger SIGTERM - should fall back to process.exit(1) - sigtermHandler(); - - // Wait a bit for async shutdown to fail and fallback to occur - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(processExitSpy).toHaveBeenCalledWith(1); - }); - }); - - describe('Global Flag Behavior', () => { - it('should set global shutdown flag on signal', async () => { - delete (global as any).__SHUTDOWN_SIGNAL_RECEIVED__; - - shutdown = new Shutdown({ autoRegister: true }); - - const listeners = (global as any).__testListeners; - const sigtermHandler = listeners['SIGTERM'][0]; - - // Trigger signal (this sets the flag immediately) - sigtermHandler(); - - expect((global as any).__SHUTDOWN_SIGNAL_RECEIVED__).toBe(true); - - // Wait for async shutdown to complete to avoid hanging promises - await new Promise(resolve => setTimeout(resolve, 10)); - }); - - it('should check global flag in isShutdownSignalReceived', () => { - shutdown = new Shutdown({ autoRegister: false }); - - expect(shutdown.isShutdownSignalReceived()).toBe(false); - - // Set global flag - (global as any).__SHUTDOWN_SIGNAL_RECEIVED__ = true; - - // Even without instance flag, should return true - expect(shutdown.isShutdownSignalReceived()).toBe(true); - - // Clean up - delete (global as any).__SHUTDOWN_SIGNAL_RECEIVED__; - }); - }); -}); \ No newline at end of file +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { Shutdown } from '../src/shutdown'; + +describe('Shutdown Signal Handlers', () => { + let shutdown: Shutdown; + let processOnSpy: any; + let processExitSpy: any; + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + const originalOn = process.on; + const originalExit = process.exit; + + beforeEach(() => { + // Reset singleton instance + (Shutdown as any).instance = null; + + // Clean up global flag + delete (global as any).__SHUTDOWN_SIGNAL_RECEIVED__; + + // Mock process.on + const listeners: Record = {}; + processOnSpy = mock((event: string, handler: Function) => { + if (!listeners[event]) { + listeners[event] = []; + } + listeners[event].push(handler); + }); + process.on = processOnSpy as any; + + // Mock process.exit + processExitSpy = mock((code?: number) => { + // Just record the call, don't throw + return; + }); + process.exit = processExitSpy as any; + + // Store listeners for manual triggering + (global as any).__testListeners = listeners; + }); + + afterEach(() => { + // Restore original methods + process.on = originalOn; + process.exit = originalExit; + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + + // Clean up + (Shutdown as any).instance = null; + delete (global as any).__testListeners; + }); + + describe('Signal Handler Registration', () => { + it('should register Unix signal handlers on non-Windows', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + + shutdown = new Shutdown({ autoRegister: true }); + + // Check that Unix signals were registered + expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('SIGUSR2', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('uncaughtException', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('unhandledRejection', expect.any(Function)); + }); + + it('should register Windows signal handlers on Windows', () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + + shutdown = new Shutdown({ autoRegister: true }); + + // Check that Windows signals were registered + expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(processOnSpy).not.toHaveBeenCalledWith('SIGUSR2', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('uncaughtException', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('unhandledRejection', expect.any(Function)); + }); + + it('should not register handlers when autoRegister is false', () => { + shutdown = new Shutdown({ autoRegister: false }); + + expect(processOnSpy).not.toHaveBeenCalled(); + }); + + it('should not register handlers twice', () => { + shutdown = new Shutdown({ autoRegister: true }); + const callCount = processOnSpy.mock.calls.length; + + // Try to setup handlers again (internally) + shutdown['setupSignalHandlers'](); + + // Should not register additional handlers + expect(processOnSpy.mock.calls.length).toBe(callCount); + }); + }); + + describe('Signal Handler Behavior', () => { + it('should handle SIGTERM signal', async () => { + shutdown = new Shutdown({ autoRegister: true }); + const callback = mock(async () => {}); + shutdown.onShutdown(callback); + + const listeners = (global as any).__testListeners; + const sigtermHandler = listeners['SIGTERM'][0]; + + // Trigger SIGTERM (this starts async shutdown) + sigtermHandler(); + + // Verify flags are set immediately + expect(shutdown.isShutdownSignalReceived()).toBe(true); + expect((global as any).__SHUTDOWN_SIGNAL_RECEIVED__).toBe(true); + + // Wait a bit for async shutdown to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + // Now process.exit should have been called + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it('should handle SIGINT signal', async () => { + shutdown = new Shutdown({ autoRegister: true }); + const callback = mock(async () => {}); + shutdown.onShutdown(callback); + + const listeners = (global as any).__testListeners; + const sigintHandler = listeners['SIGINT'][0]; + + // Trigger SIGINT (this starts async shutdown) + sigintHandler(); + + // Verify flags are set immediately + expect(shutdown.isShutdownSignalReceived()).toBe(true); + + // Wait a bit for async shutdown to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + // Now process.exit should have been called + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it('should handle uncaughtException', async () => { + shutdown = new Shutdown({ autoRegister: true }); + + const listeners = (global as any).__testListeners; + const exceptionHandler = listeners['uncaughtException'][0]; + + // Trigger uncaughtException (this starts async shutdown with exit code 1) + exceptionHandler(new Error('Uncaught error')); + + // Wait a bit for async shutdown to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + // Should exit with code 1 for uncaught exceptions + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle unhandledRejection', async () => { + shutdown = new Shutdown({ autoRegister: true }); + + const listeners = (global as any).__testListeners; + const rejectionHandler = listeners['unhandledRejection'][0]; + + // Trigger unhandledRejection (this starts async shutdown with exit code 1) + rejectionHandler(new Error('Unhandled rejection')); + + // Wait a bit for async shutdown to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + // Should exit with code 1 for unhandled rejections + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should not process signal if already shutting down', async () => { + shutdown = new Shutdown({ autoRegister: true }); + + // Start shutdown + shutdown['isShuttingDown'] = true; + + const listeners = (global as any).__testListeners; + const sigtermHandler = listeners['SIGTERM'][0]; + + // Mock shutdownAndExit to track calls + const shutdownAndExitSpy = mock(() => Promise.resolve()); + shutdown.shutdownAndExit = shutdownAndExitSpy as any; + + // Trigger SIGTERM + sigtermHandler(); + + // Should not call shutdownAndExit since already shutting down + expect(shutdownAndExitSpy).not.toHaveBeenCalled(); + }); + + it('should handle shutdown failure in signal handler', async () => { + shutdown = new Shutdown({ autoRegister: true }); + + // Mock shutdownAndExit to reject + shutdown.shutdownAndExit = mock(async () => { + throw new Error('Shutdown failed'); + }) as any; + + const listeners = (global as any).__testListeners; + const sigtermHandler = listeners['SIGTERM'][0]; + + // Trigger SIGTERM - should fall back to process.exit(1) + sigtermHandler(); + + // Wait a bit for async shutdown to fail and fallback to occur + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('Global Flag Behavior', () => { + it('should set global shutdown flag on signal', async () => { + delete (global as any).__SHUTDOWN_SIGNAL_RECEIVED__; + + shutdown = new Shutdown({ autoRegister: true }); + + const listeners = (global as any).__testListeners; + const sigtermHandler = listeners['SIGTERM'][0]; + + // Trigger signal (this sets the flag immediately) + sigtermHandler(); + + expect((global as any).__SHUTDOWN_SIGNAL_RECEIVED__).toBe(true); + + // Wait for async shutdown to complete to avoid hanging promises + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + it('should check global flag in isShutdownSignalReceived', () => { + shutdown = new Shutdown({ autoRegister: false }); + + expect(shutdown.isShutdownSignalReceived()).toBe(false); + + // Set global flag + (global as any).__SHUTDOWN_SIGNAL_RECEIVED__ = true; + + // Even without instance flag, should return true + expect(shutdown.isShutdownSignalReceived()).toBe(true); + + // Clean up + delete (global as any).__SHUTDOWN_SIGNAL_RECEIVED__; + }); + }); +}); diff --git a/libs/data/mongodb/src/simple-mongodb.ts b/libs/data/mongodb/src/simple-mongodb.ts index 5030838..27051bc 100644 --- a/libs/data/mongodb/src/simple-mongodb.ts +++ b/libs/data/mongodb/src/simple-mongodb.ts @@ -16,7 +16,9 @@ export class SimpleMongoDBClient { } async find(collection: string, filter: any = {}): Promise { - if (!this.connected) {await this.connect();} + if (!this.connected) { + await this.connect(); + } const docs = this.collections.get(collection) || []; // Simple filter matching @@ -26,7 +28,9 @@ export class SimpleMongoDBClient { return docs.filter(doc => { for (const [key, value] of Object.entries(filter)) { - if (doc[key] !== value) {return false;} + if (doc[key] !== value) { + return false; + } } return true; }); @@ -38,7 +42,9 @@ export class SimpleMongoDBClient { } async insert(collection: string, doc: any): Promise { - if (!this.connected) {await this.connect();} + if (!this.connected) { + await this.connect(); + } const docs = this.collections.get(collection) || []; docs.push({ ...doc, _id: Math.random().toString(36) }); this.collections.set(collection, docs); @@ -51,10 +57,14 @@ export class SimpleMongoDBClient { } async update(collection: string, filter: any, update: any): Promise { - if (!this.connected) {await this.connect();} + if (!this.connected) { + await this.connect(); + } const docs = await this.find(collection, filter); - if (docs.length === 0) {return 0;} + if (docs.length === 0) { + return 0; + } const doc = docs[0]; if (update.$set) { @@ -65,7 +75,9 @@ export class SimpleMongoDBClient { } async updateMany(collection: string, filter: any, update: any): Promise { - if (!this.connected) {await this.connect();} + if (!this.connected) { + await this.connect(); + } const docs = await this.find(collection, filter); for (const doc of docs) { @@ -78,11 +90,15 @@ export class SimpleMongoDBClient { } async delete(collection: string, filter: any): Promise { - if (!this.connected) {await this.connect();} + if (!this.connected) { + await this.connect(); + } const allDocs = this.collections.get(collection) || []; const toDelete = await this.find(collection, filter); - if (toDelete.length === 0) {return 0;} + if (toDelete.length === 0) { + return 0; + } const remaining = allDocs.filter(doc => !toDelete.includes(doc)); this.collections.set(collection, remaining); @@ -91,7 +107,9 @@ export class SimpleMongoDBClient { } async deleteMany(collection: string, filter: any): Promise { - if (!this.connected) {await this.connect();} + if (!this.connected) { + await this.connect(); + } const allDocs = this.collections.get(collection) || []; const toDelete = await this.find(collection, filter); @@ -102,7 +120,9 @@ export class SimpleMongoDBClient { } async batchUpsert(collection: string, documents: any[], uniqueKeys: string[]): Promise { - if (!this.connected) {await this.connect();} + if (!this.connected) { + await this.connect(); + } for (const doc of documents) { const filter: any = {}; diff --git a/libs/data/postgres/src/simple-postgres.ts b/libs/data/postgres/src/simple-postgres.ts index a5612e5..750d50d 100644 --- a/libs/data/postgres/src/simple-postgres.ts +++ b/libs/data/postgres/src/simple-postgres.ts @@ -22,18 +22,24 @@ export class SimplePostgresClient { break; } } - if (match) {return row;} + if (match) { + return row; + } } return null; } async find(table: string, where: any): Promise { const rows = this.tables.get(table) || []; - if (Object.keys(where).length === 0) {return rows;} + if (Object.keys(where).length === 0) { + return rows; + } return rows.filter(row => { for (const [key, value] of Object.entries(where)) { - if (row[key] !== value) {return false;} + if (row[key] !== value) { + return false; + } } return true; }); @@ -72,7 +78,9 @@ export class SimplePostgresClient { const rows = this.tables.get(table) || []; const remaining = rows.filter(row => { for (const [key, value] of Object.entries(where)) { - if (row[key] !== value) {return true;} + if (row[key] !== value) { + return true; + } } return false; }); diff --git a/libs/services/browser/test/browser.test.ts b/libs/services/browser/test/browser.test.ts index f406f19..5536372 100644 --- a/libs/services/browser/test/browser.test.ts +++ b/libs/services/browser/test/browser.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, mock } from 'bun:test'; import { SimpleBrowser } from '../src/simple-browser'; - describe('Browser', () => { let browser: SimpleBrowser; const logger = { diff --git a/libs/utils/test/fetch.test.ts b/libs/utils/test/fetch.test.ts index 20c25c1..2ab3b4a 100644 --- a/libs/utils/test/fetch.test.ts +++ b/libs/utils/test/fetch.test.ts @@ -1,286 +1,284 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; -import { fetch } from '../src/fetch'; - -describe('Enhanced Fetch', () => { - let originalFetch: typeof globalThis.fetch; - let mockFetch: any; - let mockLogger: any; - - beforeEach(() => { - originalFetch = globalThis.fetch; - mockFetch = mock(() => Promise.resolve(new Response('test'))); - globalThis.fetch = mockFetch; - - mockLogger = { - debug: mock(() => {}), - info: mock(() => {}), - error: mock(() => {}), - }; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - describe('basic fetch', () => { - it('should make simple GET request', async () => { - const mockResponse = new Response('test data', { status: 200 }); - mockFetch.mockResolvedValue(mockResponse); - - const response = await fetch('https://api.example.com/data'); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', { - method: 'GET', - headers: {}, - }); - expect(response).toBe(mockResponse); - }); - - it('should make POST request with body', async () => { - const mockResponse = new Response('created', { status: 201 }); - mockFetch.mockResolvedValue(mockResponse); - - const body = JSON.stringify({ name: 'test' }); - const response = await fetch('https://api.example.com/data', { - method: 'POST', - body, - headers: { 'Content-Type': 'application/json' }, - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', { - method: 'POST', - body, - headers: { 'Content-Type': 'application/json' }, - }); - expect(response).toBe(mockResponse); - }); - - it('should handle URL objects', async () => { - const mockResponse = new Response('test'); - mockFetch.mockResolvedValue(mockResponse); - - const url = new URL('https://api.example.com/data'); - await fetch(url); - - expect(mockFetch).toHaveBeenCalledWith(url, expect.any(Object)); - }); - - it('should handle Request objects', async () => { - const mockResponse = new Response('test'); - mockFetch.mockResolvedValue(mockResponse); - - const request = new Request('https://api.example.com/data', { - method: 'PUT', - }); - await fetch(request); - - expect(mockFetch).toHaveBeenCalledWith(request, expect.any(Object)); - }); - }); - - describe('proxy support', () => { - it('should add proxy to request options', async () => { - const mockResponse = new Response('proxy test'); - mockFetch.mockResolvedValue(mockResponse); - - await fetch('https://api.example.com/data', { - proxy: 'http://proxy.example.com:8080', - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/data', - expect.objectContaining({ - proxy: 'http://proxy.example.com:8080', - }) - ); - }); - - it('should handle null proxy', async () => { - const mockResponse = new Response('no proxy'); - mockFetch.mockResolvedValue(mockResponse); - - await fetch('https://api.example.com/data', { - proxy: null, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/data', - expect.not.objectContaining({ - proxy: expect.anything(), - }) - ); - }); - }); - - describe('timeout support', () => { - it('should handle timeout', async () => { - mockFetch.mockImplementation((url, options) => { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => resolve(new Response('delayed')), 100); - - // Listen for abort signal - if (options?.signal) { - options.signal.addEventListener('abort', () => { - clearTimeout(timeoutId); - reject(new DOMException('The operation was aborted', 'AbortError')); - }); - } - }); - }); - - await expect( - fetch('https://api.example.com/data', { timeout: 50 }) - ).rejects.toThrow('The operation was aborted'); - }); - - it('should clear timeout on success', async () => { - const mockResponse = new Response('quick response'); - mockFetch.mockResolvedValue(mockResponse); - - const response = await fetch('https://api.example.com/data', { - timeout: 1000, - }); - - expect(response).toBe(mockResponse); - }); - - it('should clear timeout on error', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - - await expect( - fetch('https://api.example.com/data', { timeout: 1000 }) - ).rejects.toThrow('Network error'); - }); - }); - - describe('logging', () => { - it('should log request details', async () => { - const mockResponse = new Response('test', { - status: 200, - statusText: 'OK', - headers: new Headers({ 'content-type': 'text/plain' }), - }); - mockFetch.mockResolvedValue(mockResponse); - - await fetch('https://api.example.com/data', { - logger: mockLogger, - method: 'POST', - headers: { Authorization: 'Bearer token' }, - }); - - expect(mockLogger.debug).toHaveBeenCalledWith('HTTP request', { - method: 'POST', - url: 'https://api.example.com/data', - headers: { Authorization: 'Bearer token' }, - proxy: null, - }); - - expect(mockLogger.debug).toHaveBeenCalledWith('HTTP response', { - url: 'https://api.example.com/data', - status: 200, - statusText: 'OK', - ok: true, - headers: { 'content-type': 'text/plain' }, - }); - }); - - it('should log errors', async () => { - const error = new Error('Connection failed'); - mockFetch.mockRejectedValue(error); - - await expect( - fetch('https://api.example.com/data', { logger: mockLogger }) - ).rejects.toThrow('Connection failed'); - - expect(mockLogger.debug).toHaveBeenCalledWith('HTTP error', { - url: 'https://api.example.com/data', - error: 'Connection failed', - name: 'Error', - }); - }); - - it('should use console as default logger', async () => { - const consoleSpy = mock(console.debug); - console.debug = consoleSpy; - - const mockResponse = new Response('test'); - mockFetch.mockResolvedValue(mockResponse); - - await fetch('https://api.example.com/data'); - - expect(consoleSpy).toHaveBeenCalledTimes(2); // Request and response - - console.debug = originalFetch as any; - }); - }); - - describe('request options', () => { - it('should forward all standard RequestInit options', async () => { - const mockResponse = new Response('test'); - mockFetch.mockResolvedValue(mockResponse); - - const controller = new AbortController(); - const options = { - method: 'PATCH' as const, - headers: { 'X-Custom': 'value' }, - body: 'data', - signal: controller.signal, - credentials: 'include' as const, - cache: 'no-store' as const, - redirect: 'manual' as const, - referrer: 'https://referrer.com', - referrerPolicy: 'no-referrer' as const, - integrity: 'sha256-hash', - keepalive: true, - mode: 'cors' as const, - }; - - await fetch('https://api.example.com/data', options); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/data', - expect.objectContaining(options) - ); - }); - - it('should handle undefined options', async () => { - const mockResponse = new Response('test'); - mockFetch.mockResolvedValue(mockResponse); - - await fetch('https://api.example.com/data', undefined); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/data', - expect.objectContaining({ - method: 'GET', - headers: {}, - }) - ); - }); - }); - - describe('error handling', () => { - it('should propagate fetch errors', async () => { - const error = new TypeError('Failed to fetch'); - mockFetch.mockRejectedValue(error); - - await expect(fetch('https://api.example.com/data')).rejects.toThrow( - 'Failed to fetch' - ); - }); - - it('should handle non-Error objects', async () => { - mockFetch.mockRejectedValue('string error'); - - await expect( - fetch('https://api.example.com/data', { logger: mockLogger }) - ).rejects.toBe('string error'); - - expect(mockLogger.debug).toHaveBeenCalledWith('HTTP error', { - url: 'https://api.example.com/data', - error: 'string error', - name: 'Unknown', - }); - }); - }); -}); \ No newline at end of file +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { fetch } from '../src/fetch'; + +describe('Enhanced Fetch', () => { + let originalFetch: typeof globalThis.fetch; + let mockFetch: any; + let mockLogger: any; + + beforeEach(() => { + originalFetch = globalThis.fetch; + mockFetch = mock(() => Promise.resolve(new Response('test'))); + globalThis.fetch = mockFetch; + + mockLogger = { + debug: mock(() => {}), + info: mock(() => {}), + error: mock(() => {}), + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('basic fetch', () => { + it('should make simple GET request', async () => { + const mockResponse = new Response('test data', { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + const response = await fetch('https://api.example.com/data'); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', { + method: 'GET', + headers: {}, + }); + expect(response).toBe(mockResponse); + }); + + it('should make POST request with body', async () => { + const mockResponse = new Response('created', { status: 201 }); + mockFetch.mockResolvedValue(mockResponse); + + const body = JSON.stringify({ name: 'test' }); + const response = await fetch('https://api.example.com/data', { + method: 'POST', + body, + headers: { 'Content-Type': 'application/json' }, + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', { + method: 'POST', + body, + headers: { 'Content-Type': 'application/json' }, + }); + expect(response).toBe(mockResponse); + }); + + it('should handle URL objects', async () => { + const mockResponse = new Response('test'); + mockFetch.mockResolvedValue(mockResponse); + + const url = new URL('https://api.example.com/data'); + await fetch(url); + + expect(mockFetch).toHaveBeenCalledWith(url, expect.any(Object)); + }); + + it('should handle Request objects', async () => { + const mockResponse = new Response('test'); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://api.example.com/data', { + method: 'PUT', + }); + await fetch(request); + + expect(mockFetch).toHaveBeenCalledWith(request, expect.any(Object)); + }); + }); + + describe('proxy support', () => { + it('should add proxy to request options', async () => { + const mockResponse = new Response('proxy test'); + mockFetch.mockResolvedValue(mockResponse); + + await fetch('https://api.example.com/data', { + proxy: 'http://proxy.example.com:8080', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + proxy: 'http://proxy.example.com:8080', + }) + ); + }); + + it('should handle null proxy', async () => { + const mockResponse = new Response('no proxy'); + mockFetch.mockResolvedValue(mockResponse); + + await fetch('https://api.example.com/data', { + proxy: null, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.not.objectContaining({ + proxy: expect.anything(), + }) + ); + }); + }); + + describe('timeout support', () => { + it('should handle timeout', async () => { + mockFetch.mockImplementation((url, options) => { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => resolve(new Response('delayed')), 100); + + // Listen for abort signal + if (options?.signal) { + options.signal.addEventListener('abort', () => { + clearTimeout(timeoutId); + reject(new DOMException('The operation was aborted', 'AbortError')); + }); + } + }); + }); + + await expect(fetch('https://api.example.com/data', { timeout: 50 })).rejects.toThrow( + 'The operation was aborted' + ); + }); + + it('should clear timeout on success', async () => { + const mockResponse = new Response('quick response'); + mockFetch.mockResolvedValue(mockResponse); + + const response = await fetch('https://api.example.com/data', { + timeout: 1000, + }); + + expect(response).toBe(mockResponse); + }); + + it('should clear timeout on error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect(fetch('https://api.example.com/data', { timeout: 1000 })).rejects.toThrow( + 'Network error' + ); + }); + }); + + describe('logging', () => { + it('should log request details', async () => { + const mockResponse = new Response('test', { + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'text/plain' }), + }); + mockFetch.mockResolvedValue(mockResponse); + + await fetch('https://api.example.com/data', { + logger: mockLogger, + method: 'POST', + headers: { Authorization: 'Bearer token' }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith('HTTP request', { + method: 'POST', + url: 'https://api.example.com/data', + headers: { Authorization: 'Bearer token' }, + proxy: null, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith('HTTP response', { + url: 'https://api.example.com/data', + status: 200, + statusText: 'OK', + ok: true, + headers: { 'content-type': 'text/plain' }, + }); + }); + + it('should log errors', async () => { + const error = new Error('Connection failed'); + mockFetch.mockRejectedValue(error); + + await expect(fetch('https://api.example.com/data', { logger: mockLogger })).rejects.toThrow( + 'Connection failed' + ); + + expect(mockLogger.debug).toHaveBeenCalledWith('HTTP error', { + url: 'https://api.example.com/data', + error: 'Connection failed', + name: 'Error', + }); + }); + + it('should use console as default logger', async () => { + const consoleSpy = mock(console.debug); + console.debug = consoleSpy; + + const mockResponse = new Response('test'); + mockFetch.mockResolvedValue(mockResponse); + + await fetch('https://api.example.com/data'); + + expect(consoleSpy).toHaveBeenCalledTimes(2); // Request and response + + console.debug = originalFetch as any; + }); + }); + + describe('request options', () => { + it('should forward all standard RequestInit options', async () => { + const mockResponse = new Response('test'); + mockFetch.mockResolvedValue(mockResponse); + + const controller = new AbortController(); + const options = { + method: 'PATCH' as const, + headers: { 'X-Custom': 'value' }, + body: 'data', + signal: controller.signal, + credentials: 'include' as const, + cache: 'no-store' as const, + redirect: 'manual' as const, + referrer: 'https://referrer.com', + referrerPolicy: 'no-referrer' as const, + integrity: 'sha256-hash', + keepalive: true, + mode: 'cors' as const, + }; + + await fetch('https://api.example.com/data', options); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining(options) + ); + }); + + it('should handle undefined options', async () => { + const mockResponse = new Response('test'); + mockFetch.mockResolvedValue(mockResponse); + + await fetch('https://api.example.com/data', undefined); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + method: 'GET', + headers: {}, + }) + ); + }); + }); + + describe('error handling', () => { + it('should propagate fetch errors', async () => { + const error = new TypeError('Failed to fetch'); + mockFetch.mockRejectedValue(error); + + await expect(fetch('https://api.example.com/data')).rejects.toThrow('Failed to fetch'); + }); + + it('should handle non-Error objects', async () => { + mockFetch.mockRejectedValue('string error'); + + await expect(fetch('https://api.example.com/data', { logger: mockLogger })).rejects.toBe( + 'string error' + ); + + expect(mockLogger.debug).toHaveBeenCalledWith('HTTP error', { + url: 'https://api.example.com/data', + error: 'string error', + name: 'Unknown', + }); + }); + }); +}); diff --git a/libs/utils/test/user-agent.test.ts b/libs/utils/test/user-agent.test.ts index 8193fa4..f85fc14 100644 --- a/libs/utils/test/user-agent.test.ts +++ b/libs/utils/test/user-agent.test.ts @@ -1,60 +1,60 @@ -import { describe, expect, it } from 'bun:test'; -import { getRandomUserAgent } from '../src/user-agent'; - -describe('User Agent', () => { - describe('getRandomUserAgent', () => { - it('should return a user agent string', () => { - const userAgent = getRandomUserAgent(); - expect(typeof userAgent).toBe('string'); - expect(userAgent.length).toBeGreaterThan(0); - }); - - it('should return a valid user agent containing Mozilla', () => { - const userAgent = getRandomUserAgent(); - expect(userAgent).toContain('Mozilla'); - }); - - it('should return different user agents on multiple calls', () => { - const userAgents = new Set(); - // Get 20 user agents - for (let i = 0; i < 20; i++) { - userAgents.add(getRandomUserAgent()); - } - // Should have at least 2 different user agents - expect(userAgents.size).toBeGreaterThan(1); - }); - - it('should return user agents with browser identifiers', () => { - const userAgent = getRandomUserAgent(); - const hasBrowser = - userAgent.includes('Chrome') || - userAgent.includes('Firefox') || - userAgent.includes('Safari') || - userAgent.includes('Edg'); - expect(hasBrowser).toBe(true); - }); - - it('should return user agents with OS identifiers', () => { - const userAgent = getRandomUserAgent(); - const hasOS = - userAgent.includes('Windows') || - userAgent.includes('Macintosh') || - userAgent.includes('Mac OS X'); - expect(hasOS).toBe(true); - }); - - it('should handle multiple concurrent calls', () => { - const promises = Array(10) - .fill(null) - .map(() => Promise.resolve(getRandomUserAgent())); - - return Promise.all(promises).then(userAgents => { - expect(userAgents).toHaveLength(10); - userAgents.forEach(ua => { - expect(typeof ua).toBe('string'); - expect(ua.length).toBeGreaterThan(0); - }); - }); - }); - }); -}); \ No newline at end of file +import { describe, expect, it } from 'bun:test'; +import { getRandomUserAgent } from '../src/user-agent'; + +describe('User Agent', () => { + describe('getRandomUserAgent', () => { + it('should return a user agent string', () => { + const userAgent = getRandomUserAgent(); + expect(typeof userAgent).toBe('string'); + expect(userAgent.length).toBeGreaterThan(0); + }); + + it('should return a valid user agent containing Mozilla', () => { + const userAgent = getRandomUserAgent(); + expect(userAgent).toContain('Mozilla'); + }); + + it('should return different user agents on multiple calls', () => { + const userAgents = new Set(); + // Get 20 user agents + for (let i = 0; i < 20; i++) { + userAgents.add(getRandomUserAgent()); + } + // Should have at least 2 different user agents + expect(userAgents.size).toBeGreaterThan(1); + }); + + it('should return user agents with browser identifiers', () => { + const userAgent = getRandomUserAgent(); + const hasBrowser = + userAgent.includes('Chrome') || + userAgent.includes('Firefox') || + userAgent.includes('Safari') || + userAgent.includes('Edg'); + expect(hasBrowser).toBe(true); + }); + + it('should return user agents with OS identifiers', () => { + const userAgent = getRandomUserAgent(); + const hasOS = + userAgent.includes('Windows') || + userAgent.includes('Macintosh') || + userAgent.includes('Mac OS X'); + expect(hasOS).toBe(true); + }); + + it('should handle multiple concurrent calls', () => { + const promises = Array(10) + .fill(null) + .map(() => Promise.resolve(getRandomUserAgent())); + + return Promise.all(promises).then(userAgents => { + expect(userAgents).toHaveLength(10); + userAgents.forEach(ua => { + expect(typeof ua).toBe('string'); + expect(ua.length).toBeGreaterThan(0); + }); + }); + }); + }); +});