package notificationstore import ( "context" "database/sql" "net/url" "os" "sync" "testing" "time" "galaxy/notification/internal/adapters/postgres/migrations" "galaxy/postgres" testcontainers "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" ) const ( pkgPostgresImage = "postgres:16-alpine" pkgSuperUser = "galaxy" pkgSuperPassword = "galaxy" pkgSuperDatabase = "galaxy_notification" pkgServiceRole = "notificationservice" pkgServicePassword = "notificationservice" pkgServiceSchema = "notification" pkgContainerStartup = 90 * time.Second pkgOperationTimeout = 10 * time.Second ) var ( pkgContainerOnce sync.Once pkgContainerErr error pkgContainerEnv *postgresEnv ) type postgresEnv struct { container *tcpostgres.PostgresContainer dsn string pool *sql.DB } func ensurePostgresEnv(t testing.TB) *postgresEnv { t.Helper() pkgContainerOnce.Do(func() { pkgContainerEnv, pkgContainerErr = startPostgresEnv() }) if pkgContainerErr != nil { t.Skipf("postgres container start failed (Docker unavailable?): %v", pkgContainerErr) } return pkgContainerEnv } func startPostgresEnv() (*postgresEnv, error) { ctx := context.Background() container, err := tcpostgres.Run(ctx, pkgPostgresImage, tcpostgres.WithDatabase(pkgSuperDatabase), tcpostgres.WithUsername(pkgSuperUser), tcpostgres.WithPassword(pkgSuperPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(pkgContainerStartup), ), ) if err != nil { return nil, err } baseDSN, err := container.ConnectionString(ctx, "sslmode=disable") if err != nil { _ = testcontainers.TerminateContainer(container) return nil, err } if err := provisionRoleAndSchema(ctx, baseDSN); err != nil { _ = testcontainers.TerminateContainer(container) return nil, err } scopedDSN, err := dsnForServiceRole(baseDSN) if err != nil { _ = testcontainers.TerminateContainer(container) return nil, err } cfg := postgres.DefaultConfig() cfg.PrimaryDSN = scopedDSN cfg.OperationTimeout = pkgOperationTimeout pool, err := postgres.OpenPrimary(ctx, cfg) if err != nil { _ = testcontainers.TerminateContainer(container) return nil, err } if err := postgres.Ping(ctx, pool, pkgOperationTimeout); err != nil { _ = pool.Close() _ = testcontainers.TerminateContainer(container) return nil, err } if err := postgres.RunMigrations(ctx, pool, migrations.FS(), "."); err != nil { _ = pool.Close() _ = testcontainers.TerminateContainer(container) return nil, err } return &postgresEnv{ container: container, dsn: scopedDSN, pool: pool, }, nil } func provisionRoleAndSchema(ctx context.Context, baseDSN string) error { cfg := postgres.DefaultConfig() cfg.PrimaryDSN = baseDSN cfg.OperationTimeout = pkgOperationTimeout db, err := postgres.OpenPrimary(ctx, cfg) if err != nil { return err } defer func() { _ = db.Close() }() statements := []string{ `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'notificationservice') THEN CREATE ROLE notificationservice LOGIN PASSWORD 'notificationservice'; END IF; END $$;`, `CREATE SCHEMA IF NOT EXISTS notification AUTHORIZATION notificationservice;`, `GRANT USAGE ON SCHEMA notification TO notificationservice;`, } for _, statement := range statements { if _, err := db.ExecContext(ctx, statement); err != nil { return err } } return nil } func dsnForServiceRole(baseDSN string) (string, error) { parsed, err := url.Parse(baseDSN) if err != nil { return "", err } values := url.Values{} values.Set("search_path", pkgServiceSchema) values.Set("sslmode", "disable") scoped := url.URL{ Scheme: parsed.Scheme, User: url.UserPassword(pkgServiceRole, pkgServicePassword), Host: parsed.Host, Path: parsed.Path, RawQuery: values.Encode(), } return scoped.String(), nil } // newTestStore returns a Store backed by the package-scoped pool. Every // invocation truncates the notification-owned tables so individual tests // start from a clean slate while sharing one container start. func newTestStore(t *testing.T) *Store { t.Helper() env := ensurePostgresEnv(t) truncateAll(t, env.pool) store, err := New(Config{DB: env.pool, OperationTimeout: pkgOperationTimeout}) if err != nil { t.Fatalf("new store: %v", err) } return store } func truncateAll(t *testing.T, db *sql.DB) { t.Helper() statement := `TRUNCATE TABLE malformed_intents, dead_letters, routes, records RESTART IDENTITY CASCADE` if _, err := db.ExecContext(context.Background(), statement); err != nil { t.Fatalf("truncate tables: %v", err) } } // TestMain runs first when `go test` enters the package. We drive it // through a TestMain so the container started by the first test is shut // down on the way out, even when individual tests panic. func TestMain(m *testing.M) { code := m.Run() if pkgContainerEnv != nil { if pkgContainerEnv.pool != nil { _ = pkgContainerEnv.pool.Close() } if pkgContainerEnv.container != nil { _ = testcontainers.TerminateContainer(pkgContainerEnv.container) } } os.Exit(code) }