412 lines
14 KiB
Go
412 lines
14 KiB
Go
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!")
|
|
}
|