feat(utils): add observability and resilience utilities

Implement comprehensive utility modules for InfiniteAPI:

Logging:
- structured-logger: JSON logging with levels, sanitization, metrics
- logger-adapter: Pino/Console/Structured adapter pattern
- baileys-logger: WhatsApp-specific logger with categories

Observability:
- trace-context: Distributed tracing with spans, correlation IDs
- prometheus-metrics: Counter, Gauge, Histogram, Summary metrics

Resilience:
- cache-utils: LRU cache with TTL, multi-level support
- circuit-breaker: 3-state circuit breaker with fallbacks
- retry-utils: Exponential backoff with jitter, predicates

Event Streaming:
- baileys-event-stream: Priority queues, backpressure, DLQ

Includes unit tests for all new modules.
This commit is contained in:
Claude
2026-01-20 01:26:06 +00:00
parent 5b5ac85443
commit 882a1f57cf
16 changed files with 7695 additions and 0 deletions
@@ -0,0 +1,645 @@
/**
* Testes unitários para baileys-event-stream.ts
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'
import {
BaileysEventStream,
createEventStream,
eventFilters,
eventTransformers,
type StreamEvent,
type BaileysEventType,
type EventPriority,
} from '../../Utils/baileys-event-stream.js'
describe('BaileysEventStream', () => {
let stream: BaileysEventStream
beforeEach(() => {
stream = createEventStream({
maxBufferSize: 100,
batchSize: 10,
collectMetrics: false,
})
})
afterEach(() => {
stream.destroy()
})
describe('push events', () => {
it('should push event to stream', () => {
const result = stream.push('messages.upsert', { message: 'test' })
expect(result).toBe(true)
expect(stream.getStats().bufferSize).toBeGreaterThan(0)
})
it('should assign priority based on event type', (done) => {
stream.on('*', (event) => {
expect(event.priority).toBe('critical')
done()
})
stream.push('connection.update', { state: 'open' })
})
it('should use custom priority when provided', (done) => {
stream.on('*', (event) => {
expect(event.priority).toBe('low')
done()
})
stream.push('messages.upsert', { message: 'test' }, { priority: 'low' })
})
it('should assign correct category', (done) => {
stream.on('*', (event) => {
expect(event.category).toBe('message')
done()
})
stream.push('messages.upsert', { message: 'test' })
})
it('should reject events when buffer is full', () => {
const smallStream = createEventStream({
maxBufferSize: 5,
enableBackpressure: true,
highWaterMark: 3,
collectMetrics: false,
})
smallStream.pause() // Prevent processing
for (let i = 0; i < 5; i++) {
smallStream.push('messages.upsert', { index: i })
}
const result = smallStream.push('messages.upsert', { overflow: true })
expect(result).toBe(false)
expect(smallStream.getStats().totalDropped).toBe(1)
smallStream.destroy()
})
})
describe('event handlers', () => {
it('should call handler for specific event type', async () => {
const handler = jest.fn()
stream.on('messages.upsert', handler)
stream.push('messages.upsert', { message: 'test' })
// Wait for processing
await new Promise((resolve) => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalledTimes(1)
})
it('should call global handler for all events', async () => {
const handler = jest.fn()
stream.on('*', handler)
stream.push('messages.upsert', { message: 'test' })
stream.push('connection.update', { state: 'open' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalledTimes(2)
})
it('should support once handler', async () => {
const handler = jest.fn()
stream.once('messages.upsert', handler)
stream.push('messages.upsert', { first: true })
stream.push('messages.upsert', { second: true })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalledTimes(1)
})
it('should remove handler with off', async () => {
const handler = jest.fn()
stream.on('messages.upsert', handler)
stream.off('messages.upsert', handler)
stream.push('messages.upsert', { message: 'test' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(handler).not.toHaveBeenCalled()
})
})
describe('pause and resume', () => {
it('should pause processing', async () => {
const handler = jest.fn()
stream.on('messages.upsert', handler)
stream.pause()
expect(stream.isPaused()).toBe(true)
stream.push('messages.upsert', { message: 'test' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(handler).not.toHaveBeenCalled()
})
it('should resume processing', async () => {
const handler = jest.fn()
stream.on('messages.upsert', handler)
stream.pause()
stream.push('messages.upsert', { message: 'test' })
stream.resume()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalled()
})
})
describe('flush', () => {
it('should process all buffered events', async () => {
const handler = jest.fn()
stream.pause()
stream.on('messages.upsert', handler)
for (let i = 0; i < 5; i++) {
stream.push('messages.upsert', { index: i })
}
stream.resume()
const result = await stream.flush()
expect(result.processed).toBe(5)
expect(handler).toHaveBeenCalledTimes(5)
})
})
describe('filters', () => {
it('should filter events before processing', async () => {
const handler = jest.fn()
stream.addFilter((event) => event.data.include === true)
stream.on('*', handler)
stream.push('messages.upsert', { include: true })
stream.push('messages.upsert', { include: false })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalledTimes(1)
})
it('should remove filter', async () => {
const filter = (event: StreamEvent) => false
stream.addFilter(filter)
stream.removeFilter(filter)
const handler = jest.fn()
stream.on('*', handler)
stream.push('messages.upsert', { test: true })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalled()
})
})
describe('transformers', () => {
it('should transform events before processing', async () => {
stream.addTransformer((event) => ({
...event,
metadata: { ...event.metadata, transformed: true },
}))
let receivedEvent: StreamEvent | null = null
stream.on('*', (event) => {
receivedEvent = event
})
stream.push('messages.upsert', { message: 'test' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(receivedEvent?.metadata?.transformed).toBe(true)
})
})
describe('dead letter queue', () => {
it('should move failed events to DLQ after max retries', async () => {
const failingHandler = jest.fn(() => {
throw new Error('Processing failed')
})
const dlqStream = createEventStream({
maxRetries: 2,
batchSize: 1,
collectMetrics: false,
})
dlqStream.on('messages.upsert', failingHandler)
dlqStream.push('messages.upsert', { will: 'fail' })
await new Promise((resolve) => setTimeout(resolve, 200))
const dlq = dlqStream.getDeadLetterQueue()
expect(dlq.length).toBeGreaterThan(0)
dlqStream.destroy()
})
it('should clear DLQ', () => {
stream.push('test', { data: 'test' })
// Manually add to DLQ for testing
const dlq = stream.getDeadLetterQueue()
stream.clearDeadLetterQueue()
expect(stream.getStats().deadLetterQueueSize).toBe(0)
})
})
describe('statistics', () => {
it('should track event statistics', async () => {
stream.on('*', () => {})
stream.push('messages.upsert', { a: 1 })
stream.push('connection.update', { b: 2 })
stream.push('messages.update', { c: 3 })
await new Promise((resolve) => setTimeout(resolve, 50))
const stats = stream.getStats()
expect(stats.totalReceived).toBe(3)
expect(stats.totalProcessed).toBe(3)
expect(stats.eventsByType['messages.upsert']).toBe(1)
expect(stats.eventsByType['connection.update']).toBe(1)
})
it('should reset statistics', async () => {
stream.on('*', () => {})
stream.push('messages.upsert', { test: true })
await new Promise((resolve) => setTimeout(resolve, 50))
stream.resetStats()
const stats = stream.getStats()
expect(stats.totalReceived).toBe(0)
expect(stats.totalProcessed).toBe(0)
})
})
describe('priority ordering', () => {
it('should process critical events before normal events', async () => {
const processedOrder: EventPriority[] = []
stream.pause()
stream.on('*', (event) => {
processedOrder.push(event.priority)
})
// Add in reverse priority order
stream.push('presence.update', {}, { priority: 'low' })
stream.push('messages.upsert', {}, { priority: 'normal' })
stream.push('connection.update', {}, { priority: 'critical' })
stream.push('call', {}, { priority: 'high' })
stream.resume()
await stream.flush()
expect(processedOrder[0]).toBe('critical')
expect(processedOrder[1]).toBe('high')
expect(processedOrder[2]).toBe('normal')
expect(processedOrder[3]).toBe('low')
})
})
describe('backpressure', () => {
it('should emit backpressure event when high water mark reached', (done) => {
const bpStream = createEventStream({
maxBufferSize: 100,
highWaterMark: 5,
enableBackpressure: true,
collectMetrics: false,
})
bpStream.pause()
bpStream.on('backpressure', () => {
expect(bpStream.getStats().isBackpressured).toBe(true)
bpStream.destroy()
done()
})
for (let i = 0; i < 10; i++) {
bpStream.push('messages.upsert', { index: i })
}
})
it('should emit drain event when below low water mark', (done) => {
const bpStream = createEventStream({
maxBufferSize: 100,
highWaterMark: 5,
lowWaterMark: 2,
enableBackpressure: true,
batchSize: 10,
collectMetrics: false,
})
bpStream.pause()
for (let i = 0; i < 10; i++) {
bpStream.push('messages.upsert', { index: i })
}
bpStream.on('*', () => {})
bpStream.on('drain', () => {
bpStream.destroy()
done()
})
bpStream.resume()
})
})
describe('clear', () => {
it('should clear buffer', () => {
stream.pause()
for (let i = 0; i < 10; i++) {
stream.push('messages.upsert', { index: i })
}
stream.clear()
expect(stream.getStats().bufferSize).toBe(0)
})
})
})
describe('eventFilters', () => {
describe('byType', () => {
it('should filter by event type', () => {
const filter = eventFilters.byType('messages.upsert', 'messages.update')
const matchingEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message',
}
const nonMatchingEvent: StreamEvent = {
id: '2',
type: 'connection.update',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'connection',
}
expect(filter(matchingEvent)).toBe(true)
expect(filter(nonMatchingEvent)).toBe(false)
})
})
describe('byCategory', () => {
it('should filter by category', () => {
const filter = eventFilters.byCategory('message', 'connection')
const matchingEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message',
}
const nonMatchingEvent: StreamEvent = {
id: '2',
type: 'presence.update',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'presence',
}
expect(filter(matchingEvent)).toBe(true)
expect(filter(nonMatchingEvent)).toBe(false)
})
})
describe('byMinPriority', () => {
it('should filter by minimum priority', () => {
const filter = eventFilters.byMinPriority('high')
const criticalEvent: StreamEvent = {
id: '1',
type: 'connection.update',
data: {},
timestamp: Date.now(),
priority: 'critical',
category: 'connection',
}
const lowEvent: StreamEvent = {
id: '2',
type: 'presence.update',
data: {},
timestamp: Date.now(),
priority: 'low',
category: 'presence',
}
expect(filter(criticalEvent)).toBe(true)
expect(filter(lowEvent)).toBe(false)
})
})
describe('recentOnly', () => {
it('should filter old events', () => {
const filter = eventFilters.recentOnly(1000)
const recentEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message',
}
const oldEvent: StreamEvent = {
id: '2',
type: 'messages.upsert',
data: {},
timestamp: Date.now() - 5000,
priority: 'normal',
category: 'message',
}
expect(filter(recentEvent)).toBe(true)
expect(filter(oldEvent)).toBe(false)
})
})
describe('and', () => {
it('should combine filters with AND', () => {
const filter = eventFilters.and(
eventFilters.byType('messages.upsert'),
eventFilters.byMinPriority('high')
)
const matchingEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'high',
category: 'message',
}
const partialMatch: StreamEvent = {
id: '2',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'low',
category: 'message',
}
expect(filter(matchingEvent)).toBe(true)
expect(filter(partialMatch)).toBe(false)
})
})
describe('or', () => {
it('should combine filters with OR', () => {
const filter = eventFilters.or(
eventFilters.byType('messages.upsert'),
eventFilters.byCategory('connection')
)
const typeMatch: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message',
}
const categoryMatch: StreamEvent = {
id: '2',
type: 'connection.update',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'connection',
}
const noMatch: StreamEvent = {
id: '3',
type: 'presence.update',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'presence',
}
expect(filter(typeMatch)).toBe(true)
expect(filter(categoryMatch)).toBe(true)
expect(filter(noMatch)).toBe(false)
})
})
})
describe('eventTransformers', () => {
describe('addProcessingTimestamp', () => {
it('should add processing timestamp', () => {
const transformer = eventTransformers.addProcessingTimestamp()
const event: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now() - 1000,
priority: 'normal',
category: 'message',
}
const transformed = transformer(event)
expect(transformed.metadata?.processingTimestamp).toBeDefined()
expect(transformed.metadata?.processingTimestamp).toBeGreaterThan(event.timestamp)
})
})
describe('addTraceId', () => {
it('should add trace ID', () => {
const transformer = eventTransformers.addTraceId(() => 'trace-123')
const event: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message',
}
const transformed = transformer(event)
expect(transformed.metadata?.traceId).toBe('trace-123')
})
})
describe('elevatepriorityIf', () => {
it('should elevate priority when condition is met', () => {
const transformer = eventTransformers.elevatepriorityIf(
(event) => (event.data as { urgent?: boolean }).urgent === true,
'critical'
)
const urgentEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: { urgent: true },
timestamp: Date.now(),
priority: 'normal',
category: 'message',
}
const normalEvent: StreamEvent = {
id: '2',
type: 'messages.upsert',
data: { urgent: false },
timestamp: Date.now(),
priority: 'normal',
category: 'message',
}
expect(transformer(urgentEvent).priority).toBe('critical')
expect(transformer(normalEvent).priority).toBe('normal')
})
})
})
+295
View File
@@ -0,0 +1,295 @@
/**
* Testes unitários para cache-utils.ts
*/
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
import {
Cache,
createCache,
MultiLevelCache,
withCache,
getGlobalCache,
clearGlobalCaches,
} from '../../Utils/cache-utils.js'
describe('Cache', () => {
let cache: Cache<string>
beforeEach(() => {
cache = createCache<string>({
ttl: 1000,
maxSize: 100,
collectMetrics: false,
})
})
describe('basic operations', () => {
it('should set and get values', () => {
cache.set('key1', 'value1')
expect(cache.get('key1')).toBe('value1')
})
it('should return undefined for non-existent keys', () => {
expect(cache.get('nonexistent')).toBeUndefined()
})
it('should check if key exists', () => {
cache.set('exists', 'value')
expect(cache.has('exists')).toBe(true)
expect(cache.has('notexists')).toBe(false)
})
it('should delete keys', () => {
cache.set('toDelete', 'value')
expect(cache.delete('toDelete')).toBe(true)
expect(cache.get('toDelete')).toBeUndefined()
})
it('should clear all values', () => {
cache.set('key1', 'value1')
cache.set('key2', 'value2')
cache.clear()
expect(cache.size).toBe(0)
})
})
describe('TTL', () => {
it('should expire values after TTL', async () => {
const shortTtlCache = createCache<string>({
ttl: 50,
collectMetrics: false,
})
shortTtlCache.set('expiring', 'value')
expect(shortTtlCache.get('expiring')).toBe('value')
await new Promise((resolve) => setTimeout(resolve, 100))
expect(shortTtlCache.get('expiring')).toBeUndefined()
})
it('should support custom TTL per item', async () => {
cache.set('shortLived', 'value', 50)
cache.set('longLived', 'value', 5000)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(cache.get('shortLived')).toBeUndefined()
expect(cache.get('longLived')).toBe('value')
})
})
describe('getOrSet', () => {
it('should return cached value if exists', async () => {
cache.set('cached', 'existingValue')
const factory = jest.fn(() => 'newValue')
const result = await cache.getOrSet('cached', factory)
expect(result).toBe('existingValue')
expect(factory).not.toHaveBeenCalled()
})
it('should call factory and cache if not exists', async () => {
const factory = jest.fn(() => 'newValue')
const result = await cache.getOrSet('new', factory)
expect(result).toBe('newValue')
expect(factory).toHaveBeenCalledTimes(1)
expect(cache.get('new')).toBe('newValue')
})
it('should handle async factories', async () => {
const factory = jest.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
return 'asyncValue'
})
const result = await cache.getOrSet('async', factory)
expect(result).toBe('asyncValue')
})
})
describe('getOrSetSync', () => {
it('should work synchronously', () => {
const factory = jest.fn(() => 'syncValue')
const result = cache.getOrSetSync('sync', factory)
expect(result).toBe('syncValue')
expect(factory).toHaveBeenCalledTimes(1)
})
})
describe('invalidation', () => {
it('should invalidate by pattern', () => {
cache.set('user:1', 'user1')
cache.set('user:2', 'user2')
cache.set('post:1', 'post1')
const count = cache.invalidateByPattern(/^user:/)
expect(count).toBe(2)
expect(cache.get('user:1')).toBeUndefined()
expect(cache.get('user:2')).toBeUndefined()
expect(cache.get('post:1')).toBe('post1')
})
it('should invalidate by prefix', () => {
cache.set('prefix:a', 'a')
cache.set('prefix:b', 'b')
cache.set('other:c', 'c')
const count = cache.invalidateByPrefix('prefix:')
expect(count).toBe(2)
expect(cache.get('prefix:a')).toBeUndefined()
expect(cache.get('other:c')).toBe('c')
})
})
describe('statistics', () => {
it('should track hits and misses', () => {
cache.set('key', 'value')
cache.get('key') // hit
cache.get('key') // hit
cache.get('nonexistent') // miss
const stats = cache.getStats()
expect(stats.hits).toBe(2)
expect(stats.misses).toBe(1)
expect(stats.hitRate).toBeCloseTo(2 / 3)
})
})
describe('touch', () => {
it('should update TTL of existing item', () => {
cache.set('touchable', 'value')
expect(cache.touch('touchable')).toBe(true)
})
it('should return false for non-existent item', () => {
expect(cache.touch('nonexistent')).toBe(false)
})
})
describe('getWithResult', () => {
it('should return detailed result on hit', () => {
cache.set('key', 'value')
const result = cache.getWithResult('key')
expect(result.hit).toBe(true)
expect(result.value).toBe('value')
expect(result.key).toBe('key')
})
it('should return detailed result on miss', () => {
const result = cache.getWithResult('missing')
expect(result.hit).toBe(false)
expect(result.value).toBeUndefined()
})
})
})
describe('MultiLevelCache', () => {
it('should try L1 first', async () => {
const l2Get = jest.fn()
const l2Set = jest.fn()
const l2Delete = jest.fn()
const multiCache = new MultiLevelCache<string>(
{ ttl: 1000, collectMetrics: false },
{ get: l2Get, set: l2Set, delete: l2Delete }
)
multiCache.getL1().set('local', 'value')
const result = await multiCache.get('local')
expect(result).toBe('value')
expect(l2Get).not.toHaveBeenCalled()
})
it('should fallback to L2 on L1 miss', async () => {
const l2Get = jest.fn(async () => 'l2value')
const l2Set = jest.fn()
const l2Delete = jest.fn()
const multiCache = new MultiLevelCache<string>(
{ ttl: 1000, collectMetrics: false },
{ get: l2Get, set: l2Set, delete: l2Delete }
)
const result = await multiCache.get('fromL2')
expect(result).toBe('l2value')
expect(l2Get).toHaveBeenCalledWith('fromL2')
})
it('should write to both levels', async () => {
const l2Set = jest.fn()
const multiCache = new MultiLevelCache<string>(
{ ttl: 1000, collectMetrics: false },
{ get: jest.fn(), set: l2Set, delete: jest.fn() }
)
await multiCache.set('key', 'value')
expect(multiCache.getL1().get('key')).toBe('value')
expect(l2Set).toHaveBeenCalledWith('key', 'value', undefined)
})
})
describe('withCache', () => {
it('should cache function results', async () => {
let callCount = 0
const expensiveFn = (x: number) => {
callCount++
return x * 2
}
const cachedFn = withCache(expensiveFn, { ttl: 1000, collectMetrics: false })
const result1 = await cachedFn(5)
const result2 = await cachedFn(5)
const result3 = await cachedFn(10)
expect(result1).toBe(10)
expect(result2).toBe(10)
expect(result3).toBe(20)
expect(callCount).toBe(2) // 5 was cached, 10 was not
})
})
describe('globalCache', () => {
beforeEach(() => {
clearGlobalCaches()
})
it('should create and retrieve global cache by namespace', () => {
const cache1 = getGlobalCache('ns1')
const cache2 = getGlobalCache('ns1')
const cache3 = getGlobalCache('ns2')
expect(cache1).toBe(cache2)
expect(cache1).not.toBe(cache3)
})
it('should clear all global caches', () => {
const cache1 = getGlobalCache<string>('test1')
const cache2 = getGlobalCache<string>('test2')
cache1.set('key', 'value')
cache2.set('key', 'value')
clearGlobalCaches()
// After clear, new caches should be created
const newCache1 = getGlobalCache<string>('test1')
expect(newCache1.get('key')).toBeUndefined()
})
})
+371
View File
@@ -0,0 +1,371 @@
/**
* Testes unitários para circuit-breaker.ts
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'
import {
CircuitBreaker,
createCircuitBreaker,
CircuitBreakerRegistry,
globalCircuitRegistry,
CircuitOpenError,
CircuitTimeoutError,
withCircuitBreaker,
getCircuitHealth,
type CircuitState,
} from '../../Utils/circuit-breaker.js'
describe('CircuitBreaker', () => {
let breaker: CircuitBreaker
beforeEach(() => {
breaker = createCircuitBreaker({
name: 'test',
failureThreshold: 3,
successThreshold: 2,
resetTimeout: 100,
timeout: 1000,
collectMetrics: false,
})
})
afterEach(() => {
breaker.destroy()
})
describe('initial state', () => {
it('should start in closed state', () => {
expect(breaker.getState()).toBe('closed')
expect(breaker.isClosed()).toBe(true)
expect(breaker.isOpen()).toBe(false)
expect(breaker.isHalfOpen()).toBe(false)
})
})
describe('successful operations', () => {
it('should execute successful operations', async () => {
const result = await breaker.execute(() => 'success')
expect(result).toBe('success')
})
it('should execute async operations', async () => {
const result = await breaker.execute(async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
return 'async success'
})
expect(result).toBe('async success')
})
it('should track successful calls in stats', async () => {
await breaker.execute(() => 'success')
await breaker.execute(() => 'success')
const stats = breaker.getStats()
expect(stats.totalCalls).toBe(2)
expect(stats.totalSuccesses).toBe(2)
expect(stats.totalFailures).toBe(0)
})
})
describe('failure handling', () => {
it('should open after reaching failure threshold', async () => {
const failingOp = () => {
throw new Error('Failure')
}
// Reach failure threshold
for (let i = 0; i < 3; i++) {
await expect(breaker.execute(failingOp)).rejects.toThrow('Failure')
}
expect(breaker.isOpen()).toBe(true)
})
it('should not open before reaching threshold', async () => {
const failingOp = () => {
throw new Error('Failure')
}
// Below threshold
for (let i = 0; i < 2; i++) {
await expect(breaker.execute(failingOp)).rejects.toThrow('Failure')
}
expect(breaker.isClosed()).toBe(true)
})
it('should reset failure count on success', async () => {
const failingOp = () => {
throw new Error('Failure')
}
await expect(breaker.execute(failingOp)).rejects.toThrow()
await expect(breaker.execute(failingOp)).rejects.toThrow()
// Success resets failures
await breaker.execute(() => 'success')
// Need 3 more failures to open
await expect(breaker.execute(failingOp)).rejects.toThrow()
await expect(breaker.execute(failingOp)).rejects.toThrow()
expect(breaker.isClosed()).toBe(true)
})
})
describe('open state', () => {
beforeEach(async () => {
// Force open
breaker.trip()
})
it('should reject operations when open', async () => {
await expect(breaker.execute(() => 'success')).rejects.toThrow(CircuitOpenError)
})
it('should transition to half-open after reset timeout', async () => {
await new Promise((resolve) => setTimeout(resolve, 150))
expect(breaker.isHalfOpen()).toBe(true)
})
})
describe('half-open state', () => {
beforeEach(async () => {
breaker.trip()
await new Promise((resolve) => setTimeout(resolve, 150))
})
it('should close after success threshold', async () => {
expect(breaker.isHalfOpen()).toBe(true)
// Success threshold is 2
await breaker.execute(() => 'success')
await breaker.execute(() => 'success')
expect(breaker.isClosed()).toBe(true)
})
it('should reopen on failure', async () => {
expect(breaker.isHalfOpen()).toBe(true)
await expect(
breaker.execute(() => {
throw new Error('Failure')
})
).rejects.toThrow()
expect(breaker.isOpen()).toBe(true)
})
})
describe('manual controls', () => {
it('should force open with trip()', () => {
breaker.trip()
expect(breaker.isOpen()).toBe(true)
})
it('should force close with reset()', async () => {
breaker.trip()
breaker.reset()
expect(breaker.isClosed()).toBe(true)
})
})
describe('timeout', () => {
it('should timeout slow operations', async () => {
const slowBreaker = createCircuitBreaker({
name: 'slow',
timeout: 50,
collectMetrics: false,
})
await expect(
slowBreaker.execute(
() => new Promise((resolve) => setTimeout(resolve, 200))
)
).rejects.toThrow(CircuitTimeoutError)
slowBreaker.destroy()
})
})
describe('events', () => {
it('should emit state-change event', async () => {
const stateChanges: Array<{ from: CircuitState; to: CircuitState }> = []
breaker.on('state-change', (change) => {
stateChanges.push(change)
})
breaker.trip()
breaker.reset()
expect(stateChanges).toHaveLength(2)
expect(stateChanges[0]).toEqual({ from: 'closed', to: 'open' })
expect(stateChanges[1]).toEqual({ from: 'open', to: 'closed' })
})
it('should emit success and failure events', async () => {
let successCount = 0
let failureCount = 0
breaker.on('success', () => successCount++)
breaker.on('failure', () => failureCount++)
await breaker.execute(() => 'success')
await expect(
breaker.execute(() => {
throw new Error()
})
).rejects.toThrow()
expect(successCount).toBe(1)
expect(failureCount).toBe(1)
})
})
describe('isFailure predicate', () => {
it('should use custom isFailure predicate', async () => {
const customBreaker = createCircuitBreaker({
name: 'custom',
failureThreshold: 1,
isFailure: (error) => error.message !== 'Ignored',
collectMetrics: false,
})
// This error should be ignored
await expect(
customBreaker.execute(() => {
throw new Error('Ignored')
})
).rejects.toThrow()
expect(customBreaker.isClosed()).toBe(true)
// This should trip the breaker
await expect(
customBreaker.execute(() => {
throw new Error('Real failure')
})
).rejects.toThrow()
expect(customBreaker.isOpen()).toBe(true)
customBreaker.destroy()
})
})
describe('sync operations', () => {
it('should execute sync operations', () => {
const result = breaker.executeSync(() => 'sync result')
expect(result).toBe('sync result')
})
it('should handle sync failures', () => {
expect(() =>
breaker.executeSync(() => {
throw new Error('Sync failure')
})
).toThrow('Sync failure')
})
})
})
describe('CircuitBreakerRegistry', () => {
let registry: CircuitBreakerRegistry
beforeEach(() => {
registry = new CircuitBreakerRegistry()
})
afterEach(() => {
registry.destroyAll()
})
it('should create and retrieve circuit breakers', () => {
const breaker1 = registry.get('test1')
const breaker2 = registry.get('test1')
expect(breaker1).toBe(breaker2)
})
it('should check if breaker exists', () => {
registry.get('exists')
expect(registry.has('exists')).toBe(true)
expect(registry.has('notexists')).toBe(false)
})
it('should remove breaker', () => {
registry.get('toRemove')
expect(registry.remove('toRemove')).toBe(true)
expect(registry.has('toRemove')).toBe(false)
})
it('should get all stats', () => {
registry.get('breaker1')
registry.get('breaker2')
const stats = registry.getAllStats()
expect(stats).toHaveProperty('breaker1')
expect(stats).toHaveProperty('breaker2')
})
it('should reset all breakers', async () => {
const breaker = registry.get('test')
breaker.trip()
registry.resetAll()
expect(breaker.isClosed()).toBe(true)
})
})
describe('withCircuitBreaker', () => {
it('should wrap function with circuit breaker', async () => {
let callCount = 0
const protectedFn = withCircuitBreaker(
async () => {
callCount++
return 'result'
},
{
name: 'wrapped-fn',
collectMetrics: false,
}
)
const result = await protectedFn()
expect(result).toBe('result')
expect(callCount).toBe(1)
})
})
describe('getCircuitHealth', () => {
beforeEach(() => {
globalCircuitRegistry.destroyAll()
})
it('should report healthy when all circuits closed', () => {
globalCircuitRegistry.get('healthy1')
globalCircuitRegistry.get('healthy2')
const health = getCircuitHealth()
expect(health.healthy).toBe(true)
expect(health.openCircuits).toHaveLength(0)
})
it('should report unhealthy when circuit is open', () => {
const breaker = globalCircuitRegistry.get('unhealthy')
breaker.trip()
const health = getCircuitHealth()
expect(health.healthy).toBe(false)
expect(health.openCircuits).toContain('unhealthy')
})
})
+466
View File
@@ -0,0 +1,466 @@
/**
* Testes unitários para retry-utils.ts
*/
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
import {
retry,
retryWithResult,
createRetrier,
retryable,
RetryManager,
RetryExhaustedError,
RetryAbortedError,
calculateDelay,
retryPredicates,
retryConfigs,
type RetryContext,
} from '../../Utils/retry-utils.js'
describe('retry', () => {
describe('successful operations', () => {
it('should return result on first success', async () => {
const result = await retry(() => 'success', { collectMetrics: false })
expect(result).toBe('success')
})
it('should return result from async operation', async () => {
const result = await retry(async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
return 'async success'
}, { collectMetrics: false })
expect(result).toBe('async success')
})
})
describe('retry behavior', () => {
it('should retry on failure', async () => {
let attempts = 0
const result = await retry(
() => {
attempts++
if (attempts < 3) {
throw new Error('Failing')
}
return 'success after retries'
},
{
maxAttempts: 5,
baseDelay: 10,
collectMetrics: false,
}
)
expect(result).toBe('success after retries')
expect(attempts).toBe(3)
})
it('should exhaust retries and throw', async () => {
await expect(
retry(
() => {
throw new Error('Always fails')
},
{
maxAttempts: 3,
baseDelay: 10,
collectMetrics: false,
}
)
).rejects.toThrow(RetryExhaustedError)
})
it('should pass context to operation', async () => {
let receivedContext: RetryContext | null = null
await retry(
(context) => {
receivedContext = context
return 'success'
},
{ collectMetrics: false }
)
expect(receivedContext).not.toBeNull()
expect(receivedContext!.attempt).toBe(1)
expect(receivedContext!.maxAttempts).toBe(3) // default
})
})
describe('shouldRetry predicate', () => {
it('should use custom shouldRetry', async () => {
let attempts = 0
await expect(
retry(
() => {
attempts++
throw new Error('Non-retryable')
},
{
maxAttempts: 5,
baseDelay: 10,
shouldRetry: () => false,
collectMetrics: false,
}
)
).rejects.toThrow(RetryExhaustedError)
expect(attempts).toBe(1) // Should not retry
})
})
describe('callbacks', () => {
it('should call onRetry callback', async () => {
const onRetry = jest.fn()
let attempts = 0
await retry(
() => {
attempts++
if (attempts < 3) {
throw new Error('Failing')
}
return 'success'
},
{
maxAttempts: 5,
baseDelay: 10,
onRetry,
collectMetrics: false,
}
)
expect(onRetry).toHaveBeenCalledTimes(2)
})
it('should call onSuccess callback', async () => {
const onSuccess = jest.fn()
await retry(() => 'result', {
onSuccess,
collectMetrics: false,
})
expect(onSuccess).toHaveBeenCalledWith('result', 1)
})
it('should call onFailure callback on exhaustion', async () => {
const onFailure = jest.fn()
await expect(
retry(
() => {
throw new Error('Always fails')
},
{
maxAttempts: 2,
baseDelay: 10,
onFailure,
collectMetrics: false,
}
)
).rejects.toThrow()
expect(onFailure).toHaveBeenCalled()
})
})
describe('abort signal', () => {
it('should abort with signal', async () => {
const controller = new AbortController()
const promise = retry(
async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
return 'success'
},
{
maxAttempts: 5,
baseDelay: 50,
abortSignal: controller.signal,
collectMetrics: false,
}
)
setTimeout(() => controller.abort(), 20)
await expect(promise).rejects.toThrow(RetryAbortedError)
})
})
})
describe('retryWithResult', () => {
it('should return success result', async () => {
const result = await retryWithResult(() => 'success', { collectMetrics: false })
expect(result.success).toBe(true)
expect(result.result).toBe('success')
expect(result.attempts).toBe(1)
expect(result.totalDuration).toBeGreaterThanOrEqual(0)
})
it('should return failure result', async () => {
const result = await retryWithResult(
() => {
throw new Error('Failure')
},
{
maxAttempts: 2,
baseDelay: 10,
collectMetrics: false,
}
)
expect(result.success).toBe(false)
expect(result.error).toBeInstanceOf(RetryExhaustedError)
expect(result.attempts).toBe(2)
})
})
describe('calculateDelay', () => {
describe('exponential backoff', () => {
it('should calculate exponential delays', () => {
const delay1 = calculateDelay(1, 100, 10000, 'exponential', 2, 0)
const delay2 = calculateDelay(2, 100, 10000, 'exponential', 2, 0)
const delay3 = calculateDelay(3, 100, 10000, 'exponential', 2, 0)
expect(delay1).toBe(100)
expect(delay2).toBe(200)
expect(delay3).toBe(400)
})
})
describe('linear backoff', () => {
it('should calculate linear delays', () => {
const delay1 = calculateDelay(1, 100, 10000, 'linear', 2, 0)
const delay2 = calculateDelay(2, 100, 10000, 'linear', 2, 0)
const delay3 = calculateDelay(3, 100, 10000, 'linear', 2, 0)
expect(delay1).toBe(100)
expect(delay2).toBe(200)
expect(delay3).toBe(300)
})
})
describe('constant backoff', () => {
it('should return constant delays', () => {
const delay1 = calculateDelay(1, 100, 10000, 'constant', 2, 0)
const delay2 = calculateDelay(2, 100, 10000, 'constant', 2, 0)
const delay3 = calculateDelay(3, 100, 10000, 'constant', 2, 0)
expect(delay1).toBe(100)
expect(delay2).toBe(100)
expect(delay3).toBe(100)
})
})
describe('max delay cap', () => {
it('should cap at max delay', () => {
const delay = calculateDelay(10, 100, 500, 'exponential', 2, 0)
expect(delay).toBeLessThanOrEqual(500)
})
})
describe('jitter', () => {
it('should add jitter to delay', () => {
const delays = new Set()
for (let i = 0; i < 10; i++) {
delays.add(calculateDelay(1, 100, 10000, 'constant', 2, 0.5))
}
// With jitter, we should get varying delays
expect(delays.size).toBeGreaterThan(1)
})
})
})
describe('createRetrier', () => {
it('should create preconfigured retrier', async () => {
const myRetrier = createRetrier({
maxAttempts: 5,
baseDelay: 10,
collectMetrics: false,
})
let attempts = 0
const result = await myRetrier(() => {
attempts++
if (attempts < 3) throw new Error('Failing')
return 'success'
})
expect(result).toBe('success')
expect(attempts).toBe(3)
})
})
describe('retryable', () => {
it('should wrap function with retry', async () => {
let attempts = 0
const fn = retryable(
() => {
attempts++
if (attempts < 2) throw new Error('Failing')
return 'wrapped result'
},
{
maxAttempts: 5,
baseDelay: 10,
collectMetrics: false,
}
)
const result = await fn()
expect(result).toBe('wrapped result')
expect(attempts).toBe(2)
})
})
describe('RetryManager', () => {
let manager: RetryManager
beforeEach(() => {
manager = new RetryManager({ baseDelay: 10, collectMetrics: false })
})
it('should execute operation with id', async () => {
const result = await manager.execute('op1', () => 'result')
expect(result).toBe('result')
})
it('should cancel active retry', async () => {
const promise = manager.execute('cancelable', async () => {
await new Promise((resolve) => setTimeout(resolve, 1000))
return 'result'
})
setTimeout(() => manager.cancel('cancelable'), 20)
await expect(promise).rejects.toThrow()
})
it('should check if operation is active', async () => {
let resolve: () => void
const promise = manager.execute('active', () =>
new Promise<string>((r) => {
resolve = () => r('done')
})
)
expect(manager.isActive('active')).toBe(true)
resolve!()
await promise
expect(manager.isActive('active')).toBe(false)
})
it('should cancel all operations', async () => {
const promises = [
manager.execute('op1', () => new Promise((_, reject) => setTimeout(() => reject(new Error()), 1000))),
manager.execute('op2', () => new Promise((_, reject) => setTimeout(() => reject(new Error()), 1000))),
]
setTimeout(() => manager.cancelAll(), 20)
await expect(Promise.all(promises)).rejects.toThrow()
})
it('should emit events', async () => {
const events: string[] = []
manager.on('attempt', () => events.push('attempt'))
manager.on('success', () => events.push('success'))
await manager.execute('test', () => 'result')
expect(events).toContain('attempt')
expect(events).toContain('success')
})
})
describe('retryPredicates', () => {
describe('always', () => {
it('should always return true', () => {
expect(retryPredicates.always(new Error(), 1)).toBe(true)
})
})
describe('never', () => {
it('should always return false', () => {
expect(retryPredicates.never(new Error(), 1)).toBe(false)
})
})
describe('onNetworkError', () => {
it('should return true for network errors', () => {
expect(retryPredicates.onNetworkError(new Error('ECONNREFUSED'))).toBe(true)
expect(retryPredicates.onNetworkError(new Error('ETIMEDOUT'))).toBe(true)
})
it('should return false for other errors', () => {
expect(retryPredicates.onNetworkError(new Error('Other error'))).toBe(false)
})
})
describe('onErrorCodes', () => {
it('should match specified codes', () => {
const predicate = retryPredicates.onErrorCodes(['ECODE1', 'ECODE2'])
expect(predicate(new Error('ECODE1 occurred'))).toBe(true)
expect(predicate(new Error('ECODE3 occurred'))).toBe(false)
})
})
describe('or', () => {
it('should combine predicates with OR', () => {
const combined = retryPredicates.or(
(e) => e.message.includes('A'),
(e) => e.message.includes('B')
)
expect(combined(new Error('A'), 1)).toBe(true)
expect(combined(new Error('B'), 1)).toBe(true)
expect(combined(new Error('C'), 1)).toBe(false)
})
})
describe('and', () => {
it('should combine predicates with AND', () => {
const combined = retryPredicates.and(
(e) => e.message.includes('A'),
(e) => e.message.includes('B')
)
expect(combined(new Error('A and B'), 1)).toBe(true)
expect(combined(new Error('A only'), 1)).toBe(false)
})
})
})
describe('retryConfigs', () => {
it('should have aggressive config', () => {
expect(retryConfigs.aggressive.maxAttempts).toBe(10)
expect(retryConfigs.aggressive.baseDelay).toBe(100)
})
it('should have conservative config', () => {
expect(retryConfigs.conservative.maxAttempts).toBe(3)
expect(retryConfigs.conservative.baseDelay).toBe(2000)
})
it('should have fast config', () => {
expect(retryConfigs.fast.maxAttempts).toBe(5)
expect(retryConfigs.fast.baseDelay).toBe(50)
})
it('should have network config with predicate', () => {
expect(retryConfigs.network.shouldRetry).toBe(retryPredicates.onNetworkError)
})
})
@@ -0,0 +1,200 @@
/**
* Testes unitários para structured-logger.ts
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'
import {
StructuredLogger,
createStructuredLogger,
getDefaultLogger,
setDefaultLogger,
createTimer,
LOG_LEVEL_VALUES,
type LogLevel,
} from '../../Utils/structured-logger.js'
describe('StructuredLogger', () => {
let logger: StructuredLogger
let consoleSpy: jest.SpiedFunction<typeof console.info>
beforeEach(() => {
logger = createStructuredLogger({
level: 'debug',
name: 'test',
jsonFormat: false,
})
consoleSpy = jest.spyOn(console, 'info').mockImplementation(() => {})
jest.spyOn(console, 'debug').mockImplementation(() => {})
jest.spyOn(console, 'warn').mockImplementation(() => {})
jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('log levels', () => {
it('should respect log level hierarchy', () => {
const warnLogger = createStructuredLogger({ level: 'warn' })
expect(warnLogger.isLevelEnabled('trace')).toBe(false)
expect(warnLogger.isLevelEnabled('debug')).toBe(false)
expect(warnLogger.isLevelEnabled('info')).toBe(false)
expect(warnLogger.isLevelEnabled('warn')).toBe(true)
expect(warnLogger.isLevelEnabled('error')).toBe(true)
expect(warnLogger.isLevelEnabled('fatal')).toBe(true)
})
it('should have correct level values', () => {
expect(LOG_LEVEL_VALUES.trace).toBeLessThan(LOG_LEVEL_VALUES.debug)
expect(LOG_LEVEL_VALUES.debug).toBeLessThan(LOG_LEVEL_VALUES.info)
expect(LOG_LEVEL_VALUES.info).toBeLessThan(LOG_LEVEL_VALUES.warn)
expect(LOG_LEVEL_VALUES.warn).toBeLessThan(LOG_LEVEL_VALUES.error)
expect(LOG_LEVEL_VALUES.error).toBeLessThan(LOG_LEVEL_VALUES.fatal)
})
it('should allow level to be changed', () => {
expect(logger.level).toBe('debug')
logger.level = 'error'
expect(logger.level).toBe('error')
})
})
describe('logging methods', () => {
it('should log info messages', () => {
logger.info({ test: 'data' }, 'Test message')
expect(consoleSpy).toHaveBeenCalled()
})
it('should log with object data', () => {
logger.debug({ key: 'value', number: 42 })
expect(console.debug).toHaveBeenCalled()
})
it('should log errors with stack trace', () => {
const error = new Error('Test error')
logger.error(error, 'Error occurred')
expect(console.error).toHaveBeenCalled()
})
})
describe('child loggers', () => {
it('should create child logger with additional context', () => {
const child = logger.child({ component: 'child' })
expect(child).toBeInstanceOf(StructuredLogger)
})
it('should inherit parent level', () => {
logger.level = 'warn'
const child = logger.child({ component: 'test' })
expect(child.level).toBe('warn')
})
})
describe('sanitization', () => {
it('should redact sensitive fields', () => {
const jsonLogger = createStructuredLogger({
level: 'info',
jsonFormat: true,
})
// O logger deve sanitizar campos sensíveis
jsonLogger.info({
user: 'test',
password: 'secret123',
token: 'abc123',
})
expect(consoleSpy).toHaveBeenCalled()
})
})
describe('metrics', () => {
it('should track log metrics', () => {
logger.info({}, 'test 1')
logger.warn({}, 'test 2')
logger.error({}, 'test 3')
const metrics = logger.getMetrics()
expect(metrics.totalLogs).toBe(3)
expect(metrics.logsByLevel.info).toBe(1)
expect(metrics.logsByLevel.warn).toBe(1)
expect(metrics.logsByLevel.error).toBe(1)
expect(metrics.errorsCount).toBe(1)
})
it('should reset metrics', () => {
logger.info({}, 'test')
logger.resetMetrics()
const metrics = logger.getMetrics()
expect(metrics.totalLogs).toBe(0)
})
})
describe('context helpers', () => {
it('should create logger with context', () => {
const contextLogger = logger.withContext({ requestId: '123' })
expect(contextLogger).toBeInstanceOf(StructuredLogger)
})
it('should create logger with correlation ID', () => {
const correlationLogger = logger.withCorrelationId('corr-123')
expect(correlationLogger).toBeInstanceOf(StructuredLogger)
})
})
describe('logOperation', () => {
it('should log operation start and complete', async () => {
const result = await logger.logOperation('test-op', async () => {
return 'result'
})
expect(result).toBe('result')
})
it('should log operation error', async () => {
await expect(
logger.logOperation('failing-op', async () => {
throw new Error('Operation failed')
})
).rejects.toThrow('Operation failed')
})
})
describe('createTimer', () => {
it('should measure elapsed time', async () => {
const timer = createTimer()
await new Promise((resolve) => setTimeout(resolve, 10))
const elapsed = timer.elapsed()
expect(elapsed).toBeGreaterThan(0)
})
it('should format elapsed time', async () => {
const timer = createTimer()
await new Promise((resolve) => setTimeout(resolve, 5))
const formatted = timer.elapsedMs()
expect(formatted).toMatch(/\d+\.\d+ms/)
})
})
describe('default logger', () => {
it('should get default logger', () => {
const defaultLogger = getDefaultLogger()
expect(defaultLogger).toBeInstanceOf(StructuredLogger)
})
it('should set default logger', () => {
const customLogger = createStructuredLogger({ name: 'custom' })
setDefaultLogger(customLogger)
const retrieved = getDefaultLogger()
expect(retrieved).toBe(customLogger)
})
})
})
+486
View File
@@ -0,0 +1,486 @@
/**
* Testes unitários para trace-context.ts
*/
import { describe, it, expect, beforeEach } from '@jest/globals'
import {
createTraceContext,
getCurrentContext,
getOrCreateContext,
runWithContext,
runWithNewContext,
createSpan,
startSpan,
endSpan,
addSpanEvent,
setSpanAttributes,
setSpanError,
withSpan,
withSpanSync,
setBaggage,
getBaggage,
getAllBaggage,
removeBaggage,
injectTraceHeaders,
extractTraceHeaders,
exportContext,
importContext,
createPrecisionTimer,
generateTraceId,
generateSpanId,
generateCorrelationId,
TRACE_HEADERS,
type TraceContext,
type Span,
} from '../../Utils/trace-context.js'
describe('ID generation', () => {
describe('generateTraceId', () => {
it('should generate 32 character hex string', () => {
const id = generateTraceId()
expect(id).toHaveLength(32)
expect(id).toMatch(/^[0-9a-f]+$/)
})
it('should generate unique IDs', () => {
const ids = new Set()
for (let i = 0; i < 100; i++) {
ids.add(generateTraceId())
}
expect(ids.size).toBe(100)
})
})
describe('generateSpanId', () => {
it('should generate 16 character hex string', () => {
const id = generateSpanId()
expect(id).toHaveLength(16)
expect(id).toMatch(/^[0-9a-f]+$/)
})
})
describe('generateCorrelationId', () => {
it('should generate readable correlation ID', () => {
const id = generateCorrelationId()
expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
})
})
})
describe('TraceContext', () => {
describe('createTraceContext', () => {
it('should create context with generated IDs', () => {
const context = createTraceContext()
expect(context.traceIds.traceId).toHaveLength(32)
expect(context.traceIds.spanId).toHaveLength(16)
expect(context.traceIds.correlationId).toBeTruthy()
expect(context.baggage).toEqual({})
expect(context.spanStack).toEqual([])
})
it('should use provided options', () => {
const context = createTraceContext({
traceId: 'custom-trace-id',
correlationId: 'custom-corr-id',
parentSpanId: 'parent-span',
baggage: { key: 'value' },
metadata: { env: 'test' },
})
expect(context.traceIds.traceId).toBe('custom-trace-id')
expect(context.traceIds.correlationId).toBe('custom-corr-id')
expect(context.traceIds.parentSpanId).toBe('parent-span')
expect(context.baggage).toEqual({ key: 'value' })
expect(context.metadata).toEqual({ env: 'test' })
})
})
describe('runWithContext', () => {
it('should make context available within scope', () => {
const context = createTraceContext()
let captured: TraceContext | undefined
runWithContext(context, () => {
captured = getCurrentContext()
})
expect(captured).toBe(context)
})
it('should not leak context outside scope', () => {
const context = createTraceContext()
runWithContext(context, () => {
// Context available here
})
// Context should be undefined outside
expect(getCurrentContext()).toBeUndefined()
})
it('should return value from function', () => {
const context = createTraceContext()
const result = runWithContext(context, () => 'result')
expect(result).toBe('result')
})
})
describe('runWithNewContext', () => {
it('should create and run with new context', () => {
let captured: TraceContext | undefined
runWithNewContext({ baggage: { test: 'value' } }, () => {
captured = getCurrentContext()
})
expect(captured).toBeDefined()
expect(captured!.baggage.test).toBe('value')
})
})
describe('getOrCreateContext', () => {
it('should return existing context if available', () => {
const context = createTraceContext()
runWithContext(context, () => {
const retrieved = getOrCreateContext()
expect(retrieved).toBe(context)
})
})
it('should create new context if none exists', () => {
const context = getOrCreateContext()
expect(context).toBeDefined()
expect(context.traceIds.traceId).toHaveLength(32)
})
})
})
describe('Spans', () => {
describe('createSpan', () => {
it('should create span with provided name', () => {
const span = createSpan({ name: 'test-span' })
expect(span.name).toBe('test-span')
expect(span.traceIds.spanId).toHaveLength(16)
expect(span.startTime).toBeGreaterThan(0)
expect(span.status).toBe('unset')
expect(span.ended).toBe(false)
})
it('should include provided attributes', () => {
const span = createSpan({
name: 'test',
attributes: { key: 'value', count: 42 },
})
expect(span.attributes).toEqual({ key: 'value', count: 42 })
})
})
describe('startSpan', () => {
it('should start span and set as current in context', () => {
const context = createTraceContext()
runWithContext(context, () => {
const span = startSpan({ name: 'operation' })
expect(getCurrentContext()!.currentSpan).toBe(span)
})
})
it('should support nested spans', () => {
const context = createTraceContext()
runWithContext(context, () => {
const parent = startSpan({ name: 'parent' })
const child = startSpan({ name: 'child' })
expect(child.traceIds.parentSpanId).toBe(parent.traceIds.spanId)
expect(getCurrentContext()!.spanStack).toContain(parent)
})
})
})
describe('endSpan', () => {
it('should mark span as ended', () => {
const span = createSpan({ name: 'test' })
endSpan(span)
expect(span.ended).toBe(true)
expect(span.endTime).toBeDefined()
expect(span.duration).toBeDefined()
expect(span.status).toBe('ok')
})
it('should set custom status', () => {
const span = createSpan({ name: 'test' })
endSpan(span, 'error')
expect(span.status).toBe('error')
})
it('should not modify already ended span', () => {
const span = createSpan({ name: 'test' })
endSpan(span, 'ok')
const endTime = span.endTime
endSpan(span, 'error')
expect(span.endTime).toBe(endTime)
expect(span.status).toBe('ok')
})
it('should pop span from stack', () => {
const context = createTraceContext()
runWithContext(context, () => {
const parent = startSpan({ name: 'parent' })
const child = startSpan({ name: 'child' })
endSpan(child)
expect(getCurrentContext()!.currentSpan).toBe(parent)
})
})
})
describe('addSpanEvent', () => {
it('should add event to span', () => {
const span = createSpan({ name: 'test' })
addSpanEvent(span, 'event-occurred', { detail: 'value' })
expect(span.events).toHaveLength(1)
expect(span.events[0].name).toBe('event-occurred')
expect(span.events[0].attributes).toEqual({ detail: 'value' })
})
it('should not add event to ended span', () => {
const span = createSpan({ name: 'test' })
endSpan(span)
addSpanEvent(span, 'late-event')
expect(span.events).toHaveLength(0)
})
})
describe('setSpanAttributes', () => {
it('should set attributes on span', () => {
const span = createSpan({ name: 'test' })
setSpanAttributes(span, { key1: 'value1', key2: 'value2' })
expect(span.attributes.key1).toBe('value1')
expect(span.attributes.key2).toBe('value2')
})
})
describe('setSpanError', () => {
it('should mark span as error with details', () => {
const span = createSpan({ name: 'test' })
const error = new Error('Something went wrong')
setSpanError(span, error)
expect(span.status).toBe('error')
expect(span.attributes.error).toBe(true)
expect(span.attributes.errorMessage).toBe('Something went wrong')
expect(span.events.some((e) => e.name === 'exception')).toBe(true)
})
})
})
describe('withSpan helpers', () => {
describe('withSpan', () => {
it('should execute async operation within span', async () => {
const result = await withSpan('async-op', async (span) => {
expect(span.name).toBe('async-op')
return 'async-result'
})
expect(result).toBe('async-result')
})
it('should handle errors and mark span as error', async () => {
await expect(
withSpan('failing-op', async () => {
throw new Error('Failure')
})
).rejects.toThrow('Failure')
})
})
describe('withSpanSync', () => {
it('should execute sync operation within span', () => {
const result = withSpanSync('sync-op', (span) => {
expect(span.name).toBe('sync-op')
return 'sync-result'
})
expect(result).toBe('sync-result')
})
})
})
describe('Baggage', () => {
it('should set and get baggage', () => {
const context = createTraceContext()
runWithContext(context, () => {
setBaggage('userId', 'user-123')
setBaggage('requestType', 'api')
expect(getBaggage('userId')).toBe('user-123')
expect(getBaggage('requestType')).toBe('api')
})
})
it('should get all baggage', () => {
const context = createTraceContext({ baggage: { existing: 'value' } })
runWithContext(context, () => {
setBaggage('new', 'item')
const all = getAllBaggage()
expect(all).toEqual({ existing: 'value', new: 'item' })
})
})
it('should remove baggage', () => {
const context = createTraceContext({ baggage: { toRemove: 'value' } })
runWithContext(context, () => {
removeBaggage('toRemove')
expect(getBaggage('toRemove')).toBeUndefined()
})
})
})
describe('Header injection/extraction', () => {
describe('injectTraceHeaders', () => {
it('should inject trace headers', () => {
const context = createTraceContext({
baggage: { user: 'test' },
})
const headers = runWithContext(context, () => {
return injectTraceHeaders({})
})
expect(headers[TRACE_HEADERS.TRACE_ID]).toBe(context.traceIds.traceId)
expect(headers[TRACE_HEADERS.SPAN_ID]).toBe(context.traceIds.spanId)
expect(headers[TRACE_HEADERS.CORRELATION_ID]).toBe(context.traceIds.correlationId)
expect(headers[TRACE_HEADERS.BAGGAGE]).toContain('user=test')
})
it('should preserve existing headers', () => {
const context = createTraceContext()
const headers = runWithContext(context, () => {
return injectTraceHeaders({ 'Content-Type': 'application/json' })
})
expect(headers['Content-Type']).toBe('application/json')
})
})
describe('extractTraceHeaders', () => {
it('should extract trace context from headers', () => {
const headers = {
[TRACE_HEADERS.TRACE_ID]: 'trace-123',
[TRACE_HEADERS.PARENT_SPAN_ID]: 'parent-456',
[TRACE_HEADERS.CORRELATION_ID]: 'corr-789',
[TRACE_HEADERS.BAGGAGE]: 'key1=value1,key2=value2',
}
const options = extractTraceHeaders(headers)
expect(options.traceId).toBe('trace-123')
expect(options.parentSpanId).toBe('parent-456')
expect(options.correlationId).toBe('corr-789')
expect(options.baggage).toEqual({ key1: 'value1', key2: 'value2' })
})
})
})
describe('Context serialization', () => {
describe('exportContext', () => {
it('should serialize context to JSON string', () => {
const context = createTraceContext({
baggage: { key: 'value' },
metadata: { env: 'test' },
})
const exported = exportContext(context)
const parsed = JSON.parse(exported)
expect(parsed.traceIds).toEqual(context.traceIds)
expect(parsed.baggage).toEqual(context.baggage)
expect(parsed.metadata).toEqual(context.metadata)
})
})
describe('importContext', () => {
it('should deserialize context from JSON string', () => {
const serialized = JSON.stringify({
traceIds: {
traceId: 'trace-123',
spanId: 'span-456',
correlationId: 'corr-789',
},
baggage: { imported: 'value' },
metadata: { source: 'external' },
})
const options = importContext(serialized)
expect(options.traceId).toBe('trace-123')
expect(options.parentSpanId).toBe('span-456')
expect(options.correlationId).toBe('corr-789')
expect(options.baggage).toEqual({ imported: 'value' })
})
it('should handle invalid JSON gracefully', () => {
const options = importContext('invalid json')
expect(options).toEqual({})
})
})
})
describe('PrecisionTimer', () => {
it('should measure elapsed time', async () => {
const timer = createPrecisionTimer()
await new Promise((resolve) => setTimeout(resolve, 20))
const elapsed = timer.elapsed()
expect(elapsed).toBeGreaterThan(10)
})
it('should format elapsed time', async () => {
const timer = createPrecisionTimer()
await new Promise((resolve) => setTimeout(resolve, 5))
const formatted = timer.elapsedFormatted()
expect(formatted).toMatch(/\d+(\.\d+)?(µs|ms|s)/)
})
it('should stop timer', async () => {
const timer = createPrecisionTimer()
await new Promise((resolve) => setTimeout(resolve, 10))
const stopped = timer.stop()
await new Promise((resolve) => setTimeout(resolve, 20))
const afterStop = timer.elapsed()
expect(afterStop).toBe(stopped)
})
})