Files
paragliding/backend/internal/repository/repository_test.go
2026-01-03 14:16:16 -08:00

454 lines
11 KiB
Go

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")
}
})
}