package repository import ( "context" "fmt" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/scottyah/paragliding/internal/model" ) var testPool *pgxpool.Pool // TestMain sets up the test database connection func TestMain(m *testing.M) { ctx := context.Background() // Get database URL from environment dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { dbURL = "postgres://postgres:postgres@localhost:5432/paragliding_test?sslmode=disable" } // Create connection pool pool, err := pgxpool.New(ctx, dbURL) if err != nil { fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err) os.Exit(1) } testPool = pool // Run migrations if err := runMigrations(ctx, pool); err != nil { fmt.Fprintf(os.Stderr, "Unable to run migrations: %v\n", err) pool.Close() os.Exit(1) } // Run tests code := m.Run() // Cleanup pool.Close() os.Exit(code) } // runMigrations applies the database schema for testing func runMigrations(ctx context.Context, pool *pgxpool.Pool) error { // Drop existing table if it exists _, err := pool.Exec(ctx, "DROP TABLE IF EXISTS weather_observations") if err != nil { return fmt.Errorf("failed to drop table: %w", err) } // Create table createTableSQL := ` CREATE TABLE weather_observations ( id BIGSERIAL PRIMARY KEY, observed_at TIMESTAMPTZ NOT NULL, wind_speed_mph DECIMAL(5,2) NOT NULL, wind_direction INTEGER NOT NULL CHECK (wind_direction >= 0 AND wind_direction < 360), wind_gust_mph DECIMAL(5,2), source VARCHAR(50) DEFAULT 'open-meteo', created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE (observed_at, source) ); CREATE INDEX idx_weather_time ON weather_observations (observed_at DESC); ` _, err = pool.Exec(ctx, createTableSQL) if err != nil { return fmt.Errorf("failed to create table: %w", err) } return nil } // cleanupDatabase removes all data from the weather_observations table func cleanupDatabase(ctx context.Context, pool *pgxpool.Pool) error { _, err := pool.Exec(ctx, "DELETE FROM weather_observations") return err } func TestWeatherRepository_SaveObservations(t *testing.T) { ctx := context.Background() repo := NewWeatherRepository(testPool) // Cleanup before test if err := cleanupDatabase(ctx, testPool); err != nil { t.Fatalf("Failed to cleanup database: %v", err) } t.Run("save single observation", func(t *testing.T) { defer cleanupDatabase(ctx, testPool) now := time.Now().UTC().Truncate(time.Second) observations := []model.WeatherPoint{ { Time: now, WindSpeedMPH: 10.5, WindDirection: 270, WindGustMPH: 15.2, }, } err := repo.SaveObservations(ctx, observations) if err != nil { t.Fatalf("Failed to save observations: %v", err) } // Verify data was saved var count int err = testPool.QueryRow(ctx, "SELECT COUNT(*) FROM weather_observations").Scan(&count) if err != nil { t.Fatalf("Failed to query count: %v", err) } if count != 1 { t.Errorf("Expected 1 observation, got %d", count) } }) t.Run("save multiple observations", func(t *testing.T) { defer cleanupDatabase(ctx, testPool) now := time.Now().UTC().Truncate(time.Second) observations := []model.WeatherPoint{ { Time: now, WindSpeedMPH: 10.5, WindDirection: 270, WindGustMPH: 15.2, }, { Time: now.Add(time.Hour), WindSpeedMPH: 12.0, WindDirection: 280, WindGustMPH: 16.5, }, { Time: now.Add(2 * time.Hour), WindSpeedMPH: 8.5, WindDirection: 260, WindGustMPH: 12.0, }, } err := repo.SaveObservations(ctx, observations) if err != nil { t.Fatalf("Failed to save observations: %v", err) } // Verify count var count int err = testPool.QueryRow(ctx, "SELECT COUNT(*) FROM weather_observations").Scan(&count) if err != nil { t.Fatalf("Failed to query count: %v", err) } if count != 3 { t.Errorf("Expected 3 observations, got %d", count) } }) t.Run("upsert on conflict", func(t *testing.T) { defer cleanupDatabase(ctx, testPool) now := time.Now().UTC().Truncate(time.Second) observations := []model.WeatherPoint{ { Time: now, WindSpeedMPH: 10.5, WindDirection: 270, WindGustMPH: 15.2, }, } // Insert first time err := repo.SaveObservations(ctx, observations) if err != nil { t.Fatalf("Failed to save observations: %v", err) } // Update with different values observations[0].WindSpeedMPH = 11.0 observations[0].WindDirection = 275 observations[0].WindGustMPH = 16.0 err = repo.SaveObservations(ctx, observations) if err != nil { t.Fatalf("Failed to update observations: %v", err) } // Verify still only one record var count int err = testPool.QueryRow(ctx, "SELECT COUNT(*) FROM weather_observations").Scan(&count) if err != nil { t.Fatalf("Failed to query count: %v", err) } if count != 1 { t.Errorf("Expected 1 observation after upsert, got %d", count) } // Verify values were updated var windSpeed float64 err = testPool.QueryRow(ctx, "SELECT wind_speed_mph FROM weather_observations WHERE observed_at = $1", now).Scan(&windSpeed) if err != nil { t.Fatalf("Failed to query wind speed: %v", err) } if windSpeed != 11.0 { t.Errorf("Expected wind speed 11.0, got %f", windSpeed) } }) t.Run("save empty slice", func(t *testing.T) { defer cleanupDatabase(ctx, testPool) err := repo.SaveObservations(ctx, []model.WeatherPoint{}) if err != nil { t.Errorf("Expected no error for empty slice, got %v", err) } }) } func TestWeatherRepository_GetForecast(t *testing.T) { ctx := context.Background() repo := NewWeatherRepository(testPool) // Cleanup and setup test data if err := cleanupDatabase(ctx, testPool); err != nil { t.Fatalf("Failed to cleanup database: %v", err) } now := time.Now().UTC().Truncate(time.Second) observations := []model.WeatherPoint{ { Time: now.Add(-2 * time.Hour), // Before range WindSpeedMPH: 8.0, WindDirection: 250, WindGustMPH: 12.0, }, { Time: now, WindSpeedMPH: 10.5, WindDirection: 270, WindGustMPH: 15.2, }, { Time: now.Add(time.Hour), WindSpeedMPH: 12.0, WindDirection: 280, WindGustMPH: 16.5, }, { Time: now.Add(2 * time.Hour), WindSpeedMPH: 8.5, WindDirection: 260, WindGustMPH: 12.0, }, { Time: now.Add(4 * time.Hour), // After range WindSpeedMPH: 9.0, WindDirection: 255, WindGustMPH: 13.0, }, } if err := repo.SaveObservations(ctx, observations); err != nil { t.Fatalf("Failed to save observations: %v", err) } t.Run("get forecast in range", func(t *testing.T) { start := now end := now.Add(3 * time.Hour) result, err := repo.GetForecast(ctx, start, end) if err != nil { t.Fatalf("Failed to get forecast: %v", err) } if len(result) != 3 { t.Errorf("Expected 3 observations in range, got %d", len(result)) } // Verify ordering (ascending) for i := 0; i < len(result)-1; i++ { if result[i].Time.After(result[i+1].Time) { t.Errorf("Results not ordered correctly: %v > %v", result[i].Time, result[i+1].Time) } } // Verify first result if !result[0].Time.Equal(now) { t.Errorf("Expected first result at %v, got %v", now, result[0].Time) } }) t.Run("get forecast with no results", func(t *testing.T) { start := now.Add(10 * time.Hour) end := now.Add(20 * time.Hour) result, err := repo.GetForecast(ctx, start, end) if err != nil { t.Fatalf("Failed to get forecast: %v", err) } if len(result) != 0 { t.Errorf("Expected 0 observations, got %d", len(result)) } }) // Cleanup cleanupDatabase(ctx, testPool) } func TestWeatherRepository_GetHistorical(t *testing.T) { ctx := context.Background() repo := NewWeatherRepository(testPool) // Cleanup and setup test data if err := cleanupDatabase(ctx, testPool); err != nil { t.Fatalf("Failed to cleanup database: %v", err) } // Use a specific date for testing loc := time.UTC targetDate := time.Date(2024, 1, 15, 0, 0, 0, 0, loc) observations := []model.WeatherPoint{ { Time: time.Date(2024, 1, 14, 23, 0, 0, 0, loc), // Day before WindSpeedMPH: 8.0, WindDirection: 250, WindGustMPH: 12.0, }, { Time: time.Date(2024, 1, 15, 0, 0, 0, 0, loc), // Start of day WindSpeedMPH: 10.5, WindDirection: 270, WindGustMPH: 15.2, }, { Time: time.Date(2024, 1, 15, 12, 0, 0, 0, loc), // Middle of day WindSpeedMPH: 12.0, WindDirection: 280, WindGustMPH: 16.5, }, { Time: time.Date(2024, 1, 15, 23, 59, 59, 0, loc), // End of day WindSpeedMPH: 8.5, WindDirection: 260, WindGustMPH: 12.0, }, { Time: time.Date(2024, 1, 16, 0, 0, 0, 0, loc), // Next day WindSpeedMPH: 9.0, WindDirection: 255, WindGustMPH: 13.0, }, } if err := repo.SaveObservations(ctx, observations); err != nil { t.Fatalf("Failed to save observations: %v", err) } t.Run("get historical for specific day", func(t *testing.T) { result, err := repo.GetHistorical(ctx, targetDate) if err != nil { t.Fatalf("Failed to get historical data: %v", err) } if len(result) != 3 { t.Errorf("Expected 3 observations for the day, got %d", len(result)) } // Verify all results are from the target day for _, obs := range result { if obs.Time.Year() != targetDate.Year() || obs.Time.Month() != targetDate.Month() || obs.Time.Day() != targetDate.Day() { t.Errorf("Observation %v is not from target date %v", obs.Time, targetDate) } } // Verify ordering (ascending) for i := 0; i < len(result)-1; i++ { if result[i].Time.After(result[i+1].Time) { t.Errorf("Results not ordered correctly: %v > %v", result[i].Time, result[i+1].Time) } } }) t.Run("get historical for day with no data", func(t *testing.T) { emptyDate := time.Date(2024, 2, 1, 0, 0, 0, 0, loc) result, err := repo.GetHistorical(ctx, emptyDate) if err != nil { t.Fatalf("Failed to get historical data: %v", err) } if len(result) != 0 { t.Errorf("Expected 0 observations, got %d", len(result)) } }) // Cleanup cleanupDatabase(ctx, testPool) } func TestWeatherRepository_ContextCancellation(t *testing.T) { repo := NewWeatherRepository(testPool) // Cleanup if err := cleanupDatabase(context.Background(), testPool); err != nil { t.Fatalf("Failed to cleanup database: %v", err) } t.Run("save with cancelled context", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately now := time.Now().UTC().Truncate(time.Second) observations := []model.WeatherPoint{ { Time: now, WindSpeedMPH: 10.5, WindDirection: 270, WindGustMPH: 15.2, }, } err := repo.SaveObservations(ctx, observations) if err == nil { t.Error("Expected error with cancelled context, got nil") } }) t.Run("get forecast with cancelled context", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately now := time.Now().UTC() _, err := repo.GetForecast(ctx, now, now.Add(time.Hour)) if err == nil { t.Error("Expected error with cancelled context, got nil") } }) }