init
This commit is contained in:
453
backend/internal/repository/repository_test.go
Normal file
453
backend/internal/repository/repository_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
148
backend/internal/repository/weather.go
Normal file
148
backend/internal/repository/weather.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user