UNPKG

@electric-sql/pglite-socket

Version:

A socket implementation for PGlite enabling remote connections

513 lines (425 loc) 14.7 kB
import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll, } from 'vitest' import { PGlite } from '@electric-sql/pglite' import { PGLiteSocketHandler, PGLiteSocketServer, CONNECTION_QUEUE_TIMEOUT, } from '../src' import { Socket, createConnection } from 'net' import { existsSync } from 'fs' import { unlink } from 'fs/promises' // Mock timers for testing timeouts beforeAll(() => { vi.useFakeTimers() }) afterAll(() => { vi.useRealTimers() }) async function testSocket( fn: (socketOptions: { host?: string port?: number path?: string }) => Promise<void>, ) { describe('TCP socket server', async () => { await fn({ host: '127.0.0.1', port: 5433 }) }) describe('unix socket server', async () => { await fn({ path: '/tmp/.s.PGSQL.5432' }) }) } // Create a mock Socket for testing const createMockSocket = () => { const eventHandlers: Record<string, Array<(data: any) => void>> = {} const mockSocket = { // Socket methods we need for testing removeAllListeners: vi.fn(), end: vi.fn(), destroy: vi.fn(), write: vi.fn(), writable: true, remoteAddress: '127.0.0.1', remotePort: 12345, // Mock on method with tracking of handlers on: vi .fn() .mockImplementation((event: string, callback: (data: any) => void) => { if (!eventHandlers[event]) { eventHandlers[event] = [] } eventHandlers[event].push(callback) return mockSocket }), // Store event handlers for testing eventHandlers, // Helper to emit events emit(event: string, data: any) { if (eventHandlers[event]) { eventHandlers[event].forEach((handler) => handler(data)) } }, } return mockSocket as unknown as Socket } describe('PGLiteSocketHandler', () => { let db: PGlite let handler: PGLiteSocketHandler let mockSocket: ReturnType<typeof createMockSocket> & { eventHandlers: Record<string, Array<(data: any) => void>> } beforeEach(async () => { // Create a PGlite instance for testing db = await PGlite.create() handler = new PGLiteSocketHandler({ db }) mockSocket = createMockSocket() as any }) afterEach(async () => { // Ensure handler is detached before closing the database if (handler?.isAttached) { handler.detach(true) } // Clean up await db.close() }) it('should attach to a socket', async () => { // Attach mock socket to handler await handler.attach(mockSocket) // Check that the socket is attached expect(handler.isAttached).toBe(true) expect(mockSocket.on).toHaveBeenCalledWith('data', expect.any(Function)) expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function)) expect(mockSocket.on).toHaveBeenCalledWith('close', expect.any(Function)) }) it('should detach from a socket', async () => { // First attach await handler.attach(mockSocket) expect(handler.isAttached).toBe(true) // Then detach handler.detach(false) expect(handler.isAttached).toBe(false) expect(mockSocket.removeAllListeners).toHaveBeenCalled() }) it('should close socket when detaching with close option', async () => { // Attach mock socket to handler await handler.attach(mockSocket) // Detach with close option handler.detach(true) expect(handler.isAttached).toBe(false) expect(mockSocket.end).toHaveBeenCalled() }) it('should reject attaching multiple sockets', async () => { // Attach first socket await handler.attach(mockSocket) // Trying to attach another socket should throw an error const anotherMockSocket = createMockSocket() await expect(handler.attach(anotherMockSocket)).rejects.toThrow( 'Socket already attached', ) }) it('should emit error event when socket has error', async () => { // Set up error listener const errorHandler = vi.fn() handler.addEventListener('error', errorHandler) // Attach socket await handler.attach(mockSocket) // Mock the event handler logic directly instead of triggering actual error handlers const customEvent = new CustomEvent('error', { detail: { code: 'MOCK_ERROR', message: 'Test socket error' }, }) handler.dispatchEvent(customEvent) // Verify error handler was called expect(errorHandler).toHaveBeenCalled() }) it('should emit close event when socket closes', async () => { // Set up close listener const closeHandler = vi.fn() handler.addEventListener('close', closeHandler) // Attach socket await handler.attach(mockSocket) // Mock the event handler logic directly instead of triggering actual socket handlers const customEvent = new CustomEvent('close') handler.dispatchEvent(customEvent) // Verify close handler was called expect(closeHandler).toHaveBeenCalled() }) }) testSocket(async (connOptions) => { describe('PGLiteSocketServer', () => { let db: PGlite let server: PGLiteSocketServer beforeEach(async () => { // Create a PGlite instance for testing db = await PGlite.create() if (connOptions.path) { if (existsSync(connOptions.path)) { try { await unlink(connOptions.path) console.log(`Removed old socket at ${connOptions.path}`) } catch (err) { console.log('') } } } }) afterEach(async () => { // Stop server if running try { await server?.stop() } catch (e) { // Ignore errors during cleanup } // Close database await db.close() }) it('should start and stop server', async () => { // Create server server = new PGLiteSocketServer({ db, host: connOptions.host, port: connOptions.port, path: connOptions.path, }) // Start server await server.start() // Try to connect to confirm server is running let client if (connOptions.path) { // unix socket client = createConnection({ path: connOptions.path }) } else { if (connOptions.port) { // TCP socket client = createConnection({ port: connOptions.port, host: connOptions.host, }) } else { throw new Error( 'need to specify connOptions.path or connOptions.port', ) } } client.on('error', () => { // Ignore connection errors during test }) await new Promise<void>((resolve) => { client.on('connect', () => { client.end() resolve() }) // Set timeout to resolve in case connection fails setTimeout(resolve, 100) }) // Stop server await server.stop() // Try to connect again - should fail await expect( new Promise<void>((resolve, reject) => { let failClient if (connOptions.path) { // unix socket failClient = createConnection({ path: connOptions.path }) } else { if (connOptions.port) { // TCP socket failClient = createConnection({ port: connOptions.port, host: connOptions.host, }) } else { throw new Error( 'need to specify connOptions.path or connOptions.port', ) } } failClient.on('error', () => { // Expected error - connection should fail resolve() }) failClient.on('connect', () => { failClient.end() reject(new Error('Connection should have failed')) }) // Set timeout to resolve in case no events fire setTimeout(resolve, 100) }), ).resolves.not.toThrow() }) describe('Connection queuing', () => { // Mock implementation details // eslint-disable-next-line @typescript-eslint/no-unused-vars let handleConnectionSpy: any let processNextInQueueSpy: any let attachSocketToNewHandlerSpy: any beforeEach(() => { // Create a server with a short timeout for testing server = new PGLiteSocketServer({ db, host: connOptions.host, port: connOptions.port, path: connOptions.path, connectionQueueTimeout: 100, // Very short timeout for testing }) // Spy on internal methods handleConnectionSpy = vi.spyOn(server as any, 'handleConnection') processNextInQueueSpy = vi.spyOn(server as any, 'processNextInQueue') attachSocketToNewHandlerSpy = vi.spyOn( server as any, 'attachSocketToNewHandler', ) }) it('should create a handler for a new connection', async () => { await server.start() // Create mock socket const socket1 = createMockSocket() // Setup event listener const connectionHandler = vi.fn() server.addEventListener('connection', connectionHandler) // Handle connection await (server as any).handleConnection(socket1) // Verify handler was created expect(attachSocketToNewHandlerSpy).toHaveBeenCalledWith( socket1, expect.anything(), ) expect(connectionHandler).toHaveBeenCalled() }) it('should queue a second connection when first is active', async () => { await server.start() // Setup event listeners const queuedConnectionHandler = vi.fn() server.addEventListener('queuedConnection', queuedConnectionHandler) // Create mock sockets const socket1 = createMockSocket() const socket2 = createMockSocket() // Handle first connection await (server as any).handleConnection(socket1) // The first socket should be attached directly expect(attachSocketToNewHandlerSpy).toHaveBeenCalledWith( socket1, expect.anything(), ) // Handle second connection - should be queued await (server as any).handleConnection(socket2) // The second connection should be queued expect(queuedConnectionHandler).toHaveBeenCalledTimes(1) expect(queuedConnectionHandler).toHaveBeenCalledWith( expect.objectContaining({ detail: expect.objectContaining({ queueSize: 1, }), }), ) }) it('should process next connection when current connection closes', async () => { await server.start() // Create mock sockets const socket1 = createMockSocket() const socket2 = createMockSocket() // Setup event listener const connectionHandler = vi.fn() server.addEventListener('connection', connectionHandler) // Handle first connection await (server as any).handleConnection(socket1) // Handle second connection (will be queued) await (server as any).handleConnection(socket2) // First connection should be active, but clear the handler for next assertions expect(connectionHandler).toHaveBeenCalled() connectionHandler.mockClear() // Simulate closing the first connection const activeHandler = (server as any).activeHandler activeHandler.dispatchEvent(new CustomEvent('close')) // The next connection should be processed expect(processNextInQueueSpy).toHaveBeenCalled() expect(attachSocketToNewHandlerSpy).toHaveBeenCalledWith( socket2, expect.anything(), ) }) it('should timeout queued connections after specified time', async () => { await server.start() // Setup event listeners const queueTimeoutHandler = vi.fn() server.addEventListener('queueTimeout', queueTimeoutHandler) // Create mock sockets const socket1 = createMockSocket() const socket2 = createMockSocket() // Handle first connection await (server as any).handleConnection(socket1) // Handle second connection (will be queued) await (server as any).handleConnection(socket2) // Fast-forward time to trigger timeout vi.advanceTimersByTime(1001) // The queued connection should timeout expect(queueTimeoutHandler).toHaveBeenCalledTimes(1) expect(socket2.end).toHaveBeenCalled() }) it('should use default timeout value from CONNECTION_QUEUE_TIMEOUT', async () => { // Create server without specifying timeout const defaultServer = new PGLiteSocketServer({ db, host: connOptions.host, port: connOptions.port, path: connOptions.path, }) // Check that it's using the default timeout expect((defaultServer as any).connectionQueueTimeout).toBe( CONNECTION_QUEUE_TIMEOUT, ) }) it('should clean up queue when stopping the server', async () => { await server.start() // Create mock sockets const socket1 = createMockSocket() const socket2 = createMockSocket() // Handle first connection await (server as any).handleConnection(socket1) // Handle second connection (will be queued) await (server as any).handleConnection(socket2) // Stop the server await server.stop() // All connections should be closed expect(socket1.end).toHaveBeenCalled() expect(socket2.end).toHaveBeenCalled() // Queue should be emptied expect((server as any).connectionQueue).toHaveLength(0) }) it('should start server with OS-assigned port when port is 0', async () => { server = new PGLiteSocketServer({ db, host: connOptions.host, port: 0, // Let OS assign port }) await server.start() const assignedPort = (server as any).port expect(assignedPort).toBeGreaterThan(1024) // Try to connect to confirm server is running const client = createConnection({ port: assignedPort, host: connOptions.host, }) await new Promise<void>((resolve, reject) => { client.on('error', () => { reject(new Error('Connection should have failed')) }) client.on('connect', () => { client.end() resolve() }) setTimeout(resolve, 100) }) await server.stop() }) }) }) })