feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
@@ -0,0 +1,13 @@
package harness
// AuthsessionRedisEnv returns the env-var map that wires the authsession
// binary to a Redis master at masterAddr using the master/replica/password
// shape required by `pkg/redisconn`. The integration suites pass a fixed
// placeholder password because the test Redis container runs without
// `requirepass`.
func AuthsessionRedisEnv(masterAddr string) map[string]string {
return map[string]string{
"AUTHSESSION_REDIS_MASTER_ADDR": masterAddr,
"AUTHSESSION_REDIS_PASSWORD": "integration",
}
}
@@ -0,0 +1,12 @@
package harness
// GatewayRedisEnv returns the env-var map that wires the gateway binary to a
// Redis master at masterAddr using the master/replica/password shape required
// by `pkg/redisconn`. The integration suites pass a fixed placeholder
// password because the test Redis container runs without `requirepass`.
func GatewayRedisEnv(masterAddr string) map[string]string {
return map[string]string{
"GATEWAY_REDIS_MASTER_ADDR": masterAddr,
"GATEWAY_REDIS_PASSWORD": "integration",
}
}
@@ -0,0 +1,51 @@
package harness
import (
"context"
"testing"
)
// LobbyServicePersistence captures the per-test persistence dependencies of
// the Game Lobby Service binary: a PostgreSQL container hosting the `lobby`
// schema owned by the `lobbyservice` role, plus the Redis credentials that
// point the service at the caller-supplied master address.
type LobbyServicePersistence struct {
// Postgres exposes the started container so tests that need direct SQL
// access to the lobby schema (verifying side effects, seeding fixtures)
// can read or write through it.
Postgres *PostgresRuntime
// Env carries the environment entries that must be passed to the
// lobby-service process. It is safe to merge into the caller's existing
// env map, or to use as-is and append further LOBBY_* knobs in place.
Env map[string]string
}
// StartLobbyServicePersistence brings up one isolated PostgreSQL container,
// provisions the `lobby` schema with the `lobbyservice` role, and returns
// the environment entries that wire the lobby-service binary at that
// container plus the supplied Redis master address.
//
// The returned password (`integration`) matches the architectural rule that
// Redis traffic is password-protected; miniredis accepts arbitrary password
// values when its own RequireAuth is not engaged, so the same value works
// against both miniredis and the real `tcredis` runtime.
//
// Cleanup of the container is handled by StartPostgresContainer through
// `t.Cleanup`; callers do not need to defer anything.
func StartLobbyServicePersistence(t testing.TB, redisMasterAddr string) LobbyServicePersistence {
t.Helper()
rt := StartPostgresContainer(t)
if err := rt.EnsureRoleAndSchema(context.Background(), "lobby", "lobbyservice", "lobbyservice"); err != nil {
t.Fatalf("ensure lobby schema/role: %v", err)
}
env := WithPostgres(rt, "LOBBY", "lobby", "lobbyservice")
env["LOBBY_REDIS_MASTER_ADDR"] = redisMasterAddr
env["LOBBY_REDIS_PASSWORD"] = "integration"
return LobbyServicePersistence{
Postgres: rt,
Env: env,
}
}
@@ -0,0 +1,51 @@
package harness
import (
"context"
"testing"
)
// MailServicePersistence captures the per-test persistence dependencies of
// the Mail Service binary: a PostgreSQL container hosting the `mail` schema
// owned by the `mailservice` role, and the Redis credentials that point the
// service at the caller-supplied master address.
type MailServicePersistence struct {
// Postgres exposes the started container so tests that need direct SQL
// access to the mail schema (verifying side effects, seeding fixtures)
// can read or write through it.
Postgres *PostgresRuntime
// Env carries the environment entries that must be passed to the
// mail-service process. It is safe to merge into the caller's existing env
// map, or to use as-is and append further MAIL_* knobs in place.
Env map[string]string
}
// StartMailServicePersistence brings up one isolated PostgreSQL container,
// provisions the `mail` schema with the `mailservice` role, and returns the
// environment entries that wire the mail-service binary at that container plus
// the supplied Redis master address.
//
// The returned password (`integration`) matches the architectural rule that
// Redis traffic is password-protected; miniredis accepts arbitrary password
// values when its own RequireAuth is not engaged, so the same value works
// against both miniredis and the real `tcredis` runtime.
//
// Cleanup of the container is handled by the underlying StartPostgresContainer
// through `t.Cleanup`; callers do not need to defer anything.
func StartMailServicePersistence(t testing.TB, redisMasterAddr string) MailServicePersistence {
t.Helper()
rt := StartPostgresContainer(t)
if err := rt.EnsureRoleAndSchema(context.Background(), "mail", "mailservice", "mailservice"); err != nil {
t.Fatalf("ensure mail schema/role: %v", err)
}
env := WithPostgres(rt, "MAIL", "mail", "mailservice")
env["MAIL_REDIS_MASTER_ADDR"] = redisMasterAddr
env["MAIL_REDIS_PASSWORD"] = "integration"
return MailServicePersistence{
Postgres: rt,
Env: env,
}
}
@@ -0,0 +1,55 @@
package harness
import (
"context"
"testing"
)
// NotificationServicePersistence captures the per-test persistence
// dependencies of the Notification Service binary: a PostgreSQL container
// hosting the `notification` schema owned by the `notificationservice` role,
// and the Redis credentials that point the service at the caller-supplied
// master address.
type NotificationServicePersistence struct {
// Postgres exposes the started container so tests that need direct SQL
// access to the notification schema (verifying side effects, seeding
// fixtures) can read or write through it.
Postgres *PostgresRuntime
// Env carries the environment entries that must be passed to the
// notification-service process. It is safe to merge into the caller's
// existing env map, or to use as-is and append further NOTIFICATION_*
// knobs in place.
Env map[string]string
}
// StartNotificationServicePersistence brings up one isolated PostgreSQL
// container, provisions the `notification` schema with the
// `notificationservice` role, and returns the environment entries that wire
// the notification-service binary at that container plus the supplied Redis
// master address.
//
// The returned password (`integration`) matches the architectural rule that
// Redis traffic is password-protected; miniredis accepts arbitrary password
// values when its own RequireAuth is not engaged, so the same value works
// against both miniredis and the real `tcredis` runtime.
//
// Cleanup of the container is handled by the underlying
// StartPostgresContainer through `t.Cleanup`; callers do not need to defer
// anything.
func StartNotificationServicePersistence(t testing.TB, redisMasterAddr string) NotificationServicePersistence {
t.Helper()
rt := StartPostgresContainer(t)
if err := rt.EnsureRoleAndSchema(context.Background(), "notification", "notificationservice", "notificationservice"); err != nil {
t.Fatalf("ensure notification schema/role: %v", err)
}
env := WithPostgres(rt, "NOTIFICATION", "notification", "notificationservice")
env["NOTIFICATION_REDIS_MASTER_ADDR"] = redisMasterAddr
env["NOTIFICATION_REDIS_PASSWORD"] = "integration"
return NotificationServicePersistence{
Postgres: rt,
Env: env,
}
}
@@ -0,0 +1,241 @@
package harness
import (
"context"
"fmt"
"net"
"net/url"
"strings"
"sync"
"testing"
"time"
"galaxy/postgres"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
defaultPostgresContainerImage = "postgres:16-alpine"
defaultPostgresDatabase = "galaxy_integration"
defaultPostgresSuperuser = "galaxy_integration"
defaultPostgresSuperPassword = "galaxy_integration"
postgresAdminConnectTimeout = 5 * time.Second
postgresStartupTimeout = 60 * time.Second
)
// PostgresRuntime stores one started real PostgreSQL container together with
// the parsed connection coordinates and the per-test role credentials issued
// by EnsureRoleAndSchema.
//
// The struct is safe to call from concurrent tests because credential lookups
// guard the internal map with a mutex; each test should still keep its own
// PostgresRuntime to preserve container-level isolation.
type PostgresRuntime struct {
Container *tcpostgres.PostgresContainer
baseDSN string
host string
port string
database string
mu sync.Mutex
creds map[string]string
}
// StartPostgresContainer starts one isolated PostgreSQL container and registers
// automatic cleanup for the suite. The container exposes a superuser created
// from the package-level constants; per-service roles are issued lazily by
// EnsureRoleAndSchema.
func StartPostgresContainer(t testing.TB) *PostgresRuntime {
t.Helper()
ctx := context.Background()
container, err := tcpostgres.Run(ctx,
defaultPostgresContainerImage,
tcpostgres.WithDatabase(defaultPostgresDatabase),
tcpostgres.WithUsername(defaultPostgresSuperuser),
tcpostgres.WithPassword(defaultPostgresSuperPassword),
// The default Postgres image emits the "ready to accept connections"
// log line twice during startup: once during temporary bootstrap, once
// after the real listener opens on the mapped port. Waiting for the
// second occurrence avoids racing the temporary instance.
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(postgresStartupTimeout),
),
)
if err != nil {
t.Fatalf("start postgres container: %v", err)
}
t.Cleanup(func() {
if err := testcontainers.TerminateContainer(container); err != nil {
t.Errorf("terminate postgres container: %v", err)
}
})
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("resolve postgres connection string: %v", err)
}
host, port, err := splitHostPort(baseDSN)
if err != nil {
t.Fatalf("parse postgres connection string: %v", err)
}
return &PostgresRuntime{
Container: container,
baseDSN: baseDSN,
host: host,
port: port,
database: defaultPostgresDatabase,
creds: map[string]string{},
}
}
// BaseDSN returns the superuser DSN exposed by the container, suitable for
// administrative tasks such as creating roles or schemas. Callers should
// prefer DSNForSchema for service-scoped access.
func (rt *PostgresRuntime) BaseDSN() string {
return rt.baseDSN
}
// DSNForSchema returns a DSN that connects as role and pins search_path to
// schema. EnsureRoleAndSchema must have populated credentials for role first;
// otherwise the call panics, signalling a test setup bug.
func (rt *PostgresRuntime) DSNForSchema(schema, role string) string {
rt.mu.Lock()
password, ok := rt.creds[role]
rt.mu.Unlock()
if !ok {
panic(fmt.Sprintf(
"harness: DSNForSchema called for role %q with no credentials; call EnsureRoleAndSchema first",
role,
))
}
values := url.Values{}
values.Set("search_path", schema)
values.Set("sslmode", "disable")
dsn := url.URL{
Scheme: "postgres",
User: url.UserPassword(role, password),
Host: net.JoinHostPort(rt.host, rt.port),
Path: "/" + rt.database,
RawQuery: values.Encode(),
}
return dsn.String()
}
// EnsureRoleAndSchema creates role with the given password (idempotent) and a
// schema owned by that role (idempotent), then grants USAGE so the role can
// resolve table references inside it. The credentials are cached for later
// DSNForSchema lookups.
//
// The operation runs through a temporary administrative connection opened
// from BaseDSN; the connection is closed before the call returns.
func (rt *PostgresRuntime) EnsureRoleAndSchema(ctx context.Context, schema, role, password string) error {
if strings.TrimSpace(schema) == "" {
return fmt.Errorf("ensure role and schema: schema must not be empty")
}
if strings.TrimSpace(role) == "" {
return fmt.Errorf("ensure role and schema: role must not be empty")
}
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = rt.baseDSN
cfg.OperationTimeout = postgresAdminConnectTimeout
db, err := postgres.OpenPrimary(ctx, cfg)
if err != nil {
return fmt.Errorf("ensure role and schema: open admin connection: %w", err)
}
defer func() {
_ = db.Close()
}()
createRole := fmt.Sprintf(`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = %s) THEN
CREATE ROLE %s LOGIN PASSWORD %s;
END IF;
END $$;`,
quoteSQLLiteral(role),
quoteSQLIdentifier(role),
quoteSQLLiteral(password),
)
if _, err := db.ExecContext(ctx, createRole); err != nil {
return fmt.Errorf("ensure role and schema: create role %q: %w", role, err)
}
createSchema := fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS %s AUTHORIZATION %s;`,
quoteSQLIdentifier(schema),
quoteSQLIdentifier(role),
)
if _, err := db.ExecContext(ctx, createSchema); err != nil {
return fmt.Errorf("ensure role and schema: create schema %q: %w", schema, err)
}
grantUsage := fmt.Sprintf(`GRANT USAGE ON SCHEMA %s TO %s;`,
quoteSQLIdentifier(schema),
quoteSQLIdentifier(role),
)
if _, err := db.ExecContext(ctx, grantUsage); err != nil {
return fmt.Errorf("ensure role and schema: grant usage on %q to %q: %w", schema, role, err)
}
rt.mu.Lock()
rt.creds[role] = password
rt.mu.Unlock()
return nil
}
// WithPostgres returns env entries pointing the service identified by
// envPrefix at schema/role inside rt. EnsureRoleAndSchema must have populated
// credentials for role first.
//
// The returned map carries only `<envPrefix>_POSTGRES_PRIMARY_DSN`; the other
// per-service Postgres knobs (operation timeout, pool sizes) keep the
// defaults provided by `pkg/postgres.DefaultConfig`.
func WithPostgres(rt *PostgresRuntime, envPrefix, schema, role string) map[string]string {
return map[string]string{
envPrefix + "_POSTGRES_PRIMARY_DSN": rt.DSNForSchema(schema, role),
}
}
// quoteSQLIdentifier wraps name in double quotes and escapes any embedded
// double quote, producing a SQL identifier that survives reserved words such
// as `user`.
func quoteSQLIdentifier(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
// quoteSQLLiteral wraps value in single quotes and escapes any embedded single
// quote, producing a SQL literal usable in DDL statements where parameter
// binding is not available.
func quoteSQLLiteral(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
// splitHostPort extracts host and port from a postgres:// DSN.
func splitHostPort(dsn string) (string, string, error) {
parsed, err := url.Parse(dsn)
if err != nil {
return "", "", fmt.Errorf("parse dsn: %w", err)
}
host := parsed.Hostname()
port := parsed.Port()
if host == "" || port == "" {
return "", "", fmt.Errorf("dsn %q missing host or port", dsn)
}
return host, port, nil
}
@@ -0,0 +1,138 @@
package harness
import (
"context"
"net/url"
"testing"
"time"
"galaxy/postgres"
"github.com/stretchr/testify/require"
)
func TestPostgresContainerRoundTrip(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
rt := StartPostgresContainer(t)
require.NoError(t, rt.EnsureRoleAndSchema(ctx, "smoke_schema", "smoke_role", "smoke_pass"))
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = rt.DSNForSchema("smoke_schema", "smoke_role")
cfg.OperationTimeout = 5 * time.Second
db, err := postgres.OpenPrimary(ctx, cfg)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, db.Close())
})
require.NoError(t, postgres.Ping(ctx, db, cfg.OperationTimeout))
_, err = db.ExecContext(ctx, `CREATE TABLE notes (id serial PRIMARY KEY, body text NOT NULL)`)
require.NoError(t, err)
var insertedID int64
require.NoError(t, db.QueryRowContext(ctx,
`INSERT INTO notes (body) VALUES ($1) RETURNING id`, "hello").Scan(&insertedID))
require.Greater(t, insertedID, int64(0))
var body string
require.NoError(t, db.QueryRowContext(ctx,
`SELECT body FROM notes WHERE id = $1`, insertedID).Scan(&body))
require.Equal(t, "hello", body)
// search_path is honoured: the unqualified table created above resolved
// inside smoke_schema.
var schemaName string
require.NoError(t, db.QueryRowContext(ctx,
`SELECT table_schema FROM information_schema.tables WHERE table_name = 'notes'`,
).Scan(&schemaName))
require.Equal(t, "smoke_schema", schemaName)
}
func TestEnsureRoleAndSchemaIsIdempotent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
rt := StartPostgresContainer(t)
require.NoError(t, rt.EnsureRoleAndSchema(ctx, "schema_x", "role_x", "pass_x"))
require.NoError(t, rt.EnsureRoleAndSchema(ctx, "schema_x", "role_x", "pass_x"))
}
func TestEnsureRoleAndSchemaSupportsReservedWordIdentifiers(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
rt := StartPostgresContainer(t)
// `user` is a SQL reserved word; identifier quoting must keep this working.
require.NoError(t, rt.EnsureRoleAndSchema(ctx, "user", "userservice", "secret"))
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = rt.DSNForSchema("user", "userservice")
cfg.OperationTimeout = 5 * time.Second
db, err := postgres.OpenPrimary(ctx, cfg)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, db.Close())
})
require.NoError(t, postgres.Ping(ctx, db, cfg.OperationTimeout))
}
func TestWithPostgresBuildsPrimaryDSNEnv(t *testing.T) {
t.Parallel()
rt := newRuntimeForTest("127.0.0.1", "55432", "galaxy_integration", "userservice", "s3cr3t!")
env := WithPostgres(rt, "USERSERVICE", "user", "userservice")
require.Len(t, env, 1)
dsn, ok := env["USERSERVICE_POSTGRES_PRIMARY_DSN"]
require.True(t, ok, "missing USERSERVICE_POSTGRES_PRIMARY_DSN entry")
parsed, err := url.Parse(dsn)
require.NoError(t, err)
require.Equal(t, "postgres", parsed.Scheme)
require.Equal(t, "127.0.0.1:55432", parsed.Host)
require.Equal(t, "/galaxy_integration", parsed.Path)
require.Equal(t, "userservice", parsed.User.Username())
password, hasPassword := parsed.User.Password()
require.True(t, hasPassword)
require.Equal(t, "s3cr3t!", password)
query := parsed.Query()
require.Equal(t, "user", query.Get("search_path"))
require.Equal(t, "disable", query.Get("sslmode"))
}
func TestDSNForSchemaPanicsWithoutCredentials(t *testing.T) {
t.Parallel()
rt := newRuntimeForTest("127.0.0.1", "55432", "galaxy_integration", "userservice", "secret")
require.PanicsWithValue(t,
`harness: DSNForSchema called for role "unknown" with no credentials; call EnsureRoleAndSchema first`,
func() {
_ = rt.DSNForSchema("user", "unknown")
},
)
}
// newRuntimeForTest builds a PostgresRuntime without spinning a container.
// It exists only to exercise the pure DSN/env-builder paths.
func newRuntimeForTest(host, port, database, role, password string) *PostgresRuntime {
return &PostgresRuntime{
host: host,
port: port,
database: database,
creds: map[string]string{role: password},
}
}
@@ -0,0 +1,51 @@
package harness
import (
"context"
"testing"
)
// UserServicePersistence captures the per-test persistence dependencies of
// the User Service binary: a PostgreSQL container hosting the `user` schema
// owned by the `userservice` role, and the Redis credentials that point the
// service at the caller-supplied master address.
type UserServicePersistence struct {
// Postgres exposes the started container so tests that need direct SQL
// access to the user schema (verifying side effects, seeding fixtures)
// can read or write through it.
Postgres *PostgresRuntime
// Env carries the environment entries that must be passed to the
// userservice process. It is safe to merge into the caller's existing env
// map, or to use as-is and append further USERSERVICE_* knobs in place.
Env map[string]string
}
// StartUserServicePersistence brings up one isolated PostgreSQL container,
// provisions the `user` schema with the `userservice` role, and returns the
// environment entries that wire the userservice binary at that container plus
// the supplied Redis master address.
//
// The returned password (`integration`) matches the architectural rule that
// Redis traffic is password-protected; miniredis accepts arbitrary password
// values when its own RequireAuth is not engaged, so the same value works
// against both miniredis and the real `tcredis` runtime.
//
// Cleanup of the container is handled by the underlying StartPostgresContainer
// through `t.Cleanup`; callers do not need to defer anything.
func StartUserServicePersistence(t testing.TB, redisMasterAddr string) UserServicePersistence {
t.Helper()
rt := StartPostgresContainer(t)
if err := rt.EnsureRoleAndSchema(context.Background(), "user", "userservice", "userservice"); err != nil {
t.Fatalf("ensure user schema/role: %v", err)
}
env := WithPostgres(rt, "USERSERVICE", "user", "userservice")
env["USERSERVICE_REDIS_MASTER_ADDR"] = redisMasterAddr
env["USERSERVICE_REDIS_PASSWORD"] = "integration"
return UserServicePersistence{
Postgres: rt,
Env: env,
}
}