This commit is contained in:
2026-01-03 14:16:16 -08:00
commit 1f0e678d47
71 changed files with 16127 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
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")
}
})
}

View File

@@ -0,0 +1,148 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/scottyah/paragliding/internal/model"
)
// WeatherRepository handles database operations for weather observations
type WeatherRepository struct {
pool *pgxpool.Pool
}
// NewWeatherRepository creates a new weather repository
func NewWeatherRepository(pool *pgxpool.Pool) *WeatherRepository {
return &WeatherRepository{
pool: pool,
}
}
// SaveObservations performs a bulk upsert of weather observations
// Uses batch inserts for efficiency and ON CONFLICT to handle duplicates
func (r *WeatherRepository) SaveObservations(ctx context.Context, observations []model.WeatherPoint) error {
if len(observations) == 0 {
return nil
}
// Use a batch for efficient bulk insert
batch := &pgx.Batch{}
query := `
INSERT INTO weather_observations (observed_at, wind_speed_mph, wind_direction, wind_gust_mph, source)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (observed_at, source)
DO UPDATE SET
wind_speed_mph = EXCLUDED.wind_speed_mph,
wind_direction = EXCLUDED.wind_direction,
wind_gust_mph = EXCLUDED.wind_gust_mph,
created_at = NOW()
`
for _, obs := range observations {
// Normalize wind direction to 0-359 range
windDir := obs.WindDirection
for windDir < 0 {
windDir += 360
}
for windDir >= 360 {
windDir -= 360
}
batch.Queue(query, obs.Time, obs.WindSpeedMPH, windDir, obs.WindGustMPH, "open-meteo")
}
// Execute the batch
br := r.pool.SendBatch(ctx, batch)
defer br.Close()
// Process all batch results to ensure they complete
for i := 0; i < len(observations); i++ {
_, err := br.Exec()
if err != nil {
return fmt.Errorf("failed to save observation %d: %w", i, err)
}
}
return nil
}
// GetForecast retrieves weather observations within a time range
// Results are ordered by time ascending for forecast display
func (r *WeatherRepository) GetForecast(ctx context.Context, start, end time.Time) ([]model.WeatherPoint, error) {
query := `
SELECT observed_at, wind_speed_mph, wind_direction, wind_gust_mph
FROM weather_observations
WHERE observed_at >= $1 AND observed_at <= $2
ORDER BY observed_at ASC
`
rows, err := r.pool.Query(ctx, query, start, end)
if err != nil {
return nil, fmt.Errorf("failed to query forecast: %w", err)
}
defer rows.Close()
var observations []model.WeatherPoint
for rows.Next() {
var obs model.WeatherPoint
err := rows.Scan(&obs.Time, &obs.WindSpeedMPH, &obs.WindDirection, &obs.WindGustMPH)
if err != nil {
return nil, fmt.Errorf("failed to scan observation: %w", err)
}
observations = append(observations, obs)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}
return observations, nil
}
// GetHistorical retrieves all weather observations for a specific day
// Returns data for the entire day in the system's timezone
func (r *WeatherRepository) GetHistorical(ctx context.Context, date time.Time) ([]model.WeatherPoint, error) {
// Get start and end of the day
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
end := start.Add(24 * time.Hour)
query := `
SELECT observed_at, wind_speed_mph, wind_direction, wind_gust_mph
FROM weather_observations
WHERE observed_at >= $1 AND observed_at < $2
ORDER BY observed_at ASC
`
rows, err := r.pool.Query(ctx, query, start, end)
if err != nil {
return nil, fmt.Errorf("failed to query historical data: %w", err)
}
defer rows.Close()
var observations []model.WeatherPoint
for rows.Next() {
var obs model.WeatherPoint
err := rows.Scan(&obs.Time, &obs.WindSpeedMPH, &obs.WindDirection, &obs.WindGustMPH)
if err != nil {
return nil, fmt.Errorf("failed to scan observation: %w", err)
}
observations = append(observations, obs)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}
return observations, nil
}
// Close closes the database pool
func (r *WeatherRepository) Close() {
r.pool.Close()
}