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

312 lines 24.5 kB
/** * Client-Server Contract Tests * * Tests client-server transport behaviors that apply to any transport implementation * that supports bidirectional communication, multiple clients, and broadcasting. * * These tests verify the interaction between client and server transport instances, * including connection management, message broadcasting, and resource cleanup. */ import { describe, test, expect, beforeEach, afterEach } from '@codeforbreakfast/buntest'; import { Effect, Stream, Scope, pipe, Option, Exit } from 'effect'; // ============================================================================= // Contract Tests Implementation // ============================================================================= /** * Core client-server contract tests. * * This is the primary export for testing bidirectional communication between client and server * transport instances. These tests verify the interaction patterns, connection management, * and message flow between paired transport endpoints. * * ## What This Tests * * - **Connection Management**: Establishing client-server connections, handling multiple clients * connecting to the same server, and proper connection lifecycle management * - **Message Communication**: Client-to-server message publishing, server-to-client broadcasting, * bidirectional request-response patterns, and message filtering on the client side * - **Connection Lifecycle**: Graceful client disconnection, server shutdown handling, * resource cleanup when scopes close, and connection state synchronization * - **Error Handling**: Managing malformed messages, connection errors, and ensuring * graceful degradation during communication failures * * ## Real Usage Examples * * **WebSocket Integration:** * See `/packages/eventsourcing-transport-websocket/src/tests/integration/client-server.test.ts` (lines 38-95) * - Shows `TransportPair` implementation with random port allocation * - Demonstrates real WebSocket server/client coordination * - Includes proper error mapping and connection state management * - Line 116: `runClientServerContractTests('WebSocket', createWebSocketTestContext)` * * **InMemory Integration:** * See `/packages/eventsourcing-transport-inmemory/src/tests/integration/client-server.test.ts` (lines 37-104) * - Shows shared server instance pattern for synchronized testing * - Demonstrates direct connection without network protocols * - Line 126: `runClientServerContractTests('InMemory', createInMemoryTestContext)` * * Both implementations include transport-specific tests alongside the standard contracts. * * ## Required Interface * * Your setup function must return a `ClientServerTestContext` that provides: * - `makeTransportPair`: Factory that creates paired client/server transports that can communicate * - `waitForConnectionState`: Utility to wait for specific connection states * - `collectMessages`: Utility to collect messages from streams with timeout * - `makeTestMessage`: Factory for creating standardized test messages * * ## Test Categories * * 1. **Connection Management**: Tests client-server connection establishment and multi-client scenarios * 2. **Message Communication**: Tests all forms of client-server communication patterns * 3. **Connection Lifecycle**: Tests graceful shutdown and cleanup scenarios * 4. **Error Handling**: Tests resilience to malformed messages and connection errors * * ## Transport Pair Requirements * * Your `makeTransportPair` function must return an object with: * - `makeServer`: Creates a server transport within a scope * - `makeClient`: Creates a client transport that connects to the server within a scope * * Both server and client must be automatically cleaned up when their respective scopes close. * * ## Server Interface Requirements * * Your server implementation must provide: * - `connections`: Stream that emits server-side connection objects when clients connect * - `broadcast`: Function to send messages to all connected clients * * ## Client Interface Requirements * * Your client implementation must provide: * - `connectionState`: Stream that emits connection state changes * - `publish`: Function to send messages to the server * - `subscribe`: Function to subscribe to messages from the server (with optional filtering) * * ## Connection Object Requirements * * Server connection objects must provide: * - `id`: Unique identifier for the connection * - `transport`: Client transport interface for server-side communication * * @param name - Descriptive name for your transport integration (e.g., "WebSocket Integration") * @param setup - Function that returns Effect yielding ClientServerTestContext for your paired transports * * @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 client-server transport implementations passing all contract tests. */ export const runClientServerContractTests = (name, setup) => { describe(`${name} Client-Server Contract`, () => { let context; beforeEach(async () => { context = await Effect.runPromise(setup()); }); afterEach(async () => { // With Scope-based lifecycle, cleanup happens automatically when scope closes }); describe('Connection Management', () => { test('should establish basic client-server connection', async () => { const pair = context.makeTransportPair(); const program = pipe(pair.makeServer(), Effect.flatMap(() => Effect.sleep(100)), Effect.flatMap(() => pair.makeClient()), Effect.flatMap((client) => Effect.flatMap(context.waitForConnectionState(client, 'connected'), () => Effect.flatMap(Stream.runHead(Stream.take(Stream.filter(client.connectionState, (state) => state === 'connected'), 1)), (clientState) => { if (Option.isSome(clientState)) { return Effect.sync(() => expect(clientState.value).toBe('connected')); } else { return Effect.fail(new Error('Expected client to reach connected state')); } })))); await Effect.runPromise(Effect.scoped(program)); }); test('should handle multiple clients connecting to same server', async () => { const pair = context.makeTransportPair(); const program = pipe(pair.makeServer(), Effect.flatMap((server) => Effect.flatMap(Effect.sleep(100), () => Effect.flatMap(Effect.all([pair.makeClient(), pair.makeClient(), pair.makeClient()]), ([client1, client2, client3]) => Effect.flatMap(Effect.all([ context.waitForConnectionState(client1, 'connected'), context.waitForConnectionState(client2, 'connected'), context.waitForConnectionState(client3, 'connected'), ]), () => Effect.tap(Effect.timeout(Stream.runCollect(Stream.take(server.connections, 3)), 5000), (connections) => Effect.sync(() => expect(Array.from(connections)).toHaveLength(3)))))))); await Effect.runPromise(Effect.scoped(program)); }); }); describe('Message Communication', () => { test('should support client-to-server message publishing', async () => { const pair = context.makeTransportPair(); const testMessage = context.makeTestMessage('test.message', { data: 'hello server' }); const program = pipe(pair.makeServer(), Effect.flatMap((server) => Effect.flatMap(Effect.sleep(100), () => Effect.flatMap(pair.makeClient(), (client) => Effect.flatMap(context.waitForConnectionState(client, 'connected'), () => Effect.flatMap(Stream.runHead(Stream.take(server.connections, 1)), (serverConnection) => { if (!Option.isSome(serverConnection)) { return Effect.fail(new Error('Expected server connection to be available')); } const connection = serverConnection.value; return Effect.flatMap(connection.transport.subscribe(), (messageStream) => Effect.tap(Effect.flatMap(client.publish(testMessage), () => context.collectMessages(messageStream, 1)), (receivedMessages) => Effect.sync(() => { expect(receivedMessages).toHaveLength(1); expect(receivedMessages[0]?.type).toBe('test.message'); expect(receivedMessages[0]?.payload).toBe(JSON.stringify({ data: 'hello server' })); }))); })))))); await Effect.runPromise(Effect.scoped(program)); }); test('should support server-to-client broadcasting', async () => { const verifyBroadcast = ([client1Received, client2Received]) => Effect.sync(() => { expect(client1Received).toHaveLength(1); expect(client1Received[0]?.type).toBe('server.broadcast'); expect(client1Received[0]?.payload).toBe(JSON.stringify({ announcement: 'hello all clients' })); expect(client2Received).toHaveLength(1); expect(client2Received[0]?.type).toBe('server.broadcast'); expect(client2Received[0]?.payload).toBe(JSON.stringify({ announcement: 'hello all clients' })); }); const broadcastAndCollect = (server, client1Messages, client2Messages) => { const broadcastMessage = context.makeTestMessage('server.broadcast', { announcement: 'hello all clients', }); return pipe(server.broadcast(broadcastMessage), Effect.flatMap(() => Effect.all([ context.collectMessages(client1Messages, 1), context.collectMessages(client2Messages, 1), ])), Effect.tap(verifyBroadcast)); }; const subscribeAndBroadcast = (server, client1, client2) => pipe(Effect.all([client1.subscribe(), client2.subscribe()]), Effect.flatMap(([client1Messages, client2Messages]) => broadcastAndCollect(server, client1Messages, client2Messages))); const waitForClientsAndBroadcast = (server, client1, client2) => pipe(Effect.all([ context.waitForConnectionState(client1, 'connected'), context.waitForConnectionState(client2, 'connected'), ]), Effect.flatMap(() => subscribeAndBroadcast(server, client1, client2))); const createClientsAndBroadcast = (server, pair) => pipe(Effect.all([pair.makeClient(), pair.makeClient()]), Effect.flatMap(([client1, client2]) => waitForClientsAndBroadcast(server, client1, client2))); const setupServerAndBroadcast = (pair) => pipe(pair.makeServer(), Effect.flatMap((server) => pipe(Effect.sleep(100), Effect.flatMap(() => createClientsAndBroadcast(server, pair))))); const program = pipe(Effect.sync(() => context.makeTransportPair()), Effect.flatMap(setupServerAndBroadcast)); await Effect.runPromise(Effect.scoped(program)); }); test('should support bidirectional communication', async () => { const verifyServerReceived = (serverReceivedMessages) => Effect.sync(() => { expect(serverReceivedMessages[0]?.payload).toBe(JSON.stringify({ query: 'ping' })); }); const verifyClientReceived = (clientReceivedMessages) => Effect.sync(() => { expect(clientReceivedMessages[0]?.payload).toBe(JSON.stringify({ result: 'pong' })); }); const sendServerResponseAndVerify = (connection, clientMessages) => { const serverResponse = context.makeTestMessage('server.response', { result: 'pong' }); return pipe(connection.transport.publish(serverResponse), Effect.flatMap(() => context.collectMessages(clientMessages, 1)), Effect.tap(verifyClientReceived)); }; const publishClientRequestAndVerify = (client, serverMessages, connection, clientMessages) => { const clientMessage = context.makeTestMessage('client.request', { query: 'ping' }); return pipe(client.publish(clientMessage), Effect.flatMap(() => context.collectMessages(serverMessages, 1)), Effect.tap(verifyServerReceived), Effect.flatMap(() => sendServerResponseAndVerify(connection, clientMessages))); }; const subscribeAndCommunicate = (client, connection) => pipe(Effect.all([client.subscribe(), connection.transport.subscribe()]), Effect.flatMap(([clientMessages, serverMessages]) => publishClientRequestAndVerify(client, serverMessages, connection, clientMessages))); const handleServerConnection = (client) => (serverConnection) => { if (!Option.isSome(serverConnection)) { return Effect.fail(new Error('Expected server connection to be available')); } const connection = serverConnection.value; return subscribeAndCommunicate(client, connection); }; const getConnectionAndCommunicate = (server, client) => pipe(server.connections, Stream.take(1), Stream.runHead, Effect.flatMap(handleServerConnection(client))); const waitAndCommunicate = (server, client) => pipe(context.waitForConnectionState(client, 'connected'), Effect.flatMap(() => getConnectionAndCommunicate(server, client))); const createClientAndCommunicate = (server, pair) => pipe(pair.makeClient(), Effect.flatMap((client) => waitAndCommunicate(server, client))); const setupServerAndCommunicate = (pair) => pipe(pair.makeServer(), Effect.flatMap((server) => pipe(Effect.sleep(100), Effect.flatMap(() => createClientAndCommunicate(server, pair))))); const program = pipe(Effect.sync(() => context.makeTransportPair()), Effect.flatMap(setupServerAndCommunicate)); await Effect.runPromise(Effect.scoped(program)); }); test('should filter messages correctly on client side', async () => { const verifyFilteredMessages = (receivedMessages) => Effect.sync(() => { expect(receivedMessages).toHaveLength(2); expect(receivedMessages[0]?.type).toBe('important.alert'); expect(receivedMessages[0]?.payload).toBe(JSON.stringify({ data: 2 })); expect(receivedMessages[1]?.type).toBe('important.notification'); expect(receivedMessages[1]?.payload).toBe(JSON.stringify({ data: 4 })); }); const broadcastMessagesAndCollect = (server, filteredMessages) => pipe(Effect.all([ server.broadcast(context.makeTestMessage('normal.message', { data: 1 })), server.broadcast(context.makeTestMessage('important.alert', { data: 2 })), server.broadcast(context.makeTestMessage('debug.info', { data: 3 })), server.broadcast(context.makeTestMessage('important.notification', { data: 4 })), ]), Effect.flatMap(() => context.collectMessages(filteredMessages, 2)), Effect.tap(verifyFilteredMessages)); const subscribeAndBroadcast = (server, client) => pipe(client.subscribe((msg) => msg.type.startsWith('important.')), Effect.flatMap((filteredMessages) => broadcastMessagesAndCollect(server, filteredMessages))); const waitAndTest = (server, client) => pipe(context.waitForConnectionState(client, 'connected'), Effect.flatMap(() => subscribeAndBroadcast(server, client))); const createClientAndTest = (server, pair) => pipe(pair.makeClient(), Effect.flatMap((client) => waitAndTest(server, client))); const setupServerAndTest = (pair) => pipe(pair.makeServer(), Effect.flatMap((server) => pipe(Effect.sleep(100), Effect.flatMap(() => createClientAndTest(server, pair))))); const program = pipe(Effect.sync(() => context.makeTransportPair()), Effect.flatMap(setupServerAndTest)); await Effect.runPromise(Effect.scoped(program)); }); }); describe('Connection Lifecycle', () => { test('should handle graceful client disconnection', async () => { const verifyDisconnectedState = (finalClientState) => { if (Option.isSome(finalClientState)) { return Effect.sync(() => expect(finalClientState.value).toBe('disconnected')); } else { return Effect.fail(new Error('Expected final client state to be available')); } }; const waitForDisconnectedState = (connection) => pipe(connection.transport.connectionState, Stream.filter((state) => state === 'disconnected'), Stream.take(1), Stream.runHead, Effect.timeout(2000), Effect.flatMap(verifyDisconnectedState)); const closeScopeAndVerify = (clientScope, connection) => pipe(Scope.close(clientScope, Exit.void), Effect.flatMap(() => Effect.sleep(100)), Effect.flatMap(() => waitForDisconnectedState(connection))); const handleServerConnection = (clientScope) => (serverConnection) => { if (!Option.isSome(serverConnection)) { return Effect.fail(new Error('Expected server connection to be available')); } return closeScopeAndVerify(clientScope, serverConnection.value); }; const getConnectionAndDisconnect = (server, clientScope) => pipe(server.connections, Stream.take(1), Stream.runHead, Effect.flatMap(handleServerConnection(clientScope))); const waitAndDisconnect = (client, server, clientScope) => pipe(context.waitForConnectionState(client, 'connected'), Effect.flatMap(() => getConnectionAndDisconnect(server, clientScope))); const createClientAndDisconnect = (clientScope, server, pair) => pipe(Scope.extend(pair.makeClient(), clientScope), Effect.flatMap((client) => waitAndDisconnect(client, server, clientScope))); const createScopeAndTest = (server, pair) => pipe(Scope.make(), Effect.flatMap((clientScope) => createClientAndDisconnect(clientScope, server, pair))); const setupServerAndTest = (pair) => pipe(pair.makeServer(), Effect.flatMap((server) => pipe(Effect.sleep(100), Effect.flatMap(() => createScopeAndTest(server, pair))))); const program = pipe(Effect.sync(() => context.makeTransportPair()), Effect.flatMap(setupServerAndTest)); await Effect.runPromise(Effect.scoped(program)); }); test('should handle server shutdown gracefully', async () => { const verifyDisconnectedState = (disconnectedState) => { if (Option.isSome(disconnectedState)) { return Effect.sync(() => expect(disconnectedState.value).toBe('disconnected')); } else { return Effect.fail(new Error('Expected disconnected state to be available')); } }; const waitForDisconnection = (client) => pipe(client.connectionState, Stream.filter((state) => state === 'disconnected'), Stream.take(1), Stream.runHead, Effect.timeout(5000), Effect.flatMap(verifyDisconnectedState)); const closeServerAndVerify = (serverScope, client) => pipe(Scope.close(serverScope, Exit.void), Effect.flatMap(() => waitForDisconnection(client))); const waitAndCloseServer = (client, serverScope) => pipe(context.waitForConnectionState(client, 'connected'), Effect.flatMap(() => closeServerAndVerify(serverScope, client))); const createClientAndTest = (serverScope, pair) => pipe(pair.makeClient(), Effect.flatMap((client) => waitAndCloseServer(client, serverScope))); const setupServerAndClient = (serverScope, pair) => pipe(Scope.extend(pair.makeServer(), serverScope), Effect.flatMap(() => Effect.sleep(100)), Effect.flatMap(() => createClientAndTest(serverScope, pair))); const createScopeAndTest = (pair) => pipe(Scope.make(), Effect.flatMap((serverScope) => setupServerAndClient(serverScope, pair))); const program = pipe(Effect.sync(() => context.makeTransportPair()), Effect.flatMap(createScopeAndTest)); await Effect.runPromise(Effect.scoped(program)); }); test('should clean up resources when scope closes', async () => { const verifyConnectionCount = (connections) => Effect.sync(() => expect(Array.from(connections)).toHaveLength(2)); const collectAndVerifyConnections = (server) => pipe(server.connections, Stream.take(2), Stream.runCollect, Effect.tap(verifyConnectionCount)); const waitForClientsAndVerify = (server, client1, client2) => pipe(Effect.all([ context.waitForConnectionState(client1, 'connected'), context.waitForConnectionState(client2, 'connected'), ]), Effect.flatMap(() => collectAndVerifyConnections(server))); const createClientsAndVerify = (server, pair) => pipe(Effect.all([pair.makeClient(), pair.makeClient()]), Effect.flatMap(([client1, client2]) => waitForClientsAndVerify(server, client1, client2))); const setupServerAndClients = (pair) => pipe(pair.makeServer(), Effect.flatMap((server) => pipe(Effect.sleep(100), Effect.flatMap(() => createClientsAndVerify(server, pair))))); const program = pipe(Effect.sync(() => context.makeTransportPair()), Effect.flatMap(setupServerAndClients)); await Effect.runPromise(Effect.scoped(program)); }); }); describe('Error Handling', () => { test('should handle malformed messages gracefully', async () => { const verifyReceivedMessage = (receivedMessages) => Effect.sync(() => expect(receivedMessages[0]?.type).toBe('valid.message')); const publishAndVerify = (client, messageStream) => { const validMessage = context.makeTestMessage('valid.message', { data: 'good' }); return pipe(client.publish(validMessage), Effect.flatMap(() => context.collectMessages(messageStream, 1)), Effect.tap(verifyReceivedMessage)); }; const subscribeAndPublish = (connection, client) => pipe(connection.transport.subscribe(), Effect.flatMap((messageStream) => publishAndVerify(client, messageStream))); const handleServerConnection = (client) => (serverConnection) => { if (!Option.isSome(serverConnection)) { return Effect.fail(new Error('Expected server connection to be available')); } const connection = serverConnection.value; return subscribeAndPublish(connection, client); }; const getConnectionAndPublish = (server, client) => pipe(server.connections, Stream.take(1), Stream.runHead, Effect.flatMap(handleServerConnection(client))); const waitAndPublish = (server, client) => pipe(context.waitForConnectionState(client, 'connected'), Effect.flatMap(() => getConnectionAndPublish(server, client))); const createClientAndPublish = (server, pair) => pipe(pair.makeClient(), Effect.flatMap((client) => waitAndPublish(server, client))); const setupServerAndTest = (pair) => pipe(pair.makeServer(), Effect.flatMap((server) => pipe(Effect.sleep(100), Effect.flatMap(() => createClientAndPublish(server, pair))))); const program = pipe(Effect.sync(() => context.makeTransportPair()), Effect.flatMap(setupServerAndTest)); await Effect.runPromise(Effect.scoped(program)); }); }); }); }; //# sourceMappingURL=client-server-contract-tests.js.map