UNPKG

autotel

Version:
1,528 lines (1,335 loc) 53.9 kB
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { traceProducer, traceConsumer, clearOrderingState, type ProducerConfig, type ConsumerConfig, type DLQOptions, type DLQReplayOptions, type DLQReasonCategory, type OrderingConfig, type OutOfOrderInfo, type ConsumerGroupTrackingConfig, type RebalanceEvent, type PartitionAssignment, type ConsumerGroupState, type PartitionLag, type RebalanceType, } from './messaging'; // Mock the trace function to capture span options vi.mock('./functional', () => ({ trace: vi.fn((name, factory) => { return (...args: unknown[]) => { const mockCtx = createMockContext(); const fn = factory(mockCtx); return fn(...args); }; }), })); // Mock sampling functions vi.mock('./sampling', () => ({ createLinkFromHeaders: vi.fn((headers) => { if (headers.traceparent) { return { context: { traceId: '00000000000000000000000000000001', spanId: '0000000000000001', traceFlags: 1, }, attributes: {}, }; } return null; }), extractLinksFromBatch: vi.fn((messages) => { return messages .filter( (m: { headers?: Record<string, string> }) => m.headers?.traceparent, ) .map(() => ({ context: { traceId: '00000000000000000000000000000001', spanId: '0000000000000001', traceFlags: 1, }, attributes: {}, })); }), })); function createMockContext() { const attributes: Record<string, unknown> = {}; const events: Array<{ name: string; attributes?: Record<string, unknown> }> = []; const links: unknown[] = []; return { setAttribute: vi.fn((key, value) => { attributes[key] = value; }), setAttributes: vi.fn((attrs) => { Object.assign(attributes, attrs); }), addEvent: vi.fn((name, attrs) => { events.push({ name, attributes: attrs }); }), addLink: vi.fn((link) => { links.push(link); }), addLinks: vi.fn((newLinks) => { links.push(...newLinks); }), setStatus: vi.fn(), getAttributes: () => attributes, getEvents: () => events, getLinks: () => links, // ProducerContext methods getTraceHeaders: vi.fn(() => ({ traceparent: '00-00000000000000000000000000000001-0000000000000001-01', })), getAllPropagationHeaders: vi.fn(() => ({ traceparent: '00-00000000000000000000000000000001-0000000000000001-01', baggage: 'key=value', })), // ConsumerContext methods recordDLQ: vi.fn(), recordRetry: vi.fn(), recordReplay: vi.fn(), getProducerLinks: vi.fn(() => []), }; } describe('Messaging Helpers', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('traceProducer', () => { it('should create a producer function with correct span name', async () => { const config: ProducerConfig = { system: 'kafka', destination: 'user-events', }; const producer = traceProducer(config)( (_ctx) => async (_event: { id: string }) => { return { sent: true }; }, ); const result = await producer({ id: 'event-1' }); expect(result).toEqual({ sent: true }); }); it('should set messaging semantic attributes', async () => { const config: ProducerConfig = { system: 'kafka', destination: 'user-events', messageIdFrom: (args) => (args[0] as { id: string }).id, }; // We need to directly test the attribute setting // Since the trace mock doesn't persist context properly, // we test the config structure expect(config.system).toBe('kafka'); expect(config.destination).toBe('user-events'); }); it('should support SQS system type', () => { const config: ProducerConfig = { system: 'sqs', destination: 'orders-queue', }; const producer = traceProducer(config)( (_ctx) => async (_message: string) => { return { messageId: '123' }; }, ); expect(producer).toBeDefined(); }); it('should support RabbitMQ system type', () => { const config: ProducerConfig = { system: 'rabbitmq', destination: 'notifications', }; const producer = traceProducer(config)( (_ctx) => async (_message: string) => { return { delivered: true }; }, ); expect(producer).toBeDefined(); }); it('should call beforeSend callback', async () => { const beforeSend = vi.fn(); const config: ProducerConfig = { system: 'kafka', destination: 'events', beforeSend, }; const producer = traceProducer(config)( (_ctx) => async (_event: unknown) => { return { sent: true }; }, ); await producer({ id: '1' }); expect(beforeSend).toHaveBeenCalled(); }); it('should call onError callback on failure', async () => { const onError = vi.fn(); const testError = new Error('Send failed'); const config: ProducerConfig = { system: 'kafka', destination: 'events', onError, }; const producer = traceProducer(config)((_ctx) => async () => { throw testError; }); await expect(producer()).rejects.toThrow('Send failed'); expect(onError).toHaveBeenCalledWith(testError, expect.anything()); }); it('should extract message ID from function extractor', () => { const config: ProducerConfig = { system: 'kafka', destination: 'events', messageIdFrom: (args) => (args[0] as { eventId: string }).eventId, }; expect(typeof config.messageIdFrom).toBe('function'); const extractor = config.messageIdFrom as ( args: unknown[], ) => string | undefined; expect(extractor([{ eventId: 'evt-123' }])).toBe('evt-123'); }); it('should extract partition from function extractor', () => { const config: ProducerConfig = { system: 'kafka', destination: 'events', partitionFrom: (args) => (args[0] as { partition: number }).partition, }; expect(typeof config.partitionFrom).toBe('function'); const extractor = config.partitionFrom as ( args: unknown[], ) => number | undefined; expect(extractor([{ partition: 3 }])).toBe(3); }); }); describe('traceConsumer', () => { it('should create a consumer function with correct span name', async () => { const config: ConsumerConfig = { system: 'kafka', destination: 'user-events', consumerGroup: 'my-consumer', }; const consumer = traceConsumer(config)( (_ctx) => async (_message: unknown) => { return { processed: true }; }, ); const result = await consumer({ value: 'test' }); expect(result).toEqual({ processed: true }); }); it('should support batch mode', async () => { const config: ConsumerConfig = { system: 'kafka', destination: 'user-events', batchMode: true, headersFrom: (msg) => (msg as { headers: Record<string, string> }).headers, }; const consumer = traceConsumer(config)( (_ctx) => async (messages: unknown[]) => { return { count: messages.length }; }, ); const messages = [ { value: 'a', headers: { traceparent: '00-abc-def-01' } }, { value: 'b', headers: { traceparent: '00-xyz-uvw-01' } }, ]; const result = await consumer(messages); expect(result).toEqual({ count: 2 }); }); it('should call onError callback on failure', async () => { const onError = vi.fn(); const testError = new Error('Process failed'); const config: ConsumerConfig = { system: 'sqs', destination: 'orders', onError, }; const consumer = traceConsumer(config)((_ctx) => async () => { throw testError; }); await expect(consumer({})).rejects.toThrow('Process failed'); expect(onError).toHaveBeenCalledWith(testError, expect.anything()); }); it('should support lag metrics configuration', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'events', lagMetrics: { getCurrentOffset: (msg) => (msg as { offset: number }).offset, getEndOffset: () => Promise.resolve(1000), getPartition: (msg) => (msg as { partition: number }).partition, }, }; expect(config.lagMetrics).toBeDefined(); expect(config.lagMetrics!.getCurrentOffset).toBeDefined(); expect(config.lagMetrics!.getCurrentOffset!({ offset: 500 })).toBe(500); }); it('should extract headers using string path', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'events', headersFrom: 'headers', }; expect(config.headersFrom).toBe('headers'); }); it('should extract headers using function', () => { const config: ConsumerConfig = { system: 'sqs', destination: 'events', headersFrom: (msg) => { const m = msg as { MessageAttributes: Record<string, { StringValue: string }>; }; const result: Record<string, string> = {}; for (const [k, v] of Object.entries(m.MessageAttributes || {})) { result[k] = v.StringValue; } return result; }, }; const extractor = config.headersFrom as ( msg: unknown, ) => Record<string, string> | undefined; const headers = extractor({ MessageAttributes: { traceparent: { StringValue: '00-abc-def-01' }, }, }); expect(headers).toEqual({ traceparent: '00-abc-def-01' }); }); }); describe('ProducerContext', () => { it('getTraceHeaders should return W3C trace context headers', () => { const mockCtx = createMockContext(); const headers = mockCtx.getTraceHeaders(); expect(headers.traceparent).toBeDefined(); expect(headers.traceparent).toMatch( /^\d{2}-[a-f0-9]{32}-[a-f0-9]{16}-\d{2}$/, ); }); it('getAllPropagationHeaders should include baggage when enabled', () => { const mockCtx = createMockContext(); const headers = mockCtx.getAllPropagationHeaders(); expect(headers.traceparent).toBeDefined(); expect(headers.baggage).toBeDefined(); }); }); describe('ConsumerContext', () => { it('recordDLQ should be callable with basic args', () => { const mockCtx = createMockContext(); mockCtx.recordDLQ('Max retries exceeded', 'orders-dlq'); expect(mockCtx.recordDLQ).toHaveBeenCalledWith( 'Max retries exceeded', 'orders-dlq', ); }); it('recordRetry should be callable', () => { const mockCtx = createMockContext(); mockCtx.recordRetry(2, 5); expect(mockCtx.recordRetry).toHaveBeenCalledWith(2, 5); }); it('recordReplay should be callable', () => { const mockCtx = createMockContext(); mockCtx.recordReplay({ replayAttempt: 1, dlqDwellTimeMs: 5000 }); expect(mockCtx.recordReplay).toHaveBeenCalledWith({ replayAttempt: 1, dlqDwellTimeMs: 5000, }); }); it('getProducerLinks should return links', () => { const mockCtx = createMockContext(); const links = mockCtx.getProducerLinks(); expect(mockCtx.getProducerLinks).toHaveBeenCalled(); expect(Array.isArray(links)).toBe(true); }); }); describe('Enhanced DLQ Handling', () => { describe('DLQOptions types', () => { it('should support all reason categories', () => { const categories: DLQReasonCategory[] = [ 'validation', 'processing', 'timeout', 'poison', 'unknown', ]; for (const category of categories) { const options: DLQOptions = { reasonCategory: category, }; expect(options.reasonCategory).toBe(category); } }); it('should support full DLQ options', () => { const error = new Error('Processing failed'); const options: DLQOptions = { linkToProducer: true, reasonCategory: 'processing', attemptCount: 3, originalError: error, metadata: { customField: 'value', retryDelay: 1000, isReplayable: true, }, }; expect(options.linkToProducer).toBe(true); expect(options.reasonCategory).toBe('processing'); expect(options.attemptCount).toBe(3); expect(options.originalError).toBe(error); expect(options.metadata?.customField).toBe('value'); }); it('should allow partial options', () => { const options: DLQOptions = { reasonCategory: 'validation', }; expect(options.reasonCategory).toBe('validation'); expect(options.linkToProducer).toBeUndefined(); expect(options.attemptCount).toBeUndefined(); }); }); describe('DLQReplayOptions types', () => { it('should support replay options with span context', () => { const options: DLQReplayOptions = { originalDLQSpanContext: { traceId: '00000000000000000000000000000001', spanId: '0000000000000002', traceFlags: 1, }, dlqDwellTimeMs: 3_600_000, // 1 hour replayAttempt: 2, }; expect(options.originalDLQSpanContext?.traceId).toBe( '00000000000000000000000000000001', ); expect(options.dlqDwellTimeMs).toBe(3_600_000); expect(options.replayAttempt).toBe(2); }); it('should allow partial replay options', () => { const options: DLQReplayOptions = { replayAttempt: 1, }; expect(options.replayAttempt).toBe(1); expect(options.originalDLQSpanContext).toBeUndefined(); }); }); describe('recordDLQ overloads', () => { it('should support basic signature (reason only)', () => { const mockCtx = createMockContext(); // This tests the type system accepts reason-only calls mockCtx.recordDLQ('Processing failed'); expect(mockCtx.recordDLQ).toHaveBeenCalledWith('Processing failed'); }); it('should support reason + dlqName signature', () => { const mockCtx = createMockContext(); mockCtx.recordDLQ('Schema validation failed', 'orders-dlq'); expect(mockCtx.recordDLQ).toHaveBeenCalledWith( 'Schema validation failed', 'orders-dlq', ); }); it('should support reason + options signature', () => { const mockCtx = createMockContext(); const options: DLQOptions = { reasonCategory: 'validation', attemptCount: 3, }; mockCtx.recordDLQ('Invalid payload format', options); expect(mockCtx.recordDLQ).toHaveBeenCalledWith( 'Invalid payload format', options, ); }); it('should support reason + dlqName + options signature', () => { const mockCtx = createMockContext(); const error = new Error('Timeout exceeded'); const options: DLQOptions = { reasonCategory: 'timeout', attemptCount: 5, originalError: error, linkToProducer: true, metadata: { processingTimeMs: 30_000 }, }; mockCtx.recordDLQ('Processing timeout', 'orders-dlq', options); expect(mockCtx.recordDLQ).toHaveBeenCalledWith( 'Processing timeout', 'orders-dlq', options, ); }); }); describe('Poison pill detection', () => { it('should support poison category for repeated failures', () => { const options: DLQOptions = { reasonCategory: 'poison', attemptCount: 10, metadata: { failurePattern: 'OutOfMemoryError', affectedConsumers: 3, }, }; expect(options.reasonCategory).toBe('poison'); expect(options.attemptCount).toBe(10); }); }); }); describe('MessagingSystem types', () => { it('should support kafka', () => { const config: ProducerConfig = { system: 'kafka', destination: 'topic' }; expect(config.system).toBe('kafka'); }); it('should support sqs', () => { const config: ProducerConfig = { system: 'sqs', destination: 'queue' }; expect(config.system).toBe('sqs'); }); it('should support rabbitmq', () => { const config: ProducerConfig = { system: 'rabbitmq', destination: 'exchange', }; expect(config.system).toBe('rabbitmq'); }); it('should support sns', () => { const config: ProducerConfig = { system: 'sns', destination: 'topic-arn', }; expect(config.system).toBe('sns'); }); it('should support pubsub', () => { const config: ProducerConfig = { system: 'pubsub', destination: 'topic' }; expect(config.system).toBe('pubsub'); }); it('should support custom system strings', () => { const config: ProducerConfig = { system: 'custom-mq', destination: 'channel', }; expect(config.system).toBe('custom-mq'); }); }); describe('Span naming conventions', () => { it('producer should use system.publish destination format', () => { const config: ProducerConfig = { system: 'kafka', destination: 'user-events', }; // The span name is constructed as `${config.system}.publish ${config.destination}` const expectedSpanName = `${config.system}.publish ${config.destination}`; expect(expectedSpanName).toBe('kafka.publish user-events'); }); it('consumer (single) should use system.process destination format', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'user-events', }; const expectedSpanName = `${config.system}.process ${config.destination}`; expect(expectedSpanName).toBe('kafka.process user-events'); }); it('consumer (batch) should use system.receive destination format', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'user-events', batchMode: true, }; const expectedSpanName = `${config.system}.receive ${config.destination}`; expect(expectedSpanName).toBe('kafka.receive user-events'); }); }); // ========================================================================= // Extensible Hooks ("Bring Your Own" System Support) // ========================================================================= describe('Extensible Hooks', () => { describe('Producer customAttributes hook', () => { it('should call customAttributes hook with context and args', async () => { const customAttributes = vi.fn().mockReturnValue({ 'nats.subject': 'orders.created', 'nats.stream': 'ORDERS', }); const config: ProducerConfig = { system: 'nats' as const, destination: 'orders.created', customAttributes, }; const producer = traceProducer(config)( (_ctx) => async (event: { orderId: string }) => { return { sent: true, orderId: event.orderId }; }, ); await producer({ orderId: 'order-123' }); expect(customAttributes).toHaveBeenCalled(); // Args should include the event const [, args] = customAttributes.mock.calls[0]; expect(args[0]).toEqual({ orderId: 'order-123' }); }); it('should support NATS-style attributes', () => { const config: ProducerConfig = { system: 'nats' as const, destination: 'orders.created', customAttributes: (_ctx, args) => ({ 'nats.subject': (args[0] as { subject?: string })?.subject || 'default', 'nats.reply_to': (args[0] as { replyTo?: string })?.replyTo, 'nats.stream': 'ORDERS', }), }; expect(config.customAttributes).toBeDefined(); const attrs = config.customAttributes!(createMockContext() as never, [ { subject: 'orders.created', replyTo: '_INBOX.reply' }, ]); expect(attrs['nats.subject']).toBe('orders.created'); expect(attrs['nats.reply_to']).toBe('_INBOX.reply'); }); it('should support Temporal-style attributes', () => { const config: ProducerConfig = { system: 'temporal' as const, destination: 'orders-queue', customAttributes: (_ctx, args) => ({ 'temporal.workflow_id': (args[0] as { workflowId: string }) .workflowId, 'temporal.run_id': (args[0] as { runId: string }).runId, 'temporal.task_queue': 'orders-queue', }), }; expect(config.customAttributes).toBeDefined(); const attrs = config.customAttributes!(createMockContext() as never, [ { workflowId: 'wf-123', runId: 'run-456' }, ]); expect(attrs['temporal.workflow_id']).toBe('wf-123'); expect(attrs['temporal.run_id']).toBe('run-456'); }); }); describe('Producer customHeaders hook', () => { it('should call customHeaders hook', async () => { const customHeaders = vi.fn().mockReturnValue({ 'x-correlation-id': 'corr-123', 'x-request-id': 'req-456', }); const config: ProducerConfig = { system: 'kafka', destination: 'events', customHeaders, }; // The customHeaders is called via getFullHeaders() // For testing, we verify the config accepts it expect(config.customHeaders).toBeDefined(); }); it('should support Datadog-style custom headers', () => { const config: ProducerConfig = { system: 'kafka', destination: 'events', customHeaders: (ctx) => ({ 'x-datadog-trace-id': ctx.getTraceId?.() || 'unknown', 'x-datadog-parent-id': ctx.getSpanId?.() || 'unknown', }), }; expect(config.customHeaders).toBeDefined(); }); }); describe('Consumer customAttributes hook', () => { it('should call customAttributes hook with context and message', async () => { const customAttributes = vi.fn().mockReturnValue({ 'cloudflare.queue_id': 'q-123', 'cloudflare.attempts': 1, }); const config: ConsumerConfig = { system: 'cloudflare_queues' as const, destination: 'orders-queue', customAttributes, }; const consumer = traceConsumer(config)( (_ctx) => async (msg: { id: string; attempts: number }) => { return { processed: true, id: msg.id }; }, ); await consumer({ id: 'msg-123', attempts: 1 }); expect(customAttributes).toHaveBeenCalled(); }); it('should support Cloudflare Queue attributes', () => { const config: ConsumerConfig = { system: 'cloudflare_queues' as const, destination: 'orders', customAttributes: (_ctx, msg) => ({ 'cloudflare.queue_id': (msg as { id: string }).id, 'cloudflare.timestamp_ms': Date.now(), 'cloudflare.attempts': (msg as { attempts: number }).attempts, }), }; expect(config.customAttributes).toBeDefined(); const attrs = config.customAttributes!(createMockContext() as never, { id: 'msg-123', attempts: 2, }); expect(attrs['cloudflare.queue_id']).toBe('msg-123'); expect(attrs['cloudflare.attempts']).toBe(2); }); it('should support Redis Streams attributes', () => { const config: ConsumerConfig = { system: 'redis_streams' as const, destination: 'orders:stream', customAttributes: (_ctx, msg) => ({ 'redis.stream_id': (msg as { streamId: string }).streamId, 'redis.consumer_group': 'processors', 'redis.pending_count': (msg as { pendingCount: number }) .pendingCount, }), }; expect(config.customAttributes).toBeDefined(); const attrs = config.customAttributes!(createMockContext() as never, { streamId: '1234567890-0', pendingCount: 5, }); expect(attrs['redis.stream_id']).toBe('1234567890-0'); }); }); describe('Consumer customContextExtractor hook', () => { it('should support B3 format extraction', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'events', headersFrom: 'headers', customContextExtractor: (headers) => { const traceId = headers['x-b3-traceid']; const spanId = headers['x-b3-spanid']; const sampled = headers['x-b3-sampled'] === '1'; if (!traceId || !spanId) return null; return { traceId, spanId, traceFlags: sampled ? 1 : 0, isRemote: true, }; }, }; expect(config.customContextExtractor).toBeDefined(); const context = config.customContextExtractor!({ 'x-b3-traceid': '00000000000000000000000000000abc', 'x-b3-spanid': '00000000000def', 'x-b3-sampled': '1', }); expect(context).not.toBeNull(); expect(context!.traceId).toBe('00000000000000000000000000000abc'); expect(context!.spanId).toBe('00000000000def'); expect(context!.traceFlags).toBe(1); }); it('should support Datadog format extraction', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'events', headersFrom: 'headers', customContextExtractor: (headers) => { const traceId = headers['x-datadog-trace-id']; const spanId = headers['x-datadog-parent-id']; if (!traceId || !spanId) return null; // Convert Datadog's decimal format to OTel hex return { traceId: BigInt(traceId).toString(16).padStart(32, '0'), spanId: BigInt(spanId).toString(16).padStart(16, '0'), traceFlags: 1, isRemote: true, }; }, }; expect(config.customContextExtractor).toBeDefined(); const context = config.customContextExtractor!({ 'x-datadog-trace-id': '123456789', 'x-datadog-parent-id': '987654321', }); expect(context).not.toBeNull(); // 123456789 decimal = 75bcd15 hex (7 chars) → padStart(32) = 25 zeros + 7 = 32 chars expect(context!.traceId).toBe('000000000000000000000000075bcd15'); // 987654321 decimal = 3ade68b1 hex (8 chars) → padStart(16) = 8 zeros + 8 = 16 chars expect(context!.spanId).toBe('000000003ade68b1'); }); it('should return null for missing headers', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'events', headersFrom: 'headers', customContextExtractor: (headers) => { const traceId = headers['x-b3-traceid']; const spanId = headers['x-b3-spanid']; if (!traceId || !spanId) return null; return { traceId, spanId, traceFlags: 1, isRemote: true }; }, }; const context = config.customContextExtractor!({}); expect(context).toBeNull(); }); }); describe('Combined hooks usage', () => { it('should support using all hooks together for NATS', () => { const producerConfig: ProducerConfig = { system: 'nats' as const, destination: 'orders.created', customAttributes: (_ctx, args) => ({ 'nats.subject': 'orders.created', 'nats.stream': 'ORDERS', 'nats.sequence': (args[0] as { seq?: number })?.seq || 0, }), customHeaders: (_ctx) => ({ 'Nats-Msg-Id': `msg-${Date.now()}`, }), }; const consumerConfig: ConsumerConfig = { system: 'nats' as const, destination: 'orders.created', headersFrom: 'headers', customAttributes: (_ctx, msg) => ({ 'nats.subject': (msg as { subject: string }).subject, 'nats.redelivered': (msg as { redelivered?: boolean })?.redelivered || false, }), customContextExtractor: (headers) => { // NATS uses its own tracing headers const traceId = headers['nats-trace-id']; const spanId = headers['nats-span-id']; if (!traceId || !spanId) return null; return { traceId, spanId, traceFlags: 1, isRemote: true }; }, }; expect(producerConfig.customAttributes).toBeDefined(); expect(producerConfig.customHeaders).toBeDefined(); expect(consumerConfig.customAttributes).toBeDefined(); expect(consumerConfig.customContextExtractor).toBeDefined(); }); }); }); describe('Message Ordering Support', () => { beforeEach(() => { // Clear global ordering state between tests clearOrderingState(); }); describe('OrderingConfig types', () => { it('should define OrderingConfig interface correctly', () => { const config: OrderingConfig = { sequenceFrom: (msg) => (msg as { offset: number }).offset, partitionKeyFrom: (msg) => (msg as { key: string }).key, messageIdFrom: (msg) => (msg as { id: string }).id, detectOutOfOrder: true, detectDuplicates: true, deduplicationWindowSize: 500, onOutOfOrder: (_ctx, _info) => {}, onDuplicate: (_ctx, _id) => {}, }; expect(config.sequenceFrom).toBeDefined(); expect(config.partitionKeyFrom).toBeDefined(); expect(config.messageIdFrom).toBeDefined(); expect(config.detectOutOfOrder).toBe(true); expect(config.detectDuplicates).toBe(true); expect(config.deduplicationWindowSize).toBe(500); }); it('should define OutOfOrderInfo interface correctly', () => { const info: OutOfOrderInfo = { currentSequence: 10, expectedSequence: 5, partitionKey: 'partition-1', gap: 5, }; expect(info.currentSequence).toBe(10); expect(info.expectedSequence).toBe(5); expect(info.partitionKey).toBe('partition-1'); expect(info.gap).toBe(5); }); }); describe('Sequence tracking', () => { it('should extract sequence number from message', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { sequenceFrom: (msg) => (msg as { offset: number }).offset, }, }; expect(config.ordering?.sequenceFrom?.({ offset: 42 })).toBe(42); }); it('should extract partition key from message', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { partitionKeyFrom: (msg) => (msg as { key: string }).key, }, }; expect(config.ordering?.partitionKeyFrom?.({ key: 'user-123' })).toBe( 'user-123', ); }); it('should extract message ID for deduplication', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { messageIdFrom: (msg) => (msg as { id: string }).id, }, }; expect(config.ordering?.messageIdFrom?.({ id: 'msg-abc' })).toBe( 'msg-abc', ); }); }); describe('Out-of-order detection', () => { it('should detect out-of-order messages and call callback', async () => { const outOfOrderCallback = vi.fn(); const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { sequenceFrom: (msg) => (msg as { offset: number }).offset, detectOutOfOrder: true, onOutOfOrder: outOfOrderCallback, }, }; // Verify config is set up correctly expect(config.ordering?.detectOutOfOrder).toBe(true); expect(config.ordering?.onOutOfOrder).toBe(outOfOrderCallback); }); it('should calculate gap correctly for missing messages', () => { // Test gap calculation: if we expect 5 but get 10, gap is 5 const info: OutOfOrderInfo = { currentSequence: 10, expectedSequence: 5, gap: 10 - 5, // = 5 (5 messages missing) }; expect(info.gap).toBe(5); }); it('should calculate negative gap for out-of-order (earlier) messages', () => { // If we expect 10 but get 5 (received earlier message), gap is -5 const info: OutOfOrderInfo = { currentSequence: 5, expectedSequence: 10, gap: 5 - 10, // = -5 (message from past) }; expect(info.gap).toBe(-5); }); it('should track sequence per partition key', () => { // Verify config supports partition-specific tracking const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { sequenceFrom: (msg) => (msg as { offset: number }).offset, partitionKeyFrom: (msg) => (msg as { partition: string }).partition, detectOutOfOrder: true, }, }; expect(config.ordering?.partitionKeyFrom).toBeDefined(); expect(config.ordering?.sequenceFrom).toBeDefined(); }); }); describe('Duplicate detection', () => { it('should detect duplicate messages and call callback', async () => { const duplicateCallback = vi.fn(); const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { messageIdFrom: (msg) => (msg as { id: string }).id, detectDuplicates: true, onDuplicate: duplicateCallback, }, }; // Verify config is set up correctly expect(config.ordering?.detectDuplicates).toBe(true); expect(config.ordering?.onDuplicate).toBe(duplicateCallback); }); it('should use custom deduplication window size', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { messageIdFrom: (msg) => (msg as { id: string }).id, detectDuplicates: true, deduplicationWindowSize: 500, }, }; expect(config.ordering?.deduplicationWindowSize).toBe(500); }); it('should default deduplication window to 1000', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { messageIdFrom: (msg) => (msg as { id: string }).id, detectDuplicates: true, // No deduplicationWindowSize - defaults to 1000 }, }; expect(config.ordering?.deduplicationWindowSize).toBeUndefined(); // The actual default (1000) is applied at runtime in extractAndProcessOrdering }); }); describe('ConsumerContext ordering methods', () => { it('should have isDuplicate() method in ConsumerContext interface', () => { // Type check - these methods are defined in ConsumerContext interface const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { detectDuplicates: true, }, }; // Verify the config is valid expect(config.ordering?.detectDuplicates).toBe(true); }); it('should have getOutOfOrderInfo() method in ConsumerContext interface', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { detectOutOfOrder: true, }, }; expect(config.ordering?.detectOutOfOrder).toBe(true); }); it('should have getSequenceNumber() method in ConsumerContext interface', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { sequenceFrom: (msg) => (msg as { offset: number }).offset, }, }; expect(config.ordering?.sequenceFrom).toBeDefined(); }); it('should have getPartitionKey() method in ConsumerContext interface', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'orders', ordering: { partitionKeyFrom: (msg) => (msg as { key: string }).key, }, }; expect(config.ordering?.partitionKeyFrom).toBeDefined(); }); }); describe('Combined ordering configuration', () => { it('should support full Kafka ordering configuration', () => { const outOfOrderCallback = vi.fn(); const duplicateCallback = vi.fn(); const config: ConsumerConfig = { system: 'kafka', destination: 'user-events', consumerGroup: 'event-processor', ordering: { sequenceFrom: (msg) => (msg as { offset: number }).offset, partitionKeyFrom: (msg) => (msg as { key: string }).key, messageIdFrom: (msg) => (msg as { headers: { idempotencyKey: string } }).headers .idempotencyKey, detectOutOfOrder: true, detectDuplicates: true, deduplicationWindowSize: 2000, onOutOfOrder: outOfOrderCallback, onDuplicate: duplicateCallback, }, }; expect(config.ordering).toBeDefined(); expect(config.ordering?.detectOutOfOrder).toBe(true); expect(config.ordering?.detectDuplicates).toBe(true); expect(config.ordering?.deduplicationWindowSize).toBe(2000); }); it('should support SQS ordering configuration with message group', () => { const config: ConsumerConfig = { system: 'sqs', destination: 'orders.fifo', ordering: { sequenceFrom: (msg) => Number.parseInt( (msg as { Attributes: { SequenceNumber: string } }).Attributes .SequenceNumber, 10, ), partitionKeyFrom: (msg) => (msg as { Attributes: { MessageGroupId: string } }).Attributes .MessageGroupId, messageIdFrom: (msg) => (msg as { MessageId: string }).MessageId, detectOutOfOrder: true, detectDuplicates: true, }, }; // Test extraction functions const sqsMessage = { MessageId: 'msg-123', Attributes: { SequenceNumber: '12345', MessageGroupId: 'order-group-1', }, }; expect(config.ordering?.sequenceFrom?.(sqsMessage)).toBe(12_345); expect(config.ordering?.partitionKeyFrom?.(sqsMessage)).toBe( 'order-group-1', ); expect(config.ordering?.messageIdFrom?.(sqsMessage)).toBe('msg-123'); }); it('should support RabbitMQ ordering configuration', () => { const config: ConsumerConfig = { system: 'rabbitmq', destination: 'orders', consumerGroup: 'order-processor', ordering: { sequenceFrom: (msg) => (msg as { properties: { headers: { 'x-sequence': number } } }) .properties.headers['x-sequence'], messageIdFrom: (msg) => (msg as { properties: { messageId: string } }).properties .messageId, detectDuplicates: true, }, }; const rabbitMessage = { properties: { messageId: 'rabbit-msg-1', headers: { 'x-sequence': 42, }, }, }; expect(config.ordering?.sequenceFrom?.(rabbitMessage)).toBe(42); expect(config.ordering?.messageIdFrom?.(rabbitMessage)).toBe( 'rabbit-msg-1', ); }); }); describe('clearOrderingState()', () => { it('should be exported for test isolation', () => { expect(typeof clearOrderingState).toBe('function'); }); it('should not throw when called', () => { expect(() => clearOrderingState()).not.toThrow(); }); it('should allow multiple calls', () => { clearOrderingState(); clearOrderingState(); clearOrderingState(); // No error = success expect(true).toBe(true); }); }); describe('Span attributes', () => { it('should define expected attribute names for sequence number', () => { // Document expected span attributes const expectedAttributes = [ 'messaging.message.sequence_number', 'messaging.message.partition_key', 'messaging.message.id', 'messaging.ordering.out_of_order', 'messaging.ordering.expected_sequence', 'messaging.ordering.gap', 'messaging.ordering.duplicate', ]; // These are the attributes set by extractAndProcessOrdering expect(expectedAttributes).toContain( 'messaging.message.sequence_number', ); expect(expectedAttributes).toContain('messaging.ordering.out_of_order'); expect(expectedAttributes).toContain('messaging.ordering.duplicate'); }); it('should define expected event names', () => { const expectedEvents = ['message_out_of_order', 'message_duplicate']; expect(expectedEvents).toContain('message_out_of_order'); expect(expectedEvents).toContain('message_duplicate'); }); }); }); describe('Consumer Group Tracking', () => { describe('ConsumerGroupTrackingConfig types', () => { it('should define ConsumerGroupTrackingConfig interface correctly', () => { const config: ConsumerGroupTrackingConfig = { memberId: 'consumer-1', groupInstanceId: 'instance-1', onRebalance: (_ctx, _event) => {}, onPartitionsAssigned: (_ctx, _partitions) => {}, onPartitionsRevoked: (_ctx, _partitions) => {}, trackPartitionLag: true, trackHeartbeat: true, heartbeatIntervalMs: 3000, }; expect(config.memberId).toBe('consumer-1'); expect(config.groupInstanceId).toBe('instance-1'); expect(config.trackPartitionLag).toBe(true); expect(config.trackHeartbeat).toBe(true); expect(config.heartbeatIntervalMs).toBe(3000); }); it('should support function-based memberId and groupInstanceId', () => { let dynamicMemberId = 'consumer-initial'; const config: ConsumerGroupTrackingConfig = { memberId: () => dynamicMemberId, groupInstanceId: () => 'static-instance', }; expect(typeof config.memberId).toBe('function'); expect((config.memberId as () => string)()).toBe('consumer-initial'); dynamicMemberId = 'consumer-updated'; expect((config.memberId as () => string)()).toBe('consumer-updated'); }); }); describe('RebalanceEvent types', () => { it('should define RebalanceEvent interface correctly', () => { const event: RebalanceEvent = { type: 'assigned', partitions: [ { topic: 'orders', partition: 0, offset: 100 }, { topic: 'orders', partition: 1, offset: 200 }, ], timestamp: Date.now(), generation: 5, memberId: 'consumer-1', reason: 'new consumer joined', }; expect(event.type).toBe('assigned'); expect(event.partitions).toHaveLength(2); expect(event.generation).toBe(5); expect(event.memberId).toBe('consumer-1'); }); it('should support all rebalance types', () => { const types: RebalanceType[] = ['assigned', 'revoked', 'lost']; for (const type of types) { const event: RebalanceEvent = { type, partitions: [], timestamp: Date.now(), }; expect(event.type).toBe(type); } }); }); describe('PartitionAssignment types', () => { it('should define PartitionAssignment interface correctly', () => { const assignment: PartitionAssignment = { topic: 'user-events', partition: 3, offset: 12_345, metadata: 'leader-epoch:10', }; expect(assignment.topic).toBe('user-events'); expect(assignment.partition).toBe(3); expect(assignment.offset).toBe(12_345); expect(assignment.metadata).toBe('leader-epoch:10'); }); it('should allow minimal partition assignment', () => { const assignment: PartitionAssignment = { topic: 'events', partition: 0, }; expect(assignment.topic).toBe('events'); expect(assignment.partition).toBe(0); expect(assignment.offset).toBeUndefined(); }); }); describe('ConsumerGroupState types', () => { it('should define ConsumerGroupState interface correctly', () => { const state: ConsumerGroupState = { groupId: 'order-processors', memberId: 'consumer-abc123', groupInstanceId: 'instance-1', assignedPartitions: [ { topic: 'orders', partition: 0 }, { topic: 'orders', partition: 1 }, ], generation: 10, isActive: true, lastHeartbeat: Date.now(), state: 'stable', }; expect(state.groupId).toBe('order-processors'); expect(state.isActive).toBe(true); expect(state.state).toBe('stable'); expect(state.assignedPartitions).toHaveLength(2); }); it('should support all consumer group states', () => { const states: ConsumerGroupState['state'][] = [ 'stable', 'preparing_rebalance', 'completing_rebalance', 'dead', 'empty', ]; for (const groupState of states) { const state: ConsumerGroupState = { groupId: 'test', assignedPartitions: [], isActive: groupState !== 'dead', state: groupState, }; expect(state.state).toBe(groupState); } }); }); describe('PartitionLag types', () => { it('should define PartitionLag interface correctly', () => { const lag: PartitionLag = { topic: 'events', partition: 2, currentOffset: 1000, endOffset: 1500, lag: 500, timestamp: Date.now(), }; expect(lag.topic).toBe('events'); expect(lag.partition).toBe(2); expect(lag.lag).toBe(500); expect(lag.currentOffset).toBe(1000); expect(lag.endOffset).toBe(1500); }); it('should calculate lag correctly', () => { const currentOffset = 5000; const endOffset = 5250; const lag: PartitionLag = { topic: 'orders', partition: 0, currentOffset, endOffset, lag: endOffset - currentOffset, timestamp: Date.now(), }; expect(lag.lag).toBe(250); }); }); describe('Consumer config with group tracking', () => { it('should support full Kafka consumer group configuration', () => { const rebalanceCallback = vi.fn(); const assignedCallback = vi.fn(); const revokedCallback = vi.fn(); const config: ConsumerConfig = { system: 'kafka', destination: 'user-events', consumerGroup: 'event-processor', consumerGroupTracking: { memberId: () => 'consumer-123', groupInstanceId: 'static-instance-1', onRebalance: rebalanceCallback, onPartitionsAssigned: assignedCallback, onPartitionsRevoked: revokedCallback, trackPartitionLag: true, trackHeartbeat: true, heartbeatIntervalMs: 3000, }, }; expect(config.consumerGroupTracking).toBeDefined(); expect(config.consumerGroupTracking?.trackPartitionLag).toBe(true); expect(config.consumerGroupTracking?.trackHeartbeat).toBe(true); }); it('should support minimal group tracking configuration', () => { const config: ConsumerConfig = { system: 'kafka', destination: 'events', consumerGroup: 'processors', consumerGroupTracking: { memberId: 'consumer-1', }, }; expect(config.consumerGroupTracking?.memberId).toBe('consumer-1'); expect(config.consumerGroupTracking?.trackPartitionLag).toBeUndefined(); }); }); describe('Span attributes for consumer groups', () => { it('should define expected attribute names', () => { const expectedAttributes = [ 'messaging.consumer_group.rebalance.type', 'messaging.consumer_group.rebalance.partition_count', 'messaging.consumer_group.generation', 'messagi