@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
267 lines • 21.7 kB
JavaScript
/**
 * Server Contract Tests
 *
 * Tests server-only transport behaviors that apply to any transport implementation
 * that supports multiple client connections, broadcasting, and server-side resource management.
 *
 * These tests verify server-specific behaviors like connection tracking, broadcasting to multiple clients,
 * and proper cleanup when clients disconnect or server shuts down.
 */
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { Effect, Stream, Scope, pipe, Option, Exit, Fiber, Duration, Chunk, Ref } from 'effect';
// =============================================================================
// Contract Tests Implementation
// =============================================================================
/**
 * Core server transport contract tests.
 *
 * This is the primary export for testing server-side transport implementations that support
 * multiple client connections, broadcasting, and server-side resource management.
 * Every server transport must pass these tests to ensure proper multi-client behavior.
 *
 * ## What This Tests
 *
 * - **Connection Management**: Tracking multiple client connections, unique connection IDs,
 *   connection counting, and automatic cleanup when clients disconnect
 * - **Message Broadcasting**: Sending messages to all connected clients simultaneously,
 *   ensuring disconnected clients don't receive messages, and handling broadcast errors gracefully
 * - **Individual Connection Communication**: Direct communication with specific client connections,
 *   receiving messages from individual clients, and per-connection message filtering
 * - **Resource Management**: Proper cleanup when server shuts down, handling concurrent
 *   operations during shutdown, and connection lifecycle tracking
 *
 * ## Real Usage Examples
 *
 * **WebSocket Server Implementation:**
 * See `/packages/eventsourcing-transport-websocket/src/tests/integration/client-server.test.ts` (lines 45-74)
 * - Uses `WebSocketAcceptor.make({ port, host })` with random port allocation
 * - Shows proper server startup with scope-based cleanup
 * - Demonstrates connection stream mapping to client transport interface
 *
 * **InMemory Server Implementation:**
 * See `/packages/eventsourcing-transport-inmemory/src/tests/integration/client-server.test.ts` (lines 44-78)
 * - Uses `InMemoryAcceptor.make()` for direct in-memory connections
 * - Shows shared server instance pattern for coordinated client-server testing
 * - Demonstrates server lifecycle management without network concerns
 *
 * Note: These implementations focus on client-server integration tests.
 * Server-only contract tests would need dedicated server test implementations.
 *
 * ## Required Interface
 *
 * Your server setup function must return a `ServerTestContext` that provides:
 * - `makeServerFactory`: Factory that creates server and mock client instances
 * - `waitForConnectionCount`: Utility to wait for expected number of connections
 * - `collectConnections`: Utility to collect connections from the server's connection stream
 * - `makeTestMessage`: Factory for creating test messages
 *
 * ## Test Categories
 *
 * 1. **Connection Management**: Tests tracking multiple clients, connection counting, and cleanup
 * 2. **Message Broadcasting**: Tests server-to-all-clients communication and error handling
 * 3. **Individual Connection Communication**: Tests per-client communication and message filtering
 * 4. **Resource Management**: Tests proper cleanup during server shutdown and client disconnection
 *
 * ## Server Interface Requirements
 *
 * Your server implementation must provide:
 * - `connections`: Stream of connected clients (emits when clients connect)
 * - `broadcast`: Function to send messages to all connected clients
 * - `connectionCount`: Function to get current number of connected clients
 *
 * ## Mock Client Requirements
 *
 * Your mock client factory must create clients that:
 * - Connect to the server automatically when created within a scope
 * - Expose connection state as a stream
 * - Support publishing and subscribing to messages
 * - Disconnect cleanly when their scope closes
 *
 * @param name - Descriptive name for your server transport implementation (e.g., "WebSocket Server")
 * @param setup - Function that returns Effect yielding ServerTestContext for your server 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 server implementations and client-server coordination.
 */
