201 lines
5.2 KiB
Go
201 lines
5.2 KiB
Go
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)
|
|
}
|