//go:build integration package inttest import ( "context" "database/sql" "fmt" "net/url" "os" "path/filepath" "runtime" "testing" "time" testcontainers "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" "scrabble/backend/internal/engine" "scrabble/backend/internal/postgres" ) // testDB is the shared, migrated pool every integration test runs against. It is // hydrated once by TestMain. var testDB *sql.DB // testRegistry holds the committed dictionaries the game service plays over, // loaded once under testDictVersion (the version the game tests pin). var testRegistry *engine.Registry // testDictVersion is the single dictionary version the integration suite loads. const testDictVersion = "v1" const ( pgImage = "postgres:17-alpine" pgDatabase = "scrabble_backend" pgUser = "scrabble" pgPassword = "scrabble" pgSchema = "backend" containerStartup = 90 * time.Second containerShutdown = 30 * time.Second ) // TestMain starts one Postgres container, applies the migrations, and shares the // resulting pool with every test. Any setup failure aborts the suite loudly // (exit 1) rather than skipping coverage. func TestMain(m *testing.M) { code, err := runSuite(m) if err != nil { fmt.Fprintln(os.Stderr, "inttest:", err) os.Exit(1) } os.Exit(code) } func runSuite(m *testing.M) (int, error) { ctx := context.Background() container, err := tcpostgres.Run(ctx, pgImage, tcpostgres.WithDatabase(pgDatabase), tcpostgres.WithUsername(pgUser), tcpostgres.WithPassword(pgPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(containerStartup), ), ) if err != nil { return 0, fmt.Errorf("start postgres container: %w", err) } defer func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), containerShutdown) defer cancel() if termErr := container.Terminate(shutdownCtx); termErr != nil { fmt.Fprintln(os.Stderr, "inttest: terminate container:", termErr) } }() baseDSN, err := container.ConnectionString(ctx, "sslmode=disable") if err != nil { return 0, fmt.Errorf("resolve container dsn: %w", err) } dsn, err := withSearchPath(baseDSN, pgSchema) if err != nil { return 0, err } cfg := postgres.DefaultConfig() cfg.DSN = dsn db, err := postgres.Open(ctx, cfg) if err != nil { return 0, fmt.Errorf("open pool: %w", err) } defer func() { _ = db.Close() }() if err := postgres.ApplyMigrations(ctx, db); err != nil { return 0, fmt.Errorf("apply migrations: %w", err) } reg, err := engine.Open(dictDir(), testDictVersion) if err != nil { return 0, fmt.Errorf("load dictionaries: %w", err) } defer func() { _ = reg.Close() }() testDB = db testRegistry = reg return m.Run(), nil } // dictDir resolves the committed DAWG directory: BACKEND_DICT_DIR when set (CI), // otherwise the sibling scrabble-solver checkout located relative to this file. func dictDir() string { if dir := os.Getenv("BACKEND_DICT_DIR"); dir != "" { return dir } _, file, _, _ := runtime.Caller(0) return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "scrabble-solver", "dawg") } // withSearchPath rewrites dsn so every connection pins search_path to schema. func withSearchPath(dsn, schema string) (string, error) { u, err := url.Parse(dsn) if err != nil { return "", fmt.Errorf("parse dsn: %w", err) } q := u.Query() q.Set("search_path", schema) if q.Get("sslmode") == "" { q.Set("sslmode", "disable") } u.RawQuery = q.Encode() return u.String(), nil }