package testenv import ( "context" "fmt" "net/url" "strconv" "testing" "time" "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" tcnetwork "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" ) const ( pgImage = "postgres:16-alpine" pgUser = "galaxy" pgPassword = "galaxy" pgDatabase = "galaxy_backend" pgSchema = "backend" pgStartup = 90 * time.Second ) // Postgres holds a running Postgres testcontainer reachable from both // the host (DSN with localhost-mapped port) and from another container // on the same Docker network (HostInNetworkDSN). type Postgres struct { container *tcpostgres.PostgresContainer HostDSN string NetworkDSN string } // StartPostgres boots a postgres:16-alpine container, returns DSNs for // both host and in-network access, and registers a t.Cleanup to // terminate the container. func StartPostgres(t *testing.T, network string) *Postgres { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() opts := []testcontainers.ContainerCustomizer{ tcpostgres.WithDatabase(pgDatabase), tcpostgres.WithUsername(pgUser), tcpostgres.WithPassword(pgPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(pgStartup), ), } if network != "" { opts = append(opts, tcnetwork.WithNetwork([]string{"postgres"}, &testcontainers.DockerNetwork{Name: network})) } container, err := tcpostgres.Run(ctx, pgImage, opts...) if err != nil { t.Skipf("postgres testcontainer unavailable: %v", err) } t.Cleanup(func() { if err := testcontainers.TerminateContainer(container); err != nil { t.Logf("terminate postgres: %v", err) } }) hostDSN, err := container.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatalf("postgres host DSN: %v", err) } hostDSN, err = withSearchPath(hostDSN, pgSchema) if err != nil { t.Fatalf("postgres host DSN search_path: %v", err) } networkDSN := "" if network != "" { networkDSN = buildInNetworkDSN("postgres", 5432, pgUser, pgPassword, pgDatabase, pgSchema) } return &Postgres{ container: container, HostDSN: hostDSN, NetworkDSN: networkDSN, } } func withSearchPath(dsn, schema string) (string, error) { parsed, err := url.Parse(dsn) if err != nil { return "", err } q := parsed.Query() q.Set("search_path", schema) if q.Get("sslmode") == "" { q.Set("sslmode", "disable") } parsed.RawQuery = q.Encode() return parsed.String(), nil } func buildInNetworkDSN(host string, port int, user, password, db, schema string) string { u := &url.URL{ Scheme: "postgres", User: url.UserPassword(user, password), Host: fmt.Sprintf("%s:%d", host, port), Path: "/" + db, RawQuery: "sslmode=disable&search_path=" + schema, } return u.String() } // HostPort renders a host:port pair so other testenv files can reuse // the same formatting. func HostPort(host string, port int) string { return fmt.Sprintf("%s:%d", host, port) } // FormatPort returns the decimal representation of port. func FormatPort(port int) string { return strconv.Itoa(port) }