UNPKG

autotel

Version:
330 lines (329 loc) 12 kB
//#region src/messaging-testing.ts /** * Generate a random hex string */ function randomHex(length) { let result = ""; const chars = "0123456789abcdef"; for (let i = 0; i < length; i++) result += chars.charAt(Math.floor(Math.random() * 16)); return result; } /** * Create a messaging test harness * * Provides utilities for recording and asserting on producer/consumer calls * during testing. * * @example * ```typescript * const harness = createMessagingTestHarness(); * * // In your test setup * beforeEach(() => harness.reset()); * * // In your tests * it('should publish order event', async () => { * await orderService.createOrder({ id: '123' }); * * harness.assertProducerCalled('orders', { * messageCount: 1, * hasTraceHeaders: true, * }); * * const lastCall = harness.getLastProducerCall('orders'); * expect(lastCall?.payload).toMatchObject({ orderId: '123' }); * }); * ``` */ function createMessagingTestHarness() { const producerCalls = []; const consumerCalls = []; const rebalanceEvents = []; return { producerCalls, consumerCalls, rebalanceEvents, recordProducerCall(call) { producerCalls.push({ ...call, timestamp: Date.now() }); }, recordConsumerCall(call) { consumerCalls.push({ ...call, timestamp: Date.now() }); }, recordRebalanceEvent(event) { rebalanceEvents.push(event); }, createMockMessage(payload, options = {}) { return { payload, headers: options.headers ?? this.createMockTraceHeaders(), offset: options.offset ?? Math.floor(Math.random() * 1e4), partition: options.partition ?? 0, key: options.key, messageId: options.messageId ?? `msg-${randomHex(8)}`, timestamp: options.timestamp ?? Date.now() }; }, createMockTraceHeaders(traceId, spanId) { return { traceparent: `00-${traceId ?? randomHex(32)}-${spanId ?? randomHex(16)}-01` }; }, assertProducerCalled(destination, options = {}) { const calls = producerCalls.filter((c) => c.destination === destination); if (calls.length === 0) throw new Error(`Expected producer to be called for destination '${destination}', but it was not called`); if (options.messageCount !== void 0 && calls.length !== options.messageCount) throw new Error(`Expected ${options.messageCount} producer calls for '${destination}', got ${calls.length}`); if (options.hasTraceHeaders) { const withoutHeaders = calls.filter((c) => !c.headers?.traceparent); if (withoutHeaders.length > 0) throw new Error(`Expected all producer calls for '${destination}' to have trace headers, but ${withoutHeaders.length} did not`); } if (options.traceId) { if (calls.filter((c) => c.traceId === options.traceId).length === 0) throw new Error(`Expected producer call for '${destination}' with traceId '${options.traceId}', but none found`); } if (options.payloadMatcher) { if (calls.filter((c) => options.payloadMatcher(c.payload)).length === 0) throw new Error(`Expected producer call for '${destination}' to match payload matcher, but none did`); } }, assertProducerNotCalled(destination) { if (destination) { const calls = producerCalls.filter((c) => c.destination === destination); if (calls.length > 0) throw new Error(`Expected producer not to be called for '${destination}', but it was called ${calls.length} times`); } else if (producerCalls.length > 0) throw new Error(`Expected no producer calls, but ${producerCalls.length} calls were made`); }, assertConsumerProcessed(destination, options = {}) { const calls = consumerCalls.filter((c) => c.destination === destination); if (calls.length === 0) throw new Error(`Expected consumer to process messages for destination '${destination}', but none were processed`); if (options.messageCount !== void 0 && calls.length !== options.messageCount) throw new Error(`Expected ${options.messageCount} consumer calls for '${destination}', got ${calls.length}`); if (options.consumerGroup) { if (calls.filter((c) => c.consumerGroup !== options.consumerGroup).length > 0) throw new Error(`Expected consumer group '${options.consumerGroup}' for '${destination}', but found different groups`); } if (options.hasProducerLinks) { const withoutLinks = calls.filter((c) => c.producerLinks.length === 0); if (withoutLinks.length > 0) throw new Error(`Expected all consumer calls for '${destination}' to have producer links, but ${withoutLinks.length} did not`); } if (options.hasDuplicates !== void 0) { const duplicates = calls.filter((c) => c.isDuplicate); if (options.hasDuplicates && duplicates.length === 0) throw new Error(`Expected duplicate messages for '${destination}', but none were detected`); if (!options.hasDuplicates && duplicates.length > 0) throw new Error(`Expected no duplicate messages for '${destination}', but ${duplicates.length} were detected`); } if (options.hasOutOfOrder !== void 0) { const outOfOrder = calls.filter((c) => c.outOfOrderInfo !== null); if (options.hasOutOfOrder && outOfOrder.length === 0) throw new Error(`Expected out-of-order messages for '${destination}', but none were detected`); if (!options.hasOutOfOrder && outOfOrder.length > 0) throw new Error(`Expected no out-of-order messages for '${destination}', but ${outOfOrder.length} were detected`); } if (options.hasDLQ !== void 0) { const dlqCalls = calls.filter((c) => c.dlqReason !== void 0); if (options.hasDLQ && dlqCalls.length === 0) throw new Error(`Expected DLQ routing for '${destination}', but none occurred`); if (!options.hasDLQ && dlqCalls.length > 0) throw new Error(`Expected no DLQ routing for '${destination}', but ${dlqCalls.length} occurred`); } }, assertConsumerNotCalled(destination) { if (destination) { const calls = consumerCalls.filter((c) => c.destination === destination); if (calls.length > 0) throw new Error(`Expected consumer not to be called for '${destination}', but it processed ${calls.length} messages`); } else if (consumerCalls.length > 0) throw new Error(`Expected no consumer calls, but ${consumerCalls.length} messages were processed`); }, assertRebalanceOccurred(destination, type, partitionCount) { const events = rebalanceEvents.filter((e) => e.destination === destination && e.type === type); if (events.length === 0) throw new Error(`Expected rebalance '${type}' for '${destination}', but none occurred`); if (partitionCount !== void 0) { if (events.filter((e) => e.partitions.length === partitionCount).length === 0) throw new Error(`Expected rebalance '${type}' for '${destination}' with ${partitionCount} partitions, but none matched`); } }, getProducerCalls(destination) { if (destination) return producerCalls.filter((c) => c.destination === destination); return [...producerCalls]; }, getConsumerCalls(destination) { if (destination) return consumerCalls.filter((c) => c.destination === destination); return [...consumerCalls]; }, getLastProducerCall(destination) { return this.getProducerCalls(destination).at(-1); }, getLastConsumerCall(destination) { return this.getConsumerCalls(destination).at(-1); }, reset() { producerCalls.length = 0; consumerCalls.length = 0; rebalanceEvents.length = 0; }, shutdown() { this.reset(); } }; } /** * Create a mock message broker for testing * * Simulates a message broker (Kafka, SQS, RabbitMQ, etc.) for unit testing. * * @example * ```typescript * const broker = createMockMessageBroker(); * * // Producer publishes * broker.publish('orders', { payload: { orderId: '123' }, headers: {} }); * * // Consumer receives * const messages = broker.consume('orders'); * expect(messages).toHaveLength(1); * expect(messages[0].payload).toEqual({ orderId: '123' }); * ``` */ function createMockMessageBroker() { const topics = /* @__PURE__ */ new Map(); return { topics, publish(topic, message) { if (!topics.has(topic)) topics.set(topic, []); topics.get(topic).push({ ...message, timestamp: message.timestamp ?? Date.now(), offset: message.offset ?? topics.get(topic).length }); }, consume(topic, count) { const messages = topics.get(topic) ?? []; if (count === void 0) { const all = [...messages]; messages.length = 0; return all; } return messages.splice(0, count); }, peek(topic, count) { const messages = topics.get(topic) ?? []; if (count === void 0) return [...messages]; return messages.slice(0, count); }, getMessageCount(topic) { return topics.get(topic)?.length ?? 0; }, clear(topic) { if (topic) topics.set(topic, []); else topics.clear(); }, createTopic(topic) { if (!topics.has(topic)) topics.set(topic, []); }, deleteTopic(topic) { topics.delete(topic); }, listTopics() { return [...topics.keys()]; } }; } /** * Extract trace ID from traceparent header */ function extractTraceIdFromHeader(traceparent) { const parts = traceparent.split("-"); if (parts.length >= 3 && parts[1] !== void 0) return parts[1]; return null; } /** * Extract span ID from traceparent header */ function extractSpanIdFromHeader(traceparent) { const parts = traceparent.split("-"); if (parts.length >= 4 && parts[2] !== void 0) return parts[2]; return null; } /** * Create a mock span context */ function createMockSpanContext(traceId, spanId) { return { traceId: traceId ?? randomHex(32), spanId: spanId ?? randomHex(16), traceFlags: 1, isRemote: true }; } /** * Create a mock link to a producer span */ function createMockProducerLink(traceId, spanId) { return { context: createMockSpanContext(traceId, spanId), attributes: { "messaging.link.source": "producer" } }; } /** * Create a batch of mock messages */ function createMockMessageBatch(payloads, options = {}) { const startOffset = options.startOffset ?? 0; const addTraceHeaders = options.addTraceHeaders ?? true; const traceId = options.traceId ?? randomHex(32); return payloads.map((payload, index) => ({ payload, headers: addTraceHeaders ? { traceparent: `00-${traceId}-${randomHex(16)}-01` } : void 0, offset: startOffset + index, partition: options.partition ?? 0, messageId: `msg-${randomHex(8)}`, timestamp: Date.now() + index })); } /** * Create a rebalance scenario */ function createRebalanceScenario(topic, consumerGroup, partitions) { const assignments = partitions.map((p) => ({ topic, partition: p, offset: 0 })); return { assignEvent: { type: "assigned", partitions: assignments, timestamp: Date.now(), generation: 1, destination: topic, consumerGroup }, revokeEvent: { type: "revoked", partitions: assignments, timestamp: Date.now() + 1e3, generation: 2, destination: topic, consumerGroup } }; } /** * Create an out-of-order scenario */ function createOutOfOrderScenario(payloads, outOfOrderIndices) { const shuffled = [...createMockMessageBatch(payloads, { addTraceHeaders: true })]; for (const index of outOfOrderIndices) if (index > 0 && index < shuffled.length) { const prev = shuffled[index - 1]; const curr = shuffled[index]; shuffled[index - 1] = curr; shuffled[index] = prev; } return shuffled; } /** * Create a duplicate message scenario */ function createDuplicateScenario(payloads, duplicateIndices) { const messages = createMockMessageBatch(payloads, { addTraceHeaders: true }); const result = [...messages]; for (const index of duplicateIndices) { const originalMessage = messages[index]; if (index >= 0 && index < messages.length && originalMessage) result.splice(index + 1, 0, { ...originalMessage }); } return result; } //#endregion export { createDuplicateScenario, createMessagingTestHarness, createMockMessageBatch, createMockMessageBroker, createMockProducerLink, createMockSpanContext, createOutOfOrderScenario, createRebalanceScenario, extractSpanIdFromHeader, extractTraceIdFromHeader }; //# sourceMappingURL=messaging-testing.js.map