From c669b41785578d56610608d853555b8dc4d39286 Mon Sep 17 00:00:00 2001 From: Sylvain <1552102+sgaunet@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:16:39 +0100 Subject: [PATCH] feat(connection): add explicit connection parameter support - Add Connect() method accepting connection string as parameter - Implement connect_database MCP tool with parameter validation - Support both full URL and individual connection parameters - Add buildConnectionString() helper with defaults (port=5432, sslmode=prefer) - Refactor tryConnect() to delegate to Connect() method - Remove auto-connect from New() constructor for explicit control - Update error messages to guide users to connect_database tool - Maintain backward compatibility with environment variables as fallback - Update all integration tests to use explicit Connect() calls - Add comprehensive unit tests for connection builder and Connect() method Implements #36 --- integration_test.go | 91 +++++++++++++------ internal/app/app.go | 52 +++++++---- internal/app/app_test.go | 59 +++++++++++++ internal/app/interfaces.go | 4 +- main.go | 173 ++++++++++++++++++++++++++++++++++++- main_additional_test.go | 93 ++++++++++++++++++++ 6 files changed, 423 insertions(+), 49 deletions(-) diff --git a/integration_test.go b/integration_test.go index 8f7be86..91780e8 100644 --- a/integration_test.go +++ b/integration_test.go @@ -75,12 +75,9 @@ func setupTestContainer(t *testing.T) (*postgres.PostgresContainer, string, func return postgresContainer, connStr, cleanup } -func setupTestDatabase(t *testing.T) (*sql.DB, func()) { +func setupTestDatabase(t *testing.T) (*sql.DB, string, func()) { _, connectionString, containerCleanup := setupTestContainer(t) - // Set environment variable for the app to use - os.Setenv("POSTGRES_URL", connectionString) - // Connect to PostgreSQL db, err := sql.Open("postgres", connectionString) require.NoError(t, err) @@ -134,56 +131,63 @@ func setupTestDatabase(t *testing.T) (*sql.DB, func()) { cleanup := func() { _, _ = db.ExecContext(context.Background(), fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", testSchema)) db.Close() - os.Unsetenv("POSTGRES_URL") containerCleanup() // Clean up container } - return db, cleanup + return db, connectionString, cleanup } func TestIntegration_App_Connect(t *testing.T) { _, connectionString, cleanup := setupTestContainer(t) defer cleanup() - // Set environment variable for connection - os.Setenv("POSTGRES_URL", connectionString) - defer os.Unsetenv("POSTGRES_URL") - appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + // Test explicit connection with connection string + err = appInstance.Connect(connectionString) + require.NoError(t, err) + // Test that we can get current database dbName, err := appInstance.GetCurrentDatabase() assert.NoError(t, err) assert.NotEmpty(t, dbName) } -func TestIntegration_App_ConnectWithDatabaseURL(t *testing.T) { +func TestIntegration_App_ConnectWithEnvironmentVariable(t *testing.T) { _, connectionString, cleanup := setupTestContainer(t) defer cleanup() - // Test with DATABASE_URL environment variable - os.Setenv("DATABASE_URL", connectionString) - defer os.Unsetenv("DATABASE_URL") + // Test with POSTGRES_URL environment variable (backward compatibility via tryConnect) + os.Setenv("POSTGRES_URL", connectionString) + defer os.Unsetenv("POSTGRES_URL") appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() - // Test that connection works + // Explicitly call ensureConnection which will trigger tryConnect() fallback err = appInstance.ValidateConnection() assert.NoError(t, err) + + // Verify connection works + dbName, err := appInstance.GetCurrentDatabase() + assert.NoError(t, err) + assert.NotEmpty(t, dbName) } func TestIntegration_App_ListDatabases(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + databases, err := appInstance.ListDatabases() assert.NoError(t, err) assert.NotEmpty(t, databases) @@ -201,13 +205,16 @@ func TestIntegration_App_ListDatabases(t *testing.T) { } func TestIntegration_App_ListSchemas(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + schemas, err := appInstance.ListSchemas() assert.NoError(t, err) assert.NotEmpty(t, schemas) @@ -223,13 +230,16 @@ func TestIntegration_App_ListSchemas(t *testing.T) { } func TestIntegration_App_ListTables(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + // List tables in test schema listOpts := &app.ListTablesOptions{ Schema: "test_mcp_schema", @@ -253,13 +263,16 @@ func TestIntegration_App_ListTables(t *testing.T) { } func TestIntegration_App_ListTablesWithSize(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + // List tables with size information listOpts := &app.ListTablesOptions{ Schema: "test_mcp_schema", @@ -280,13 +293,16 @@ func TestIntegration_App_ListTablesWithSize(t *testing.T) { } func TestIntegration_App_DescribeTable(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + columns, err := appInstance.DescribeTable("test_mcp_schema", "test_users") assert.NoError(t, err) assert.NotEmpty(t, columns) @@ -322,13 +338,16 @@ func TestIntegration_App_DescribeTable(t *testing.T) { } func TestIntegration_App_ExecuteQuery(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + // Test simple SELECT query queryOpts := &app.ExecuteQueryOptions{ Query: "SELECT id, name, email FROM test_mcp_schema.test_users WHERE active = true ORDER BY id", @@ -352,13 +371,16 @@ func TestIntegration_App_ExecuteQuery(t *testing.T) { } func TestIntegration_App_ExecuteQueryWithLimit(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + // Test query with limit queryOpts := &app.ExecuteQueryOptions{ Query: "SELECT * FROM test_mcp_schema.test_users ORDER BY id", @@ -375,13 +397,16 @@ func TestIntegration_App_ExecuteQueryWithLimit(t *testing.T) { } func TestIntegration_App_ListIndexes(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + indexes, err := appInstance.ListIndexes("test_mcp_schema", "test_users") assert.NoError(t, err) assert.NotEmpty(t, indexes) @@ -412,13 +437,16 @@ func TestIntegration_App_ListIndexes(t *testing.T) { } func TestIntegration_App_ExplainQuery(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + // Test EXPLAIN query result, err := appInstance.ExplainQuery("SELECT * FROM test_mcp_schema.test_users WHERE active = true") require.NoError(t, err) @@ -430,13 +458,16 @@ func TestIntegration_App_ExplainQuery(t *testing.T) { } func TestIntegration_App_GetTableStats(t *testing.T) { - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(t, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(t, err) + stats, err := appInstance.GetTableStats("test_mcp_schema", "test_users") assert.NoError(t, err) assert.NotNil(t, stats) @@ -512,13 +543,16 @@ func BenchmarkIntegration_ListTables(b *testing.B) { // Use a testing.T wrapper for setup functions t := &testing.T{} - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(b, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(b, err) + listOpts := &app.ListTablesOptions{ Schema: "test_mcp_schema", } @@ -539,13 +573,16 @@ func BenchmarkIntegration_ExecuteQuery(b *testing.B) { // Use a testing.T wrapper for setup functions t := &testing.T{} - _, cleanup := setupTestDatabase(t) + _, connectionString, cleanup := setupTestDatabase(t) defer cleanup() appInstance, err := app.New() require.NoError(b, err) defer appInstance.Disconnect() + err = appInstance.Connect(connectionString) + require.NoError(b, err) + queryOpts := &app.ExecuteQueryOptions{ Query: "SELECT COUNT(*) FROM test_mcp_schema.test_users", } diff --git a/internal/app/app.go b/internal/app/app.go index 1f321c5..bf4fa56 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -32,17 +32,16 @@ type App struct { logger *slog.Logger } -// New creates a new App instance and attempts to connect to the database. +// New creates a new App instance without establishing a connection. +// Use Connect() method or connect_database tool to establish connection. func New() (*App, error) { app := &App{ client: NewPostgreSQLClient(), logger: logger.NewLogger("info"), } - // Attempt initial connection - if err := app.tryConnect(); err != nil { - app.logger.Warn("Could not connect to database on startup, will retry on first tool request", "error", err) - } + // Note: Connection is now explicit via Connect() or connect_database tool + // Environment variables are still supported as fallback via tryConnect() return app, nil } @@ -52,6 +51,34 @@ func (a *App) SetLogger(logger *slog.Logger) { a.logger = logger } +// Connect establishes a database connection with the provided connection string. +// If a connection already exists, it will be closed before establishing a new one. +func (a *App) Connect(connectionString string) error { + if connectionString == "" { + return ErrNoConnectionString + } + + // Close existing connection if any + if a.client != nil { + if err := a.client.Ping(); err == nil { + // Connection exists and is active, close it first + if closeErr := a.client.Close(); closeErr != nil { + a.logger.Warn("Failed to close existing connection", "error", closeErr) + } + } + } + + a.logger.Debug("Connecting to PostgreSQL database") + + if err := a.client.Connect(connectionString); err != nil { + a.logger.Error("Failed to connect to database", "error", err) + return fmt.Errorf("failed to connect: %w", err) + } + + a.logger.Info("Successfully connected to PostgreSQL database") + return nil +} + // Disconnect closes the database connection. func (a *App) Disconnect() error { if a.client != nil { @@ -280,9 +307,10 @@ func (a *App) ValidateConnection() error { return a.ensureConnection() } -// tryConnect attempts to connect to the database using environment variables. +// tryConnect attempts to connect using environment variables as a fallback mechanism. +// Returns ErrNoConnectionString if no environment variables are set. func (a *App) tryConnect() error { - // Try environment variables + // Try environment variables as fallback connectionString := os.Getenv("POSTGRES_URL") if connectionString == "" { connectionString = os.Getenv("DATABASE_URL") @@ -292,15 +320,7 @@ func (a *App) tryConnect() error { return ErrNoConnectionString } - a.logger.Debug("Connecting to PostgreSQL database") - - if err := a.client.Connect(connectionString); err != nil { - a.logger.Error("Failed to connect to database", "error", err) - return fmt.Errorf("failed to connect: %w", err) - } - - a.logger.Info("Successfully connected to PostgreSQL database") - return nil + return a.Connect(connectionString) } // ensureConnection checks if the database connection is valid and attempts to reconnect if needed. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 72c2cca..a74b0e5 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -532,3 +532,62 @@ func TestApp_ListIndexes(t *testing.T) { assert.Equal(t, expectedIndexes, indexes) mockClient.AssertExpectations(t) } + +func TestApp_Connect_Success(t *testing.T) { + app, _ := New() + mockClient := &MockPostgreSQLClient{} + app.client = mockClient + + connectionString := "postgres://user:pass@localhost/db" + + // Mock expectations + mockClient.On("Ping").Return(errors.New("not connected")) // No existing connection + mockClient.On("Connect", connectionString).Return(nil) + + err := app.Connect(connectionString) + assert.NoError(t, err) + mockClient.AssertExpectations(t) +} + +func TestApp_Connect_EmptyString(t *testing.T) { + app, _ := New() + + err := app.Connect("") + assert.Error(t, err) + assert.Equal(t, ErrNoConnectionString, err) +} + +func TestApp_Connect_ReconnectClosesExisting(t *testing.T) { + app, _ := New() + mockClient := &MockPostgreSQLClient{} + app.client = mockClient + + connectionString := "postgres://user:pass@localhost/db" + + // Mock expectations for reconnection scenario + mockClient.On("Ping").Return(nil).Once() // Existing connection is alive + mockClient.On("Close").Return(nil).Once() // Close existing + mockClient.On("Connect", connectionString).Return(nil) + + err := app.Connect(connectionString) + assert.NoError(t, err) + mockClient.AssertExpectations(t) +} + +func TestApp_Connect_ConnectError(t *testing.T) { + app, _ := New() + mockClient := &MockPostgreSQLClient{} + app.client = mockClient + + connectionString := "postgres://user:pass@localhost/db" + expectedError := errors.New("connection failed") + + // Mock expectations + mockClient.On("Ping").Return(errors.New("not connected")) // No existing connection + mockClient.On("Connect", connectionString).Return(expectedError) + + err := app.Connect(connectionString) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to connect") + mockClient.AssertExpectations(t) +} diff --git a/internal/app/interfaces.go b/internal/app/interfaces.go index 18d5d92..6b42a47 100644 --- a/internal/app/interfaces.go +++ b/internal/app/interfaces.go @@ -8,14 +8,14 @@ import ( // Error variables for static errors. var ( ErrConnectionRequired = errors.New( - "database connection failed. Please check POSTGRES_URL or DATABASE_URL environment variable", + "database connection failed. Please connect to a database using the connect_database tool", ) ErrSchemaRequired = errors.New("schema name is required") ErrTableRequired = errors.New("table name is required") ErrQueryRequired = errors.New("query is required") ErrInvalidQuery = errors.New("only SELECT and WITH queries are allowed") ErrNoConnectionString = errors.New( - "no database connection string found in POSTGRES_URL or DATABASE_URL environment variables", + "no database connection string provided. Either call connect_database tool or set POSTGRES_URL/DATABASE_URL environment variable", ) ErrNoDatabaseConnection = errors.New("no database connection") ErrTableNotFound = errors.New("table does not exist") diff --git a/main.go b/main.go index 4c12552..3d8cc2b 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,165 @@ var ( ErrInvalidConnectionParameters = errors.New("invalid connection parameters") ) +// ConnectionParams represents individual database connection parameters. +type ConnectionParams struct { + Host string + Port int + User string + Password string + Database string + SSLMode string +} + +// buildConnectionString builds a PostgreSQL connection URL from individual parameters. +// Returns the connection string or an error if required parameters are missing. +func buildConnectionString(params ConnectionParams) (string, error) { + // Validate required parameters + if params.Host == "" { + return "", errors.New("host is required") + } + if params.User == "" { + return "", errors.New("user is required") + } + if params.Database == "" { + return "", errors.New("database is required") + } + + // Set defaults + port := params.Port + if port == 0 { + port = 5432 // PostgreSQL default port + } + + sslMode := params.SSLMode + if sslMode == "" { + sslMode = "prefer" // PostgreSQL default SSL mode + } + + // Build connection string + connStr := fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=%s", + params.User, + params.Password, + params.Host, + port, + params.Database, + sslMode, + ) + + return connStr, nil +} + +// setupConnectDatabaseTool creates and registers the connect_database tool. +func setupConnectDatabaseTool(s *server.MCPServer, appInstance *app.App, debugLogger *slog.Logger) { + connectDBTool := mcp.NewTool("connect_database", + mcp.WithDescription("Connect to a PostgreSQL database using connection parameters or connection URL"), + mcp.WithString("connection_url", + mcp.Description("Full PostgreSQL connection URL (postgres://user:password@host:port/dbname?sslmode=mode). If provided, individual parameters are ignored."), + ), + mcp.WithString("host", + mcp.Description("Database host (default: localhost)"), + ), + mcp.WithNumber("port", + mcp.Description("Database port (default: 5432)"), + ), + mcp.WithString("user", + mcp.Description("Database user"), + ), + mcp.WithString("password", + mcp.Description("Database password"), + ), + mcp.WithString("database", + mcp.Description("Database name"), + ), + mcp.WithString("sslmode", + mcp.Description("SSL mode: disable, allow, prefer, require, verify-ca, verify-full (default: prefer)"), + ), + ) + + s.AddTool(connectDBTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := request.GetArguments() + debugLogger.Debug("Received connect_database tool request", "args", args) + + var connectionString string + + // Check if full connection URL is provided + if connURL, ok := args["connection_url"].(string); ok && connURL != "" { + connectionString = connURL + debugLogger.Debug("Using provided connection URL") + } else { + // Build connection string from individual parameters + params := ConnectionParams{} + + if host, ok := args["host"].(string); ok && host != "" { + params.Host = host + } else { + params.Host = "localhost" // Default + } + + if portFloat, ok := args["port"].(float64); ok { + params.Port = int(portFloat) + } + // Port will default to 5432 in buildConnectionString if 0 + + if user, ok := args["user"].(string); ok { + params.User = user + } + + if password, ok := args["password"].(string); ok { + params.Password = password + } + + if database, ok := args["database"].(string); ok { + params.Database = database + } + + if sslmode, ok := args["sslmode"].(string); ok { + params.SSLMode = sslmode + } + + // Validate and build connection string + var err error + connectionString, err = buildConnectionString(params) + if err != nil { + debugLogger.Error("Failed to build connection string", "error", err) + return mcp.NewToolResultError(fmt.Sprintf("Invalid connection parameters: %v", err)), nil + } + + debugLogger.Debug("Built connection string from parameters", "host", params.Host, "port", params.Port, "database", params.Database) + } + + // Attempt to connect + if err := appInstance.Connect(connectionString); err != nil { + debugLogger.Error("Failed to connect to database", "error", err) + return mcp.NewToolResultError(fmt.Sprintf("Failed to connect to database: %v", err)), nil + } + + // Get current database name to confirm connection + dbName, err := appInstance.GetCurrentDatabase() + if err != nil { + debugLogger.Warn("Connected but failed to get database name", "error", err) + dbName = "unknown" + } + + debugLogger.Info("Successfully connected to database", "database", dbName) + + response := map[string]interface{}{ + "status": "connected", + "database": dbName, + "message": fmt.Sprintf("Successfully connected to database: %s", dbName), + } + + jsonData, err := json.Marshal(response) + if err != nil { + debugLogger.Error("Failed to marshal connection response", "error", err) + return mcp.NewToolResultError("Failed to format connection response"), nil + } + + return mcp.NewToolResultText(string(jsonData)), nil + }) +} + // setupListDatabasesTool creates and registers the list_databases tool. func setupListDatabasesTool(s *server.MCPServer, appInstance *app.App, debugLogger *slog.Logger) { listDBTool := mcp.NewTool("list_databases", @@ -409,13 +568,17 @@ OPTIONS: -h, --help Show this help message -v, --version Show version information -ENVIRONMENT VARIABLES: - POSTGRES_URL PostgreSQL connection URL (format: postgres://user:password@host:port/dbname?sslmode=prefer) - DATABASE_URL Alternative to POSTGRES_URL +ENVIRONMENT VARIABLES (OPTIONAL): + POSTGRES_URL PostgreSQL connection URL (fallback if connect_database tool not used) + DATABASE_URL Alternative to POSTGRES_URL (fallback) + + Note: Environment variables are now optional. Use the connect_database tool + for explicit connection management. DESCRIPTION: This MCP server provides the following tools for PostgreSQL integration: + • connect_database - Connect to a PostgreSQL database (use this first!) • list_databases - List all databases on the server • list_schemas - List schemas in the current database • list_tables - List tables in a schema with optional metadata @@ -438,7 +601,7 @@ EXAMPLES: # Show version postgresql-mcp -v -For more information, visit: https://github.com/sylvain/postgresql-mcp +For more information, visit: https://github.com/sgaunet/postgresql-mcp `, version) } @@ -484,7 +647,9 @@ func initializeApp() (*app.App, *slog.Logger) { } // registerAllTools registers all available tools with the MCP server. +// connect_database is registered first as it establishes the connection needed by other tools. func registerAllTools(s *server.MCPServer, appInstance *app.App, debugLogger *slog.Logger) { + setupConnectDatabaseTool(s, appInstance, debugLogger) setupListDatabasesTool(s, appInstance, debugLogger) setupListSchemasTool(s, appInstance, debugLogger) setupListTablesTool(s, appInstance, debugLogger) diff --git a/main_additional_test.go b/main_additional_test.go index eedfe75..e76f70c 100644 --- a/main_additional_test.go +++ b/main_additional_test.go @@ -270,3 +270,96 @@ func TestEnvironmentVariableHandling(t *testing.T) { assert.Equal(t, "postgres://test2@localhost/db2", connectionString) } + +func TestBuildConnectionString_AllParameters(t *testing.T) { + params := ConnectionParams{ + Host: "localhost", + Port: 5432, + User: "testuser", + Password: "testpass", + Database: "testdb", + SSLMode: "disable", + } + + connStr, err := buildConnectionString(params) + assert.NoError(t, err) + assert.Equal(t, "postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable", connStr) +} + +func TestBuildConnectionString_Defaults(t *testing.T) { + params := ConnectionParams{ + Host: "localhost", + User: "testuser", + Password: "testpass", + Database: "testdb", + // Port and SSLMode should use defaults + } + + connStr, err := buildConnectionString(params) + assert.NoError(t, err) + assert.Equal(t, "postgres://testuser:testpass@localhost:5432/testdb?sslmode=prefer", connStr) +} + +func TestBuildConnectionString_MissingHost(t *testing.T) { + params := ConnectionParams{ + User: "testuser", + Password: "testpass", + Database: "testdb", + } + + _, err := buildConnectionString(params) + assert.Error(t, err) + assert.Contains(t, err.Error(), "host is required") +} + +func TestBuildConnectionString_MissingUser(t *testing.T) { + params := ConnectionParams{ + Host: "localhost", + Password: "testpass", + Database: "testdb", + } + + _, err := buildConnectionString(params) + assert.Error(t, err) + assert.Contains(t, err.Error(), "user is required") +} + +func TestBuildConnectionString_MissingDatabase(t *testing.T) { + params := ConnectionParams{ + Host: "localhost", + User: "testuser", + Password: "testpass", + } + + _, err := buildConnectionString(params) + assert.Error(t, err) + assert.Contains(t, err.Error(), "database is required") +} + +func TestBuildConnectionString_EmptyPassword(t *testing.T) { + params := ConnectionParams{ + Host: "localhost", + User: "testuser", + Password: "", + Database: "testdb", + } + + connStr, err := buildConnectionString(params) + assert.NoError(t, err) + assert.Equal(t, "postgres://testuser:@localhost:5432/testdb?sslmode=prefer", connStr) +} + +func TestBuildConnectionString_CustomPort(t *testing.T) { + params := ConnectionParams{ + Host: "dbserver", + Port: 5433, + User: "admin", + Password: "secret", + Database: "mydb", + SSLMode: "require", + } + + connStr, err := buildConnectionString(params) + assert.NoError(t, err) + assert.Equal(t, "postgres://admin:secret@dbserver:5433/mydb?sslmode=require", connStr) +}