454 lines
11 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|