@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
JavaScript
/**
 * 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