export const runServerTransportContractTests = (name, setup) => {
    describe(`${name} Server Transport 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 track client connections', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => pipe(server.connectionCount(), Effect.tap((initialCount) => Effect.sync(() => expect(initialCount).toBe(0))), Effect.flatMap(() => factory.makeMockClient()), Effect.flatMap(() => context.waitForConnectionCount(server, 1)), Effect.flatMap(() => server.connectionCount()), Effect.tap((finalCount) => Effect.sync(() => expect(finalCount).toBe(1))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
            test('should track multiple client connections', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => pipe(Effect.all([
                    factory.makeMockClient(),
                    factory.makeMockClient(),
                    factory.makeMockClient(),
                ]), Effect.flatMap(() => context.waitForConnectionCount(server, 3)), Effect.flatMap(() => server.connectionCount()), Effect.tap((connectionCount) => Effect.sync(() => expect(connectionCount).toBe(3))), Effect.flatMap(() => context.collectConnections(server.connections, 3)), Effect.tap((connections) => Effect.sync(() => {
                    expect(connections).toHaveLength(3);
                    // Each connection should have a unique ID
                    const connectionIds = connections.map((conn) => conn.id);
                    const uniqueIds = new Set(connectionIds);
                    expect(uniqueIds.size).toBe(3);
                })))))));
                await Effect.runPromise(Effect.scoped(program));
            });
            test('should remove connections when clients disconnect', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => pipe(Effect.all([factory.makeMockClient(), factory.makeMockClient()]), Effect.flatMap(([client1]) => pipe(context.waitForConnectionCount(server, 2), Effect.flatMap(() => client1.disconnect()), Effect.flatMap(() => Effect.sleep(Duration.millis(100))), Effect.flatMap(() => context.waitForConnectionCount(server, 1)), Effect.flatMap(() => server.connectionCount()), Effect.tap((finalCount) => Effect.sync(() => expect(finalCount).toBe(1))))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
            test('should handle client connection state changes', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => pipe(factory.makeMockClient(), Effect.flatMap((client) => pipe(server.connections, Stream.take(1), Stream.runHead, Effect.flatMap((serverConnection) => {
                    if (!Option.isSome(serverConnection)) {
                        return Effect.fail(new Error('Expected server connection to be available'));
                    }
                    const connection = serverConnection.value;
                    return pipe(Ref.make(Chunk.empty()), Effect.flatMap((stateHistoryRef) => pipe(Effect.fork(pipe(connection.connectionState, Stream.take(2), Stream.runForEach((state) => Ref.update(stateHistoryRef, Chunk.append(state))))), Effect.flatMap((stateMonitor) => pipe(Effect.sleep(Duration.millis(50)), Effect.flatMap(() => client.disconnect()), Effect.flatMap(() => Effect.sleep(Duration.millis(100))), Effect.flatMap(() => Fiber.interrupt(stateMonitor)), Effect.flatMap(() => Ref.get(stateHistoryRef)), Effect.tap((stateHistory) => Effect.sync(() => {
                        expect(Chunk.size(stateHistory)).toBeGreaterThan(0);
                        expect(Chunk.unsafeGet(stateHistory, 0)).toBe('connected');
                    })))))));
                }))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
        });
        describe('Message Broadcasting', () => {
            test('should broadcast messages to all connected clients', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => pipe(Effect.all([
                    factory.makeMockClient(),
                    factory.makeMockClient(),
                    factory.makeMockClient(),
                ]), Effect.flatMap(([client1, client2, client3]) => pipe(context.waitForConnectionCount(server, 3), Effect.flatMap(() => Effect.all([client1.subscribe(), client2.subscribe(), client3.subscribe()])), Effect.flatMap(([client1Messages, client2Messages, client3Messages]) => {
                    const broadcastMessage = context.makeTestMessage('server.announcement', {
                        message: 'hello all clients',
                        timestamp: Date.now(),
                    });
                    return pipe(server.broadcast(broadcastMessage), Effect.flatMap(() => Effect.sleep(Duration.millis(100))), Effect.flatMap(() => Effect.all([
                        pipe(client1Messages, Stream.take(1), Stream.runCollect, Effect.map(Chunk.toReadonlyArray)),
                        pipe(client2Messages, Stream.take(1), Stream.runCollect, Effect.map(Chunk.toReadonlyArray)),
                        pipe(client3Messages, Stream.take(1), Stream.runCollect, Effect.map(Chunk.toReadonlyArray)),
                    ])), Effect.tap(([msg1, msg2, msg3]) => Effect.sync(() => {
                        expect(msg1).toHaveLength(1);
                        expect(msg1[0]?.type).toBe('server.announcement');
                        expect(msg2).toHaveLength(1);
                        expect(msg2[0]?.type).toBe('server.announcement');
                        expect(msg3).toHaveLength(1);
                        expect(msg3[0]?.type).toBe('server.announcement');
                    })));
                }))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
            test('should not broadcast to disconnected clients', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => pipe(Effect.all([factory.makeMockClient(), factory.makeMockClient()]), Effect.flatMap(([client1, client2]) => pipe(context.waitForConnectionCount(server, 2), Effect.flatMap(() => client2.subscribe()), Effect.flatMap((client2Messages) => pipe(client1.disconnect(), Effect.flatMap(() => context.waitForConnectionCount(server, 1)), Effect.flatMap(() => {
                    const broadcastMessage = context.makeTestMessage('server.test', {
                        data: 'test',
                    });
                    return server.broadcast(broadcastMessage);
                }), Effect.flatMap(() => Effect.sleep(Duration.millis(100))), Effect.flatMap(() => pipe(client2Messages, Stream.take(1), Stream.runCollect, Effect.map(Chunk.toReadonlyArray))), Effect.tap((msg2) => Effect.sync(() => {
                    expect(msg2).toHaveLength(1);
                    expect(msg2[0]?.type).toBe('server.test');
                })))))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
            test('should handle broadcast errors gracefully', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => {
                    const broadcastMessage = context.makeTestMessage('server.empty', {
                        data: 'no clients',
                    });
                    return pipe(server.broadcast(broadcastMessage), Effect.flatMap(() => server.connectionCount()), Effect.tap((connectionCount) => Effect.sync(() => expect(connectionCount).toBe(0))));
                }))));
                await Effect.runPromise(Effect.scoped(program));
            });
        });
        describe('Individual Connection Communication', () => {
            test('should support direct communication with individual connections', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => pipe(Effect.all([factory.makeMockClient(), factory.makeMockClient()]), Effect.flatMap(([client1, client2]) => pipe(context.waitForConnectionCount(server, 2), Effect.flatMap(() => context.collectConnections(server.connections, 2)), Effect.flatMap((connections) => {
                    const [connection1] = connections;
                    return pipe(Effect.all([client1.subscribe(), client2.subscribe()]), Effect.flatMap(([client1Messages, client2Messages]) => {
                        const directMessage = context.makeTestMessage('server.direct', {
                            target: 'client1',
                        });
                        return pipe(connection1.publish(directMessage), Effect.flatMap(() => Effect.sleep(Duration.millis(100))), Effect.flatMap(() => pipe(client1Messages, Stream.take(1), Stream.runCollect, Effect.map(Chunk.toReadonlyArray))), Effect.flatMap((msg1) => pipe(Effect.sync(() => {
                            expect(msg1).toHaveLength(1);
                            expect(msg1[0]?.type).toBe('server.direct');
                        }), Effect.flatMap(() => pipe(client2Messages, Stream.take(1), Stream.runCollect, Effect.map(Chunk.toReadonlyArray), Effect.timeout(Duration.millis(200)), Effect.either)), Effect.tap((msg2Result) => Effect.sync(() => {
                            expect(msg2Result._tag).toBe('Left');
                        })))));
                    }));
                }))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
            test('should receive messages from individual clients', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => pipe(factory.makeMockClient(), Effect.flatMap((client) => pipe(context.waitForConnectionCount(server, 1), Effect.flatMap(() => pipe(server.connections, Stream.take(1), Stream.runHead)), Effect.flatMap((serverConnection) => {
                    if (!Option.isSome(serverConnection)) {
                        return Effect.fail(new Error('Expected server connection to be available'));
                    }
                    const connection = serverConnection.value;
                    return pipe(connection.subscribe(), Effect.flatMap((serverMessages) => {
                        const clientMessage = context.makeTestMessage('client.request', {
                            action: 'ping',
                        });
                        return pipe(client.publish(clientMessage), Effect.flatMap(() => pipe(serverMessages, Stream.take(1), Stream.runCollect, Effect.map(Chunk.toReadonlyArray))), Effect.tap((receivedMessages) => Effect.sync(() => {
                            expect(receivedMessages).toHaveLength(1);
                            expect(receivedMessages[0]?.type).toBe('client.request');
                            expect(receivedMessages[0]?.payload).toBe(JSON.stringify({ action: 'ping' }));
                        })));
                    }));
                }))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
        });
        describe('Resource Management', () => {
            test('should clean up all connections when server shuts down', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(Scope.make(), Effect.flatMap((serverScope) => pipe(Scope.extend(factory.makeServer(), serverScope), Effect.flatMap((server) => pipe(Effect.all([factory.makeMockClient(), factory.makeMockClient()]), Effect.flatMap(([client1, client2]) => pipe(context.waitForConnectionCount(server, 2), Effect.flatMap(() => pipe(Effect.all([
                    Ref.make(Chunk.empty()),
                    Ref.make(Chunk.empty()),
                ]), Effect.flatMap(([client1StateHistoryRef, client2StateHistoryRef]) => pipe(Effect.all([
                    Effect.fork(pipe(client1.connectionState, Stream.runForEach((state) => Ref.update(client1StateHistoryRef, Chunk.append(state))))),
                    Effect.fork(pipe(client2.connectionState, Stream.runForEach((state) => Ref.update(client2StateHistoryRef, Chunk.append(state))))),
                ]), Effect.flatMap(([stateMonitor1, stateMonitor2]) => pipe(Effect.sleep(Duration.millis(50)), Effect.flatMap(() => Scope.close(serverScope, Exit.void)), Effect.flatMap(() => Effect.sleep(Duration.millis(200))), Effect.flatMap(() => Effect.all([
                    Fiber.interrupt(stateMonitor1),
                    Fiber.interrupt(stateMonitor2),
                ])), Effect.flatMap(() => Effect.all([
                    Ref.get(client1StateHistoryRef),
                    Ref.get(client2StateHistoryRef),
                ])), Effect.tap(([client1StateHistory, client2StateHistory]) => Effect.sync(() => {
                    expect(Chunk.toReadonlyArray(client1StateHistory)).toContain('disconnected');
                    expect(Chunk.toReadonlyArray(client2StateHistory)).toContain('disconnected');
                })))))))))))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
            test('should handle concurrent client operations during server shutdown', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(Scope.make(), Effect.flatMap((serverScope) => pipe(Scope.extend(factory.makeServer(), serverScope), Effect.flatMap((server) => pipe(factory.makeMockClient(), Effect.flatMap((client) => pipe(context.waitForConnectionCount(server, 1), Effect.flatMap(() => {
                    const operations = Array.from({ length: 5 }, (_, i) => Effect.fork(client.publish(context.makeTestMessage(`concurrent-${i}`, { index: i }))));
                    return pipe(Effect.all(operations), Effect.flatMap((fibers) => pipe(Effect.fork(server.broadcast(context.makeTestMessage('server.shutdown', {
                        message: 'shutting down',
                    }))), Effect.flatMap((broadcastFiber) => pipe(Effect.sleep(Duration.millis(10)), Effect.flatMap(() => Scope.close(serverScope, Exit.void)), Effect.flatMap(() => Effect.all(fibers.map(Fiber.interrupt))), Effect.flatMap(() => Fiber.interrupt(broadcastFiber)))))));
                }))))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
            test('should track connection lifecycle properly', async () => {
                const program = pipe(Effect.sync(() => context.makeServerFactory()), Effect.flatMap((factory) => pipe(factory.makeServer(), Effect.flatMap((server) => pipe(Scope.make(), Effect.flatMap((clientScope) => pipe(Scope.extend(factory.makeMockClient(), clientScope), Effect.flatMap(() => context.waitForConnectionCount(server, 1)), Effect.flatMap(() => pipe(server.connections, Stream.take(1), Stream.runHead)), Effect.flatMap((serverConnection) => {
                    if (!Option.isSome(serverConnection)) {
                        return Effect.fail(new Error('Expected server connection to be available'));
                    }
                    const connection = serverConnection.value;
                    return pipe(connection.connectionState, Stream.take(1), Stream.runHead, Effect.flatMap((initialState) => pipe(Effect.sync(() => {
                        if (Option.isSome(initialState)) {
                            expect(initialState.value).toBe('connected');
                        }
                    }), Effect.flatMap(() => Scope.close(clientScope, Exit.void)), Effect.flatMap(() => context.waitForConnectionCount(server, 0)), Effect.flatMap(() => server.connectionCount()), Effect.tap((finalCount) => Effect.sync(() => expect(finalCount).toBe(0))))));
                }))))))));
                await Effect.runPromise(Effect.scoped(program));
            });
        });
    });
};
//# sourceMappingURL=server-transport-contract-tests.js.map