feat: use postgres
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user