autotel
Version:
Write Once, Observe Anywhere
330 lines (329 loc) • 12 kB
JavaScript
//#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