diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 2020386b42..8d6205cf5b 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -152,6 +152,37 @@ jobs: apps/backend/test-results.json retention-days: 30 + postgres-boot: + name: Backend Postgres boot + runs-on: ubuntu-latest + timeout-minutes: 10 + container: ghcr.io/kdlbs/kandev-ci:build-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: kandev + POSTGRES_PASSWORD: kandev + POSTGRES_DB: kandev_admin + options: >- + --health-cmd "pg_isready -U kandev -d kandev_admin" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + defaults: + run: + shell: bash + working-directory: apps/backend + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Run Postgres boot smoke test + env: + KANDEV_TEST_POSTGRES_DSN: host=postgres port=5432 user=kandev password=kandev dbname=kandev_admin sslmode=disable + run: go test -v -race -run TestPostgresBootInitializesRepositories ./cmd/kandev + test-windows: name: Backend (windows) runs-on: windows-latest diff --git a/apps/backend/cmd/kandev/postgres_boot_test.go b/apps/backend/cmd/kandev/postgres_boot_test.go new file mode 100644 index 0000000000..b6daa57ab8 --- /dev/null +++ b/apps/backend/cmd/kandev/postgres_boot_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "fmt" + "net/url" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresBootInitializesRepositories(t *testing.T) { + adminDSN := testutil.PostgresDSNFromEnv(t) + adminInfo, err := parsePostgresConnInfo(adminDSN) + if err != nil { + t.Fatalf("parse postgres dsn: %v", err) + } + admin := openPostgresAdmin(t, adminDSN) + + dbName := "kandev_boot_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if _, err := admin.Exec("CREATE DATABASE " + dbName); err != nil { + t.Fatalf("create postgres database %s: %v", dbName, err) + } + t.Cleanup(func() { + _, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName + " WITH (FORCE)") + }) + + cfg := &config.Config{ + HomeDir: t.TempDir(), + Database: config.DatabaseConfig{ + Driver: "postgres", + Host: adminInfo.Host, + Port: adminInfo.Port, + User: adminInfo.User, + Password: adminInfo.Password, + DBName: dbName, + SSLMode: adminInfo.SSLMode, + MaxConns: 5, + MinConns: 1, + }, + } + log := newTestLogger() + + pool, repos, cleanups, err := provideRepositories(cfg, log, "test-postgres-boot") + if err != nil { + t.Fatalf("provide repositories with postgres: %v", err) + } + if pool == nil || repos == nil { + t.Fatal("provideRepositories returned nil pool or repositories") + } + t.Cleanup(func() { + for i := len(cleanups) - 1; i >= 0; i-- { + if cleanups[i] != nil { + _ = cleanups[i]() + } + } + }) +} + +type postgresConnInfo struct { + Host string + Port int + User string + Password string + SSLMode string +} + +func openPostgresAdmin(t *testing.T, dsn string) *sqlx.DB { + t.Helper() + db, err := sqlx.Open("pgx", dsn) + if err != nil { + t.Fatalf("open postgres admin connection: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + if err := db.Ping(); err != nil { + t.Fatalf("ping postgres admin connection: %v", err) + } + return db +} + +func parsePostgresConnInfo(dsn string) (postgresConnInfo, error) { + cfg, err := pgconn.ParseConfig(dsn) + if err != nil { + return postgresConnInfo{}, err + } + info := postgresConnInfo{ + Host: cfg.Host, + Port: int(cfg.Port), + User: cfg.User, + Password: cfg.Password, + SSLMode: postgresSSLMode(dsn), + } + if info.Host == "" { + info.Host = "localhost" + } + if info.Port == 0 { + info.Port = 5432 + } + return info, nil +} + +func postgresSSLMode(dsn string) string { + if parsed, err := url.Parse(dsn); err == nil && parsed.Scheme != "" { + if mode := parsed.Query().Get("sslmode"); mode != "" { + return mode + } + } + for _, field := range strings.Fields(dsn) { + key, value, ok := strings.Cut(field, "=") + if ok && strings.EqualFold(key, "sslmode") { + return strings.Trim(value, "'\"") + } + } + if mode := os.Getenv("PGSSLMODE"); mode != "" { + return mode + } + return "disable" +} + +func TestParsePostgresConnInfo(t *testing.T) { + info, err := parsePostgresConnInfo( + "host=postgres port=5433 user=kandev password=secret dbname=kandev_test sslmode=require", + ) + if err != nil { + t.Fatalf("parse conn info: %v", err) + } + want := postgresConnInfo{ + Host: "postgres", + Port: 5433, + User: "kandev", + Password: "secret", + SSLMode: "require", + } + if info != want { + t.Fatalf("conn info = %s, want %s", formatConnInfo(info), formatConnInfo(want)) + } +} + +func formatConnInfo(info postgresConnInfo) string { + return fmt.Sprintf("{host:%s port:%d user:%s sslmode:%s}", info.Host, info.Port, info.User, info.SSLMode) +}