UNPKG

@codeforbreakfast/eventsourcing-testing-contracts

Version:

Comprehensive testing utilities for event sourcing implementations - Test contracts for transport and protocol implementations with mock utilities and test data generators

316 lines 21.4 kB
/** * Client Transport Contract Tests * * Tests client-side transport interface compliance with transport-contracts. * Validates client-side message delivery mechanics, connection management, * and subscription behaviors. */ import { Effect, Stream, pipe, Chunk, Duration, Fiber, Schema, Ref } from 'effect'; import { describe, expect, it, beforeEach, afterEach } from '@codeforbreakfast/buntest'; import { TransportMessageSchema } from '../test-layer-interfaces'; const verifyInitialConnectionState = (transport) => pipe(transport.connectionState, Stream.take(1), Stream.runHead, Effect.tap((initialState) => Effect.sync(() => { expect(initialState._tag).toBe('Some'); if (initialState._tag === 'Some') { expect(initialState.value).toBe('connected'); } }))); const interruptAfterDelay = (fiber) => pipe(Effect.sleep(Duration.millis(100)), Effect.flatMap(() => Fiber.interrupt(fiber))); const monitorConnectionStateThenInterrupt = (transport) => pipe(transport.connectionState, Stream.runForEach((state) => Effect.sync(() => { void state; })), Effect.fork, Effect.flatMap(interruptAfterDelay)); const collectStateHistoryThenVerify = (stateHistoryRef, stateMonitoring) => pipe(Effect.sleep(Duration.millis(200)), Effect.flatMap(() => Fiber.interrupt(stateMonitoring)), Effect.flatMap(() => Ref.get(stateHistoryRef)), Effect.tap((stateHistory) => Effect.sync(() => { expect(Chunk.size(stateHistory)).toBeGreaterThan(0); expect(Chunk.unsafeGet(stateHistory, 0)).toBe('connected'); }))); const monitorConnectionStateHistory = (transport) => pipe(Ref.make(Chunk.empty()), Effect.flatMap((stateHistoryRef) => pipe(transport.connectionState, Stream.take(3), Stream.runForEach((state) => Ref.update(stateHistoryRef, Chunk.append(state))), Effect.fork, Effect.flatMap((stateMonitoring) => collectStateHistoryThenVerify(stateHistoryRef, stateMonitoring))))); const publishSimpleMessage = (transport) => { const messageInput = { id: 'test-1', type: 'test-message', payload: { content: 'hello world' }, }; return pipe(Schema.decodeUnknown(TransportMessageSchema)(messageInput), Effect.flatMap((message) => transport.publish(message))); }; const publishVariousMessageTypes = (transport) => { const messages = [ { id: 'string-msg', type: 'test', payload: 'simple string' }, { id: 'number-msg', type: 'test', payload: 42 }, { id: 'boolean-msg', type: 'test', payload: true }, { id: 'object-msg', type: 'test', payload: { nested: { data: [1, 2, 3] } } }, { id: 'array-msg', type: 'test', payload: [{ a: 1 }, { b: 2 }] }, { id: 'null-msg', type: 'test', payload: null }, ]; return Effect.forEach(messages, transport.publish); }; const publishMessageWithMetadata = (transport) => { const message = { id: 'meta-msg', type: 'test-message', payload: { content: 'test' }, metadata: { source: 'test-suite', priority: 'high', customField: { nested: 'value' }, }, }; return transport.publish(message); }; const publishAndHandleError = (transport) => { const message = { id: '', type: 'test', payload: 'should be handled gracefully', }; const success = 'success'; const error = 'error'; return pipe(transport.publish(message), Effect.map(() => success), Effect.catchAll(() => Effect.succeed(error)), Effect.tap((result) => Effect.sync(() => expect(['success', 'error']).toContain(result)))); }; const publishTestMessageAfterDelay = (transport) => pipe(Effect.sleep(Duration.millis(50)), Effect.flatMap(() => { const testMessage = { id: 'sub-test-1', type: 'subscription-test', payload: { data: 'received' }, }; return transport.publish(testMessage); })); const verifyReceivedMessage = (messages) => Effect.sync(() => { expect(messages).toHaveLength(1); expect(messages[0]?.id).toBe('sub-test-1'); expect(messages[0]?.type).toBe('subscription-test'); }); const collectMessageThenPublishAndVerify = (stream, transport) => pipe(stream, Stream.take(1), Stream.runCollect, Effect.map(Chunk.toReadonlyArray), Effect.fork, Effect.flatMap((messagePromise) => pipe(publishTestMessageAfterDelay(transport), Effect.flatMap(() => Fiber.join(messagePromise)), Effect.tap(verifyReceivedMessage)))); const receivePublishedMessages = (transport) => pipe(transport.subscribe(), Effect.flatMap((stream) => collectMessageThenPublishAndVerify(stream, transport))); const forkStreamCollection = (count) => (stream) => pipe(stream, Stream.take(count), Stream.runCollect, Effect.map(Chunk.toReadonlyArray), Effect.fork); const subscribeAndCollectMessages = (transport, filter, count) => pipe(transport.subscribe(filter), Effect.flatMap(forkStreamCollection(count))); const publishMultipleTypedMessages = (transport) => { const messages = [ { id: '1', type: 'type-a', payload: 'a1' }, { id: '2', type: 'type-b', payload: 'b1' }, { id: '3', type: 'type-a', payload: 'a2' }, { id: '4', type: 'type-b', payload: 'b2' }, ]; return Effect.forEach(messages, transport.publish); }; const verifyTypedMessages = ([typeAMessages, typeBMessages]) => Effect.sync(() => { expect(typeAMessages).toHaveLength(2); expect(typeBMessages).toHaveLength(2); expect(typeAMessages.every((msg) => msg.type === 'type-a')).toBe(true); expect(typeBMessages.every((msg) => msg.type === 'type-b')).toBe(true); }); const publishAndVerifyTypedSubscriptions = (subscription1, subscription2, transport) => pipe(Effect.sleep(Duration.millis(50)), Effect.flatMap(() => publishMultipleTypedMessages(transport)), Effect.flatMap(() => Effect.all([Fiber.join(subscription1), Fiber.join(subscription2)])), Effect.tap(verifyTypedMessages)); const testMultipleConcurrentSubscriptions = (transport) => pipe(Effect.all([ subscribeAndCollectMessages(transport, (msg) => msg.type === 'type-a', 2), subscribeAndCollectMessages(transport, (msg) => msg.type === 'type-b', 2), ]), Effect.flatMap(([subscription1, subscription2]) => publishAndVerifyTypedSubscriptions(subscription1, subscription2, transport))); const FilteredPayloadSchema = Schema.Struct({ priority: Schema.Literal('high'), value: Schema.Number.pipe(Schema.greaterThan(10)), }); const complexFilter = (msg) => { if (msg.type !== 'filtered-test') return false; const parseResult = Schema.decodeUnknownEither(FilteredPayloadSchema)(msg.payload); return parseResult._tag === 'Right'; }; const publishFilterTestMessages = (transport) => { const testMessages = [ { id: '1', type: 'filtered-test', payload: { priority: 'high', value: 15 } }, { id: '2', type: 'filtered-test', payload: { priority: 'low', value: 20 } }, { id: '3', type: 'filtered-test', payload: { priority: 'high', value: 5 } }, { id: '4', type: 'other-test', payload: { priority: 'high', value: 25 } }, { id: '5', type: 'filtered-test', payload: { priority: 'high', value: 30 } }, ]; return Effect.forEach(testMessages, transport.publish); }; const verifyFilteredResults = (results) => Effect.sync(() => { expect(results).toHaveLength(2); expect(results[0]?.id).toBe('1'); expect(results[1]?.id).toBe('5'); }); const publishAndVerifyFilteredMessages = (filteredMessages, transport) => pipe(Effect.sleep(Duration.millis(50)), Effect.flatMap(() => publishFilterTestMessages(transport)), Effect.flatMap(() => Fiber.join(filteredMessages)), Effect.tap(verifyFilteredResults)); const forkAndVerifyFilteredMessages = (transport) => (stream) => pipe(stream, Stream.take(2), Stream.runCollect, Effect.map(Chunk.toReadonlyArray), Effect.fork, Effect.flatMap((filteredMessages) => publishAndVerifyFilteredMessages(filteredMessages, transport))); const testSubscriptionWithComplexFilter = (transport) => pipe(transport.subscribe(complexFilter), Effect.flatMap(forkAndVerifyFilteredMessages(transport))); const testSubscriptionErrorHandling = (transport) => pipe(transport.subscribe(() => { throw new Error('Filter error'); }), Effect.either, Effect.tap((result) => Effect.sync(() => expect(['Left', 'Right']).toContain(result._tag)))); /** * Core client transport contract tests. * * This is the primary export for testing client-side transport implementations. * Every client transport must pass these tests to ensure compatibility with * the event sourcing framework. * * ## What This Tests * * - **Connection Lifecycle**: Establishing connections using Effect's Scope pattern, * monitoring connection state changes, and automatic cleanup when scopes close * - **Message Publishing**: Sending messages with various payload types (strings, numbers, * objects, arrays, null), handling metadata, and graceful error handling * - **Message Subscription**: Receiving published messages, filtering with custom predicates, * supporting multiple concurrent subscriptions, and subscription error handling * - **Connection State Monitoring**: Tracking state transitions (connecting → connected), * providing current state to late subscribers, and handling disconnection scenarios * - **Resource Management**: Scope-based cleanup, handling concurrent operations during * cleanup, and preventing resource leaks * * ## Real Usage Examples * * **WebSocket Implementation:** * See `/packages/eventsourcing-transport-websocket/src/tests/integration/client-server.test.ts` (lines 36-116) * - Shows how to create test context with `WebSocketConnector.connect()` * - Demonstrates random port allocation for test isolation * - Includes proper error mapping and connection state management * * **InMemory Implementation:** * See `/packages/eventsourcing-transport-inmemory/src/tests/integration/client-server.test.ts` (lines 35-126) * - Shows how to handle shared server instances * - Demonstrates direct connection without network protocols * * ## Required Interface * * Your transport setup function must return a `TransportTestContext` that provides: * - `makeConnectedTransport`: Factory function that creates connected transports within Effect scopes * - `simulateDisconnect`: (Optional) Simulate connection failures for testing reconnection behavior * * ## Test Categories * * 1. **Connection Lifecycle (Scope-based)**: Tests Effect Scope integration and automatic cleanup * 2. **Message Publishing**: Tests message sending with various data types and error handling * 3. **Message Subscription**: Tests message receiving, filtering, and concurrent subscriptions * 4. **Connection State Monitoring**: Tests connection state stream behavior and transitions * 5. **Resource Management**: Tests proper cleanup and resource management during scope closure * * @param name - Descriptive name for your transport implementation (e.g., "WebSocket", "HTTP") * @param setup - Function that returns Effect yielding TransportTestContext for your transport * * @example * For complete working examples, see: * - WebSocket: `/packages/eventsourcing-transport-websocket/src/tests/integration/client-server.test.ts` * - InMemory: `/packages/eventsourcing-transport-inmemory/src/tests/integration/client-server.test.ts` * Both demonstrate real transport implementations passing all contract tests. */ export const runClientTransportContractTests = (name, setup) => { describe(`${name} Client Transport Contract`, () => { let context; beforeEach(async () => { context = await Effect.runPromise(setup()); }); afterEach(async () => { // With Scope-based lifecycle, cleanup happens automatically when scope closes // No manual disconnect needed }); describe('Connection Lifecycle (Scope-based)', () => { it('should create connected transport within scope', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(verifyInitialConnectionState)))); }); it('should automatically disconnect when scope closes', async () => { let transport; await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.tap((t) => Effect.sync(() => { transport = t; })), Effect.flatMap(monitorConnectionStateThenInterrupt)))); await Effect.runPromise(Effect.sleep(Duration.millis(50))); expect(transport).toBeDefined(); }); it('should monitor connection state stream', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(monitorConnectionStateHistory)))); }); it('should handle connection errors gracefully', async () => { const result = await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('invalid://bad-url'), Effect.either))); expect(result._tag).toBe('Left'); }); it('should handle connection to non-existent server', async () => { // Try to connect to a non-existent endpoint const result = await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://non-existent-server'), Effect.either))); expect(result._tag).toBe('Left'); // The exact error type depends on the implementation }); }); describe('Message Publishing', () => { it('should publish a simple message', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(publishSimpleMessage)))); }); it('should handle messages with various payload types', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(publishVariousMessageTypes)))); }); it('should handle messages with metadata', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(publishMessageWithMetadata)))); }); it('should handle publishing errors gracefully', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(publishAndHandleError)))); }); }); describe('Message Subscription', () => { it('should receive published messages', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(receivePublishedMessages)))); }); it('should support multiple concurrent subscriptions', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(testMultipleConcurrentSubscriptions)))); }); it('should handle subscription filters correctly', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(testSubscriptionWithComplexFilter)))); }); it('should handle subscription errors gracefully', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap(testSubscriptionErrorHandling)))); }); }); describe('Connection State Monitoring', () => { it('should monitor connection state changes', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap((transport) => pipe(Ref.make(Chunk.empty()), Effect.flatMap((stateHistoryRef) => pipe(transport.connectionState, Stream.take(2), Stream.runForEach((state) => Ref.update(stateHistoryRef, Chunk.append(state))), Effect.fork, Effect.flatMap((stateMonitoring) => pipe(Effect.sleep(Duration.millis(100)), Effect.flatMap(() => context.simulateDisconnect ? pipe(context.simulateDisconnect(), Effect.flatMap(() => Effect.sleep(Duration.millis(50)))) : Effect.void), Effect.flatMap(() => Fiber.interrupt(stateMonitoring)), Effect.flatMap(() => Ref.get(stateHistoryRef)), Effect.tap((stateHistory) => Effect.sync(() => { expect(Chunk.size(stateHistory)).toBeGreaterThan(0); expect(Chunk.unsafeGet(stateHistory, 0)).toBe('connected'); }))))))))))); }); it('should track connection state transitions correctly', async () => { await Effect.runPromise(Effect.scoped(pipe(Ref.make(Chunk.empty()), Effect.flatMap((stateHistoryRef) => pipe(Effect.fork(context.makeConnectedTransport('test://localhost')), Effect.flatMap((connectionFiber) => pipe(Effect.sleep(Duration.millis(5)), Effect.flatMap(() => Fiber.join(connectionFiber)), Effect.flatMap((transport) => pipe(Effect.fork(pipe(transport.connectionState, Stream.runForEach((state) => Ref.update(stateHistoryRef, Chunk.append(state))))), Effect.flatMap((stateCollectorFiber) => pipe(transport.connectionState, Stream.filter((state) => state === 'connected'), Stream.take(1), Stream.runDrain, Effect.flatMap(() => Effect.sleep(Duration.millis(10))), Effect.flatMap(() => Fiber.interrupt(stateCollectorFiber)), Effect.flatMap(() => Ref.get(stateHistoryRef)), Effect.tap((stateHistory) => Effect.sync(() => { const observedStates = Chunk.toReadonlyArray(stateHistory); expect(observedStates.length).toBeGreaterThanOrEqual(1); expect(observedStates[observedStates.length - 1]).toBe('connected'); if (observedStates.length > 1) { const connectingIndex = observedStates.indexOf('connecting'); const connectedIndex = observedStates.indexOf('connected'); if (connectingIndex !== -1 && connectedIndex !== -1) { expect(connectingIndex).toBeLessThan(connectedIndex); } } }))))))))))))); }); it('should provide current state when subscribing after connection', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap((transport) => pipe(transport.connectionState, Stream.filter((state) => state === 'connected'), Stream.take(1), Stream.runDrain, Effect.flatMap(() => pipe(Ref.make(Chunk.empty()), Effect.flatMap((stateHistoryRef) => pipe(Effect.fork(pipe(transport.connectionState, Stream.take(3), Stream.runForEach((state) => Ref.update(stateHistoryRef, Chunk.append(state))))), Effect.flatMap((lateSubscriberFiber) => pipe(Effect.sleep(Duration.millis(50)), Effect.flatMap(() => Fiber.interrupt(lateSubscriberFiber)), Effect.flatMap(() => Ref.get(stateHistoryRef)), Effect.tap((stateHistory) => Effect.sync(() => { const states = Chunk.toReadonlyArray(stateHistory); expect(states[0]).toBe('connected'); expect(states.length).toBe(1); expect(states).toEqual(['connected']); }))))))))))))); }); }); describe('Resource Management', () => { it('should handle Scope-based cleanup', async () => { let transport; await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.tap((t) => Effect.sync(() => { transport = t; })), Effect.flatMap((t) => pipe(t.subscribe(), Effect.flatMap((subscription) => pipe(subscription, Stream.take(1), Stream.runCollect, Effect.fork, Effect.flatMap((messagePromise) => pipe(t.publish({ id: 'cleanup-test', type: 'test', payload: 'cleanup', }), Effect.flatMap(() => Fiber.join(messagePromise))))))))))); // After scope closes, transport should be cleaned up expect(transport).toBeDefined(); // We can't test the transport directly after scope closes since it's been cleaned up // But the test passing means cleanup worked correctly }); it('should handle concurrent operations during cleanup', async () => { await Effect.runPromise(Effect.scoped(pipe(context.makeConnectedTransport('test://localhost'), Effect.flatMap((transport) => { const operations = Array.from({ length: 5 }, (_, i) => Effect.fork(transport.publish({ id: `concurrent-${i}`, type: 'concurrent-test', payload: { index: i }, }))); return pipe(Effect.all(operations), Effect.flatMap((fibers) => pipe(Effect.sleep(Duration.millis(10)), Effect.flatMap(() => Effect.all(fibers.map(Fiber.interrupt)))))); })))); }); }); }); }; //# sourceMappingURL=client-transport-contract-tests.js.map