diff --git a/README.md b/README.md index 914520c..00a0dc1 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ export TEST_DATABASE_URL="postgres://user:pass@localhost:5432/postgres" Or use the default: `postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable` +Each database provider also has a variable of its own that wins over the shared ones, so a project that tests against more than one database server can point each provider at the right one: `TEST_POSTGRES_URL` for PostgreSQL. The full order is `WithAdminDSN`, the provider's variable, `TEST_DATABASE_URL`, `DATABASE_URL`, then the default. + ### Options ```go diff --git a/config.go b/config.go index f17ace1..cbd2e85 100644 --- a/config.go +++ b/config.go @@ -19,9 +19,10 @@ type Config struct { // For PostgreSQL, this is typically the 'postgres' database. // // If not specified, the library automatically discovers the admin DSN from: - // 1. TEST_DATABASE_URL environment variable - // 2. DATABASE_URL environment variable - // 3. Database-specific defaults (e.g., postgres://postgres:postgres@localhost:5432/postgres) + // 1. A database-specific environment variable (e.g., TEST_POSTGRES_URL) + // 2. TEST_DATABASE_URL environment variable + // 3. DATABASE_URL environment variable + // 4. Database-specific defaults (e.g., postgres://postgres:postgres@localhost:5432/postgres) // // Most users don't need to set this - automatic discovery works in most cases. // Use this when you need custom admin credentials or connection settings. @@ -242,17 +243,27 @@ func discoverAdminDSN() string { // // Resolution order: // 1. cfg.AdminDSNOverride (explicit user override via WithAdminDSN) -// 2. TEST_DATABASE_URL environment variable -// 3. DATABASE_URL environment variable -// 4. defaultDSN (database-specific default) +// 2. The provider's own environment variables, in the order given +// (e.g. TEST_POSTGRES_URL), so a project that tests against two +// databases can point each provider at its own server +// 3. TEST_DATABASE_URL environment variable +// 4. DATABASE_URL environment variable +// 5. defaultDSN (database-specific default) // // Example usage in provider initialization: // -// adminDSN := testdb.ResolveAdminDSN(cfg, "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable") -func ResolveAdminDSN(cfg Config, defaultDSN string) string { +// adminDSN := testdb.ResolveAdminDSN(cfg, +// "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable", +// "TEST_POSTGRES_URL") +func ResolveAdminDSN(cfg Config, defaultDSN string, providerEnv ...string) string { if cfg.AdminDSNOverride != "" { return cfg.AdminDSNOverride } + for _, name := range providerEnv { + if dsn := os.Getenv(name); dsn != "" { + return dsn + } + } if discovered := discoverAdminDSN(); discovered != "" { return discovered } @@ -306,6 +317,10 @@ var ( // ErrNoMigrator is returned when RunMigrations is called without a Migrator configured. ErrNoMigrator = errors.New("no migrator configured") + // ErrTemplatesNotSupported is returned by New when WithTemplate is used + // with a provider that does not implement TemplateProvider. + ErrTemplatesNotSupported = errors.New("templates are not supported") + // ErrPrefixTooLong is returned when the database prefix would cause identifier truncation. ErrPrefixTooLong = errors.New("database prefix too long: would exceed database identifier limit") ) diff --git a/postgres/admin_dsn_test.go b/postgres/admin_dsn_test.go new file mode 100644 index 0000000..a3b3c3a --- /dev/null +++ b/postgres/admin_dsn_test.go @@ -0,0 +1,54 @@ +package postgres_test + +import ( + "context" + "testing" + + "github.com/bashhack/testdb" + "github.com/bashhack/testdb/postgres" +) + +// An address nothing listens on, so Initialize fails fast after resolving +// the DSN, which is the part under test. +const unreachableDSN = "postgres://u:p@127.0.0.1:1/postgres?sslmode=disable&connect_timeout=1" + +func TestAdminDSNEnvPrecedence(t *testing.T) { + t.Setenv(postgres.AdminDSNEnv, unreachableDSN) + t.Setenv("TEST_DATABASE_URL", "postgres://shared/should-lose") + t.Setenv("DATABASE_URL", "postgres://generic/should-lose") + + provider := &postgres.PostgresProvider{} + err := provider.Initialize(context.Background(), testdb.Config{}) + if err == nil { + t.Cleanup(func() { + if err := provider.Cleanup(context.Background()); err != nil { + t.Error(err) + } + }) + t.Fatal("expected the connection to the unreachable address to fail") + } + if got := provider.ResolvedAdminDSN(); got != unreachableDSN { + t.Errorf("resolved %q, want %s to win: %q", got, postgres.AdminDSNEnv, unreachableDSN) + } +} + +func TestAdminDSNDefault(t *testing.T) { + t.Setenv(postgres.AdminDSNEnv, "") + t.Setenv("TEST_DATABASE_URL", "") + t.Setenv("DATABASE_URL", "") + + provider := &postgres.PostgresProvider{} + err := provider.Initialize(context.Background(), testdb.Config{}) + if err == nil { + t.Cleanup(func() { + if err := provider.Cleanup(context.Background()); err != nil { + t.Error(err) + } + }) + } + // Whether or not a server answers at the default address, the DSN + // chosen must be the documented default + if got := provider.ResolvedAdminDSN(); got != postgres.DefaultAdminDSN { + t.Errorf("resolved %q, want the default %q", got, postgres.DefaultAdminDSN) + } +} diff --git a/postgres/postgres.go b/postgres/postgres.go index d6b4d9f..f4d2972 100644 --- a/postgres/postgres.go +++ b/postgres/postgres.go @@ -108,8 +108,20 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// PostgresProvider implements testdb.Provider for PostgreSQL. -// It handles database creation, deletion, and connection management. +const ( + // AdminDSNEnv is the environment variable this provider checks first for + // its admin connection string, before the shared TEST_DATABASE_URL and + // DATABASE_URL. Set it when a project tests against more than one + // database server. + AdminDSNEnv = "TEST_POSTGRES_URL" + + // DefaultAdminDSN is used when no override or environment variable is set. + DefaultAdminDSN = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" +) + +// PostgresProvider implements testdb.Provider and testdb.TemplateProvider +// for PostgreSQL. It handles database creation, deletion, template cloning +// and connection management. type PostgresProvider struct { conn *pgx.Conn adminDSN string // Store the admin DSN for use in migrations @@ -130,8 +142,7 @@ type PoolInitializer struct { // It establishes a connection to the admin database which will be used // for creating and managing test databases. func (p *PostgresProvider) Initialize(ctx context.Context, cfg testdb.Config) error { - const defaultPostgresDSN = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" - adminDSN := testdb.ResolveAdminDSN(cfg, defaultPostgresDSN) + adminDSN := testdb.ResolveAdminDSN(cfg, DefaultAdminDSN, AdminDSNEnv) // Store the admin DSN for later use (e.g., migrations) p.adminDSN = adminDSN diff --git a/provider_test.go b/provider_test.go new file mode 100644 index 0000000..a2b2c89 --- /dev/null +++ b/provider_test.go @@ -0,0 +1,134 @@ +package testdb + +import ( + "context" + "errors" + "strings" + "testing" +) + +// plainProvider implements Provider but not TemplateProvider, standing in +// for a database that cannot clone one database from another. It delegates +// to a mockProvider by hand so no template method gets promoted. +type plainProvider struct { + inner mockProvider + initialized bool +} + +func (p *plainProvider) Initialize(ctx context.Context, cfg Config) error { + p.initialized = true + return p.inner.Initialize(ctx, cfg) +} +func (p *plainProvider) CreateDatabase(ctx context.Context, name string) error { + return p.inner.CreateDatabase(ctx, name) +} +func (p *plainProvider) DropDatabase(ctx context.Context, name string) error { + return p.inner.DropDatabase(ctx, name) +} +func (p *plainProvider) TerminateConnections(ctx context.Context, name string) error { + return p.inner.TerminateConnections(ctx, name) +} +func (p *plainProvider) BuildDSN(dbName string) (string, error) { return p.inner.BuildDSN(dbName) } +func (p *plainProvider) ResolvedAdminDSN() string { return p.inner.ResolvedAdminDSN() } +func (p *plainProvider) Cleanup(ctx context.Context) error { return p.inner.Cleanup(ctx) } + +// Compile-time statements of which interfaces the test doubles satisfy. +var ( + _ Provider = (*plainProvider)(nil) + _ TemplateProvider = (*mockProvider)(nil) +) + +func TestNewWithTemplateNeedsTemplateProvider(t *testing.T) { + provider := &plainProvider{} + + _, err := New(t, provider, nil, WithTemplate("us_region")) + if !errors.Is(err, ErrTemplatesNotSupported) { + t.Fatalf("expected ErrTemplatesNotSupported, got %v", err) + } + var testErr *Error + if !errors.As(err, &testErr) || testErr.Op != "testdb.New" { + t.Errorf("expected *Error with Op testdb.New, got %#v", err) + } + if !strings.Contains(err.Error(), "plainProvider") { + t.Errorf("error should name the provider type, got: %v", err) + } + if provider.initialized { + t.Error("New should refuse before initializing the provider") + } +} + +func TestNewWithoutTemplateAcceptsPlainProvider(t *testing.T) { + provider := &plainProvider{} + + db, err := New(t, provider, nil) + if err != nil { + t.Fatalf("plain provider without a template should work, got %v", err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } +} + +func TestResolveAdminDSN(t *testing.T) { + const ( + override = "postgres://override/db" + specific = "postgres://specific/db" + specific2 = "postgres://specific2/db" + shared = "postgres://shared/db" + generic = "postgres://generic/db" + fallback = "postgres://default/db" + ) + + tests := map[string]struct { + cfg Config + env map[string]string + args []string + want string + }{ + "override beats everything": { + cfg: Config{AdminDSNOverride: override}, + env: map[string]string{"TEST_X_URL": specific, "TEST_DATABASE_URL": shared, "DATABASE_URL": generic}, + args: []string{"TEST_X_URL"}, + want: override, + }, + "provider variable beats the shared ones": { + env: map[string]string{"TEST_X_URL": specific, "TEST_DATABASE_URL": shared, "DATABASE_URL": generic}, + args: []string{"TEST_X_URL"}, + want: specific, + }, + "provider variables are tried in order": { + env: map[string]string{"TEST_Y_URL": specific2, "TEST_DATABASE_URL": shared}, + args: []string{"TEST_X_URL", "TEST_Y_URL"}, + want: specific2, + }, + "TEST_DATABASE_URL beats DATABASE_URL": { + env: map[string]string{"TEST_DATABASE_URL": shared, "DATABASE_URL": generic}, + args: []string{"TEST_X_URL"}, + want: shared, + }, + "DATABASE_URL when nothing else": { + env: map[string]string{"DATABASE_URL": generic}, + args: []string{"TEST_X_URL"}, + want: generic, + }, + "default when no variable is set": { + args: []string{"TEST_X_URL"}, + want: fallback, + }, + "no provider variables given": { + env: map[string]string{"TEST_DATABASE_URL": shared}, + want: shared, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + for _, v := range []string{"TEST_X_URL", "TEST_Y_URL", "TEST_DATABASE_URL", "DATABASE_URL"} { + t.Setenv(v, tc.env[v]) + } + if got := ResolveAdminDSN(tc.cfg, fallback, tc.args...); got != tc.want { + t.Errorf("ResolveAdminDSN = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/testdb.go b/testdb.go index 6ee0b19..67692d9 100644 --- a/testdb.go +++ b/testdb.go @@ -48,6 +48,7 @@ package testdb import ( "context" "errors" + "fmt" "testing" ) @@ -114,22 +115,9 @@ type Provider interface { // CreateDatabase creates a new database with the given name. CreateDatabase(ctx context.Context, name string) error - // CreateDatabaseFromTemplate creates a new database by cloning a template. - // This is significantly faster than CreateDatabase for databases with large schemas - // or pre-seeded data. The template database must already exist. - // - // Providers that don't support templates should return an error. - CreateDatabaseFromTemplate(ctx context.Context, name, templateName string) error - // DropDatabase drops an existing database. DropDatabase(ctx context.Context, name string) error - // TemplateExists checks if a template database exists. - // This is useful for debugging and conditional template creation. - // - // Providers that don't support templates should return false, nil. - TemplateExists(ctx context.Context, name string) (bool, error) - // TerminateConnections forcefully closes all connections to a database. // This is necessary before dropping a database. TerminateConnections(ctx context.Context, name string) error @@ -146,6 +134,24 @@ type Provider interface { Cleanup(ctx context.Context) error } +// TemplateProvider is implemented by providers whose database can clone one +// database from another, which is what templates (see WithTemplate) rely on. +// PostgreSQL has CREATE DATABASE ... TEMPLATE and implements it. A provider +// for a database without an equivalent implements only Provider, and New +// returns ErrTemplatesNotSupported if a template is requested with it. +type TemplateProvider interface { + Provider + + // CreateDatabaseFromTemplate creates a new database by cloning a template. + // This is significantly faster than CreateDatabase for databases with large schemas + // or pre-seeded data. The template database must already exist. + CreateDatabaseFromTemplate(ctx context.Context, name, templateName string) error + + // TemplateExists checks if a template database exists. + // This is useful for debugging and conditional template creation. + TemplateExists(ctx context.Context, name string) (bool, error) +} + // DBInitializer defines the interface for custom database initialization in tests. // // # When You Need a Custom Initializer @@ -300,6 +306,17 @@ func New(t testing.TB, provider Provider, initializer DBInitializer, opts ...Opt } } + // Decide this before touching the server: a template on a provider that + // cannot clone is a configuration error, not a connection problem. + if cfg.TemplateName != "" { + if _, ok := provider.(TemplateProvider); !ok { + return nil, &Error{ + Op: "testdb.New", + Err: fmt.Errorf("%w by %T", ErrTemplatesNotSupported, provider), + } + } + } + ctx := ContextOf(t) // t.Context() is already cancelled by the time t.Cleanup functions run, // so cleanup must not use it. @@ -329,7 +346,8 @@ func New(t testing.TB, provider Provider, initializer DBInitializer, opts ...Opt // Use template if specified, otherwise create fresh database if cfg.TemplateName != "" { - if err := provider.CreateDatabaseFromTemplate(ctx, dbName, cfg.TemplateName); err != nil { + // Checked above + if err := provider.(TemplateProvider).CreateDatabaseFromTemplate(ctx, dbName, cfg.TemplateName); err != nil { return nil, &Error{ Op: "provider.CreateDatabaseFromTemplate", Err: err,