package openmeteo import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" ) // serve returns a server replying with a recorded fixture, and records the // query it was asked for so tests can assert on the request as well as the // parsing. func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server { t.Helper() body, err := os.ReadFile(filepath.Join("testdata", fixture)) if err != nil { t.Fatal(err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if gotQuery != nil { *gotQuery = r.URL.RawQuery } w.Header().Set("Content-Type", "application/json") w.Write(body) })) t.Cleanup(srv.Close) return srv } // fixtureStart is the first hour in the recorded forecast, so tests can pin the // clock relative to real recorded data. func fixtureStart(t *testing.T) time.Time { t.Helper() body, err := os.ReadFile(filepath.Join("testdata", "forecast.json")) if err != nil { t.Fatal(err) } var r struct { Hourly struct { Time []string `json:"time"` } `json:"hourly"` } if err := json.Unmarshal(body, &r); err != nil { t.Fatal(err) } when, err := time.Parse("2006-01-02T15:04", r.Hourly.Time[0]) if err != nil { t.Fatal(err) } return when } func pinClock(t *testing.T, at time.Time) { t.Helper() old := now now = func() time.Time { return at.UTC() } t.Cleanup(func() { now = old }) } // The hourly array begins at 00:00 local, so slicing from the front reports the // small hours of this morning rather than the hours ahead -- the bug that made // the Python version report the wrong pollen peak. // // It also pins the timezone contract: the clock is real UTC, and the offset in // the response converts it to the *location's* local time. That is what makes // "prognosis -l krakow" start at the right hour when run from another zone. func TestForecastWindowStartsAtTheCurrentLocalHourNotMidnight(t *testing.T) { offset := fixtureOffset(t) // +02:00 for the recorded Polish forecast if offset == 0 { t.Skip("fixture has no UTC offset; the conversion cannot be exercised") } // 11:00 UTC is 13:00 in a +02:00 location. utcNoon := fixtureStart(t).Add(13*time.Hour - time.Duration(offset)*time.Second) pinClock(t, utcNoon) srv := serve(t, "forecast.json", nil) forecastURL = srv.URL d, err := Forecast(50.0617, 19.9373, 6, "metric", []string{"temperature_2m"}) if err != nil { t.Fatal(err) } if got := d.Rows[0].When.Hour(); got != 13 { t.Fatalf("first row is %02d:00, want 13:00 local (clock was %s UTC, offset %+ds)", got, utcNoon.Format("15:04"), offset) } if len(d.Rows) != 6 { t.Fatalf("got %d rows, want 6", len(d.Rows)) } // Rows must be consecutive hours from there. for i, r := range d.Rows { if want := 13 + i; r.When.Hour() != want { t.Fatalf("row %d is %02d:00, want %02d:00", i, r.When.Hour(), want) } } } // fixtureOffset is the recorded response's utc_offset_seconds. func fixtureOffset(t *testing.T) int { t.Helper() body, err := os.ReadFile(filepath.Join("testdata", "forecast.json")) if err != nil { t.Fatal(err) } var r struct { Offset int `json:"utc_offset_seconds"` } if err := json.Unmarshal(body, &r); err != nil { t.Fatal(err) } return r.Offset } func TestForecastRequestsOnlySelectedFields(t *testing.T) { pinClock(t, fixtureStart(t)) var query string srv := serve(t, "forecast.json", &query) forecastURL = srv.URL if _, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m", "wind_speed_10m"}); err != nil { t.Fatal(err) } hourly := queryValue(t, query, "hourly") for _, want := range []string{"temperature_2m", "wind_speed_10m", "weather_code"} { if !strings.Contains(hourly, want) { t.Errorf("hourly=%q is missing %q", hourly, want) } } // A field nobody asked for costs response size for nothing. for _, unwanted := range []string{"relative_humidity_2m", "pressure_msl", "uv_index"} { if strings.Contains(hourly, unwanted) { t.Errorf("hourly=%q requests %q, which was not selected", hourly, unwanted) } } } func TestForecastUnitsArePassedToTheProvider(t *testing.T) { pinClock(t, fixtureStart(t)) cases := map[string]map[string]string{ "metric": {"temperature_unit": "", "wind_speed_unit": ""}, "imperial": {"temperature_unit": "fahrenheit", "wind_speed_unit": "mph"}, "si": {"wind_speed_unit": "ms"}, } for units, want := range cases { t.Run(units, func(t *testing.T) { var query string srv := serve(t, "forecast.json", &query) forecastURL = srv.URL if _, err := Forecast(50, 21, 3, units, []string{"temperature_2m"}); err != nil { t.Fatal(err) } for k, v := range want { if got := queryValue(t, query, k); got != v { t.Errorf("%s=%q, want %q", k, got, v) } } }) } } func TestForecastRequestsEnoughDaysAndRespectsTheCap(t *testing.T) { pinClock(t, fixtureStart(t)) for hours, wantDays := range map[int]string{12: "2", 24: "3", 48: "4", 400: "16"} { var query string srv := serve(t, "forecast.json", &query) forecastURL = srv.URL if _, err := Forecast(50, 21, hours, "metric", []string{"temperature_2m"}); err != nil { t.Fatal(err) } if got := queryValue(t, query, "forecast_days"); got != wantDays { t.Errorf("%d hours asked for forecast_days=%s, want %s", hours, got, wantDays) } } } func TestForecastParsesDailyAndSun(t *testing.T) { pinClock(t, fixtureStart(t)) srv := serve(t, "forecast.json", nil) forecastURL = srv.URL d, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m"}) if err != nil { t.Fatal(err) } if len(d.Sun) == 0 { t.Error("no sunrise/sunset parsed") } for _, day := range d.Sun { if len(day[0]) != 5 || len(day[1]) != 5 { t.Errorf("sun times must be HH:MM, got %v", day) } } for _, f := range DailyFields { if _, ok := d.Daily[f]; !ok { t.Errorf("daily field %q missing", f) } } } func TestForecastErrors(t *testing.T) { pinClock(t, fixtureStart(t)) t.Run("http error", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "nope", http.StatusInternalServerError) })) defer srv.Close() forecastURL = srv.URL if _, err := Forecast(50, 21, 3, "metric", nil); err == nil { t.Fatal("expected an error on HTTP 500") } }) t.Run("bad json", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("{not json")) })) defer srv.Close() forecastURL = srv.URL if _, err := Forecast(50, 21, 3, "metric", nil); err == nil { t.Fatal("expected an error on malformed JSON") } }) } // Pollen peaks around midday, so a 3-hour request must still look 12 hours // ahead or it understates the day for someone with an allergy. func TestPollenAlwaysLooksAtLeastTwelveHoursAhead(t *testing.T) { start := fixtureStart(t) pinClock(t, start.Add(6*time.Hour)) srv := serve(t, "pollen.json", nil) airURL = srv.URL short, err := Pollen(50, 21, 3, []string{"grass"}) if err != nil { t.Fatal(err) } long, err := Pollen(50, 21, 12, []string{"grass"}) if err != nil { t.Fatal(err) } if short["grass"] != long["grass"] { t.Fatalf("3-hour window gave %v but 12-hour gave %v; the short window must "+ "still cover 12 hours", short["grass"], long["grass"]) } } // Outside Europe the API returns nulls. Treating those as 0.0 reports a // confident "grass 0.0 none" where the truth is "no data". func TestPollenNullsAreAbsentNotZero(t *testing.T) { pinClock(t, fixtureStart(t)) srv := serve(t, "pollen_nulls.json", nil) airURL = srv.URL peaks, err := Pollen(-54.8, -68.3, 12, []string{"grass"}) if err != nil { t.Fatal(err) } if v, ok := peaks["grass"]; ok { t.Fatalf("grass reported as %v, but the fixture has no readings there", v) } } func TestPollenCapsDaysAtTheAirQualityLimit(t *testing.T) { pinClock(t, fixtureStart(t)) var query string srv := serve(t, "pollen.json", &query) airURL = srv.URL if _, err := Pollen(50, 21, 15*24, []string{"grass"}); err != nil { t.Fatal(err) } if got := queryValue(t, query, "forecast_days"); got != "7" { t.Fatalf("forecast_days=%s, want 7 (the air-quality API rejects more)", got) } } func TestPollenWithNoSpeciesMakesNoRequest(t *testing.T) { called := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })) defer srv.Close() airURL = srv.URL peaks, err := Pollen(50, 21, 12, nil) if err != nil || len(peaks) != 0 { t.Fatalf("got %v, %v", peaks, err) } if called { t.Error("requested pollen despite no species being selected") } } func TestGeocodeReportsAlternatives(t *testing.T) { srv := serve(t, "geocode_ambiguous.json", nil) geoURL = srv.URL cands, err := Geocode("krakow") if err != nil { t.Fatal(err) } if len(cands) < 2 { t.Fatalf("got %d candidates, want several: the fixture is ambiguous", len(cands)) } g := cands[0].Geo if g.Country == "" || g.Label == "" { t.Errorf("incomplete geo: %+v", g) } if !strings.Contains(g.Label, g.Country) { t.Errorf("label %q should carry the country %q", g.Label, g.Country) } } // Selecting a place other than the first one is impossible unless its // coordinates survive the call, which is exactly what the old API discarded. func TestGeocodeKeepsCoordinatesForEveryCandidate(t *testing.T) { srv := serve(t, "geocode_ambiguous.json", nil) geoURL = srv.URL cands, err := Geocode("krakow") if err != nil { t.Fatal(err) } for i, c := range cands { if c.Geo.Lat == 0 || c.Geo.Lon == 0 { t.Errorf("candidate %d (%s) has no coordinates, so it cannot be picked", i+1, c.Geo.Label) } if c.Admin1 == "" { t.Errorf("candidate %d (%s) has no region, so the list cannot tell duplicates apart", i+1, c.Geo.Label) } } } func TestGeocodeNoResultsIsAnError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"generationtime_ms":0.1}`)) })) defer srv.Close() geoURL = srv.URL if _, err := Geocode("zzzznowhere"); err == nil { t.Fatal("expected an error when nothing matched") } } func TestWindowStartFallsBackToZeroWhenEverythingIsPast(t *testing.T) { pinClock(t, time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) if got := WindowStart([]string{"2026-08-10T00:00", "2026-08-10T01:00"}, 0); got != 0 { t.Fatalf("got %d, want 0", got) } } func queryValue(t *testing.T, rawQuery, key string) string { t.Helper() for _, pair := range strings.Split(rawQuery, "&") { k, v, _ := strings.Cut(pair, "=") if k == key { unescaped, err := urlUnescape(v) if err != nil { t.Fatal(err) } return unescaped } } return "" } func urlUnescape(s string) (string, error) { return url.QueryUnescape(s) }