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:
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user