feat: use postgres
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/adapters/postgres/migrations"
|
||||
mailconfig "galaxy/mail/internal/config"
|
||||
"galaxy/postgres"
|
||||
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const (
|
||||
pkgPGImage = "postgres:16-alpine"
|
||||
pkgPGSuperUser = "galaxy"
|
||||
pkgPGSuperPassword = "galaxy"
|
||||
pkgPGSuperDatabase = "galaxy_mail"
|
||||
pkgPGServiceRole = "mailservice"
|
||||
pkgPGServicePassword = "mailservice"
|
||||
pkgPGServiceSchema = "mail"
|
||||
pkgPGContainerStartup = 90 * time.Second
|
||||
pkgPGOperationTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
pkgPGContainerOnce sync.Once
|
||||
pkgPGContainerErr error
|
||||
pkgPGContainerEnv *runtimePostgresEnv
|
||||
)
|
||||
|
||||
type runtimePostgresEnv struct {
|
||||
container *tcpostgres.PostgresContainer
|
||||
dsn string
|
||||
pool *sql.DB
|
||||
}
|
||||
|
||||
func ensureRuntimePostgresEnv(t testing.TB) *runtimePostgresEnv {
|
||||
t.Helper()
|
||||
pkgPGContainerOnce.Do(func() {
|
||||
pkgPGContainerEnv, pkgPGContainerErr = startRuntimePostgresEnv()
|
||||
})
|
||||
if pkgPGContainerErr != nil {
|
||||
t.Skipf("postgres container start failed (Docker unavailable?): %v", pkgPGContainerErr)
|
||||
}
|
||||
return pkgPGContainerEnv
|
||||
}
|
||||
|
||||
func startRuntimePostgresEnv() (*runtimePostgresEnv, error) {
|
||||
ctx := context.Background()
|
||||
container, err := tcpostgres.Run(ctx, pkgPGImage,
|
||||
tcpostgres.WithDatabase(pkgPGSuperDatabase),
|
||||
tcpostgres.WithUsername(pkgPGSuperUser),
|
||||
tcpostgres.WithPassword(pkgPGSuperPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(pkgPGContainerStartup),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := provisionRuntimeRoleAndSchema(ctx, baseDSN); err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scopedDSN, err := dsnForRuntimeServiceRole(baseDSN)
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = pkgPGOperationTimeout
|
||||
pool, err := postgres.OpenPrimary(ctx, cfg)
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
if err := postgres.Ping(ctx, pool, pkgPGOperationTimeout); 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 &runtimePostgresEnv{container: container, dsn: scopedDSN, pool: pool}, nil
|
||||
}
|
||||
|
||||
func provisionRuntimeRoleAndSchema(ctx context.Context, baseDSN string) error {
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.PrimaryDSN = baseDSN
|
||||
cfg.OperationTimeout = pkgPGOperationTimeout
|
||||
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 = 'mailservice') THEN
|
||||
CREATE ROLE mailservice LOGIN PASSWORD 'mailservice';
|
||||
END IF;
|
||||
END $$;`,
|
||||
`CREATE SCHEMA IF NOT EXISTS mail AUTHORIZATION mailservice;`,
|
||||
`GRANT USAGE ON SCHEMA mail TO mailservice;`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := db.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dsnForRuntimeServiceRole(baseDSN string) (string, error) {
|
||||
parsed, err := url.Parse(baseDSN)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("search_path", pkgPGServiceSchema)
|
||||
values.Set("sslmode", "disable")
|
||||
scoped := url.URL{
|
||||
Scheme: parsed.Scheme,
|
||||
User: url.UserPassword(pkgPGServiceRole, pkgPGServicePassword),
|
||||
Host: parsed.Host,
|
||||
Path: parsed.Path,
|
||||
RawQuery: values.Encode(),
|
||||
}
|
||||
return scoped.String(), nil
|
||||
}
|
||||
|
||||
// truncateRuntimeMail clears the mail schema between tests sharing the
|
||||
// container.
|
||||
func truncateRuntimeMail(t *testing.T) {
|
||||
t.Helper()
|
||||
env := ensureRuntimePostgresEnv(t)
|
||||
if env == nil {
|
||||
return
|
||||
}
|
||||
if _, err := env.pool.ExecContext(context.Background(),
|
||||
`TRUNCATE TABLE
|
||||
malformed_commands,
|
||||
dead_letters,
|
||||
delivery_payloads,
|
||||
attempts,
|
||||
delivery_recipients,
|
||||
deliveries
|
||||
RESTART IDENTITY CASCADE`,
|
||||
); err != nil {
|
||||
t.Fatalf("truncate mail tables: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeBaseConfig returns a minimum-viable config suitable for runtime
|
||||
// construction, with Redis and Postgres connection coordinates wired up. The
|
||||
// caller still has to fill the templates dir, internal HTTP addr, SMTP mode,
|
||||
// etc. The helper does NOT truncate mail tables — tests that need a clean
|
||||
// slate should call truncateRuntimeMail explicitly (typically once at test
|
||||
// start, not on every runtime restart).
|
||||
func runtimeBaseConfig(t *testing.T, redisAddr string) mailconfig.Config {
|
||||
t.Helper()
|
||||
env := ensureRuntimePostgresEnv(t)
|
||||
|
||||
cfg := mailconfig.DefaultConfig()
|
||||
cfg.Redis.Conn.MasterAddr = redisAddr
|
||||
cfg.Redis.Conn.Password = "integration"
|
||||
cfg.Postgres.Conn.PrimaryDSN = env.dsn
|
||||
cfg.Postgres.Conn.OperationTimeout = pkgPGOperationTimeout
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestMain shuts down the shared container after the test process completes.
|
||||
func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
if pkgPGContainerEnv != nil {
|
||||
if pkgPGContainerEnv.pool != nil {
|
||||
_ = pkgPGContainerEnv.pool.Close()
|
||||
}
|
||||
if pkgPGContainerEnv.container != nil {
|
||||
_ = testcontainers.TerminateContainer(pkgPGContainerEnv.container)
|
||||
}
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
Reference in New Issue
Block a user