package server import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) // saveEnvVars saves the current state of relevant environment variables func saveEnvVars() map[string]string { vars := []string{ "DB_HOST", "DB_PORT", "DB_USERNAME", "DB_PASSWORD", "DB_NAME", "MUSIC_PATH", "CHARACTERS_PATH", "PORT", "DISABLE_SESSION_AUTH", "LOG_LEVEL", } saved := make(map[string]string) for _, v := range vars { saved[v] = os.Getenv(v) } return saved } // restoreEnvVars restores environment variables from a saved state func restoreEnvVars(saved map[string]string) { for k, v := range saved { if v == "" { os.Unsetenv(k) } else { os.Setenv(k, v) } } } // checkTableExists returns true if the specified table exists in the database func checkTableExists(ctx context.Context, pool *pgxpool.Pool, tableName string) (bool, error) { var exists bool err := pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 ) `, tableName).Scan(&exists) return exists, err } // loadSQLDumpIntoDatabase loads a SQL dump file into a PostgreSQL database func loadSQLDumpIntoDatabase(t *testing.T, ctx context.Context, container testcontainers.Container, dbname, dumpPath string) error { // Copy the SQL dump file to the container basename := filepath.Base(dumpPath) targetPath := "/tmp/" + basename if err := container.CopyFileToContainer(ctx, dumpPath, targetPath, 0644); err != nil { return fmt.Errorf("failed to copy dump file to container: %w", err) } // Execute psql to load the dump as the postgres superuser // We execute psql inside the container, so we use localhost and port 5432 (the container's internal port) // The postgres user is the superuser created by the Docker image // Use sh instead of bash as postgres:15-alpine uses Alpine Linux cmd := []string{"sh", "-c", "PGPASSWORD=postgres psql -h localhost -U postgres -d " + dbname + " -f " + targetPath + " -v ON_ERROR_STOP=1"} exitCode, reader, err := container.Exec(ctx, cmd) if err != nil { return fmt.Errorf("failed to execute psql: %w", err) } // Read the output output, err := io.ReadAll(reader) if err != nil { return fmt.Errorf("failed to read psql output: %w", err) } if exitCode != 0 { return fmt.Errorf("psql exited with code %d, output: %s", exitCode, string(output)) } t.Logf("Successfully loaded SQL dump from %s into database %s", dumpPath, dbname) return nil } // createRoleInContainer creates a role in the PostgreSQL container func createRoleInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, roleName, superuserPassword string) error { // Get the mapped port for the container mappedPort, err := container.MappedPort(ctx, "5432") if err != nil { return fmt.Errorf("failed to get mapped port: %w", err) } // Connect to the postgres database to create the role connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable", mappedPort.Port()) pool, err := pgxpool.New(ctx, connStr) if err != nil { return fmt.Errorf("failed to connect to postgres database: %w", err) } defer pool.Close() // Check if role already exists var exists bool err = pool.QueryRow(ctx, "SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists) if err == nil && exists { return nil // Role already exists } // Clear any error from the role check (role might not exist, which is expected) // Create the role _, err = pool.Exec(ctx, fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD 'testpass'", roleName)) if err != nil { return fmt.Errorf("failed to create role %s: %w", roleName, err) } return nil } // createDatabaseInContainer creates a new database in the PostgreSQL container func createDatabaseInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, dbname string) error { // Get the mapped port for the container mappedPort, err := container.MappedPort(ctx, "5432") if err != nil { return fmt.Errorf("failed to get mapped port: %w", err) } // Add a small delay to ensure PostgreSQL is fully ready time.Sleep(2 * time.Second) // Connect to the default 'postgres' database to create the new database // Use localhost and the mapped port connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable", mappedPort.Port()) pool, err := pgxpool.New(ctx, connStr) if err != nil { return fmt.Errorf("failed to connect to postgres database: %w", err) } defer pool.Close() // Check if database already exists and drop it if so var exists int err = pool.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists) if err == nil && exists == 1 { _, err = pool.Exec(ctx, "DROP DATABASE "+dbname+" WITH (FORCE)") if err != nil { return fmt.Errorf("failed to drop existing database: %w", err) } t.Logf("Dropped existing database %s", dbname) } // Create the new database _, err = pool.Exec(ctx, "CREATE DATABASE "+dbname) if err != nil { return fmt.Errorf("failed to create database: %w", err) } t.Logf("Created database %s", dbname) return nil } // startPostgreSQLContainer starts a PostgreSQL container and returns it func startPostgreSQLContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) { req := testcontainers.ContainerRequest{ Image: "postgres:15-alpine", ExposedPorts: []string{"5432/tcp"}, Env: map[string]string{ "POSTGRES_PASSWORD": "postgres", "POSTGRES_DB": "postgres", }, WaitingFor: wait.ForLog("database system is ready to accept connections"), } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, fmt.Errorf("failed to start container: %w", err) } return container, nil } // TestMusicRefDump tests that the application can start with music_ref_dump.sql and migrations work func TestMusicRefDump(t *testing.T) { testSQLDump(t, "music_ref_dump.sql", "music_server_ref_test") } // TestMusicTestDump tests that the application can start with music_test_dump.sql and migrations work func TestMusicTestDump(t *testing.T) { testSQLDump(t, "music_test_dump.sql", "music_server_test_dump_test") } // TestMusicProdDump tests that the application can start with music_prod_dump.sql and migrations work func TestMusicProdDump(t *testing.T) { testSQLDump(t, "music_prod_dump.sql", "music_server_prod_test") } // testSQLDump is the common test function for all SQL dump tests func testSQLDump(t *testing.T, dumpFile, dbname string) { ctx := context.Background() // Save original environment variables originalEnv := saveEnvVars() defer restoreEnvVars(originalEnv) // Start PostgreSQL container t.Log("Starting PostgreSQL container...") container, err := startPostgreSQLContainer(t, ctx) require.NoError(t, err) defer func() { t.Log("Stopping container...") container.Terminate(ctx) }() // Get container connection details endpoint, err := container.Endpoint(ctx, "") require.NoError(t, err) t.Logf("Container running at: %s", endpoint) // Create database for this test t.Logf("Creating database %s...", dbname) err = createDatabaseInContainer(t, ctx, container, dbname) require.NoError(t, err) // Create common roles that might be referenced in dump files err = createRoleInContainer(t, ctx, container, "sansan", "postgres") if err != nil { t.Logf("Note: Could not create role 'sansan': %v (may not be needed)", err) } // Load the SQL dump // The dump files are in the project root // Get the absolute path to the dump file absDumpPath, err := filepath.Abs(filepath.Join("..", "..", dumpFile)) require.NoError(t, err) if _, err := os.Stat(absDumpPath); os.IsNotExist(err) { // Try relative to current working directory absDumpPath = filepath.Join(".", dumpFile) if _, err := os.Stat(absDumpPath); os.IsNotExist(err) { absDumpPath = dumpFile // Try as-is } } t.Logf("Loading SQL dump from: %s", absDumpPath) err = loadSQLDumpIntoDatabase(t, ctx, container, dbname, absDumpPath) require.NoError(t, err) // ============================================ // PRE-MIGRATION CHECK // ============================================ t.Log("Running pre-migration checks...") // For connecting to the database from the test, we need to use the container's endpoint // But the container's endpoint might be localhost:random_port // We need to parse it to get host and port // endpoint format: "host:port" e.g., "localhost:12345" or "127.0.0.1:23456" // Actually, for testcontainers with Docker, the endpoint should be localhost:mapped_port // Let's extract the mapped port // Get the mapped port from the container mappedPort, err := container.MappedPort(ctx, "5432") require.NoError(t, err) t.Logf("Container mapped port: %s", mappedPort) // Use localhost as the host since we're on the same machine dbHost := "localhost" dbPort := mappedPort.Port() // Create a connection to the database to check pre-migration state connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", dbHost, dbPort, "postgres", "postgres", dbname) pool, err := pgxpool.New(ctx, connStr) require.NoError(t, err) defer pool.Close() // Check that game table exists (pre-migration state) gameExists, err := checkTableExists(ctx, pool, "game") require.NoError(t, err) assert.True(t, gameExists, "Before migration: game table should exist") // Check that soundtrack table does NOT exist soundtrackExists, err := checkTableExists(ctx, pool, "soundtrack") require.NoError(t, err) assert.False(t, soundtrackExists, "Before migration: soundtrack table should NOT exist") t.Log("✓ Pre-migration check passed: game table exists, soundtrack table does not") // ============================================ // START APPLICATION (triggers migrations) // ============================================ t.Log("Starting application...") // Set environment variables for the server // The application needs to connect to the container's mapped port os.Setenv("DB_HOST", dbHost) os.Setenv("DB_PORT", dbPort) os.Setenv("DB_USERNAME", "postgres") os.Setenv("DB_PASSWORD", "postgres") os.Setenv("DB_NAME", dbname) os.Setenv("MUSIC_PATH", "./testMusic") os.Setenv("CHARACTERS_PATH", "./testCharacters") // Use different ports for each test to allow for sequential execution // and to make port detection reliable portMap := map[string]string{ "music_server_ref_test": "8082", "music_server_test_dump_test": "8083", "music_server_prod_test": "8084", } appPort := portMap[dbname] if appPort == "" { appPort = "8085" // fallback } os.Setenv("PORT", appPort) os.Setenv("DISABLE_SESSION_AUTH", "true") os.Setenv("LOG_LEVEL", "error") // Create server instance (this runs migrations automatically) serverInstance := NewServerInstance() httpServer := serverInstance.HTTPServer() // Start HTTP server in background go func() { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { t.Logf("Server error: %v", err) } }() defer func() { t.Log("Stopping HTTP server...") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() httpServer.Shutdown(ctx) }() // Wait for server to start by polling /health endpoint // Use the fixed port we configured for the application healthURL := fmt.Sprintf("http://localhost:%s/health", appPort) maxAttempts := 30 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := http.Get(healthURL) if err == nil { defer resp.Body.Close() if resp.StatusCode == http.StatusOK { var healthData map[string]string json.NewDecoder(resp.Body).Decode(&healthData) if healthData["status"] == "up" { t.Logf("Server is healthy on %s after %d attempts", healthURL, attempt) break } } } if attempt == maxAttempts { require.Fail(t, "Server did not become healthy within timeout") } time.Sleep(500 * time.Millisecond) } // ============================================ // POST-MIGRATION CHECK // ============================================ t.Log("Running post-migration checks...") // Check that game table no longer exists gameExistsAfter, err := checkTableExists(ctx, pool, "game") require.NoError(t, err) assert.False(t, gameExistsAfter, "After migration: game table should NOT exist") // Check that soundtrack table now exists soundtrackExistsAfter, err := checkTableExists(ctx, pool, "soundtrack") require.NoError(t, err) assert.True(t, soundtrackExistsAfter, "After migration: soundtrack table should exist") // Verify data was preserved var soundtrackCount int err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount) require.NoError(t, err) assert.Greater(t, soundtrackCount, 0, "Migration should preserve data: soundtrack count should be > 0") t.Logf("✓ Post-migration check passed: game table gone, soundtrack table exists with %d records", soundtrackCount) // ============================================ // HEALTH CHECK // ============================================ t.Log("Running health check...") resp, err := http.Get(healthURL) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) var healthData map[string]string err = json.NewDecoder(resp.Body).Decode(&healthData) require.NoError(t, err) assert.Equal(t, "up", healthData["status"]) t.Log("✓ Health check passed") t.Log("✓ Test completed successfully!") }