aboutsummaryrefslogtreecommitdiff
path: root/internal/openmeteo/openmeteo_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/openmeteo/openmeteo_test.go')
-rw-r--r--internal/openmeteo/openmeteo_test.go364
1 files changed, 364 insertions, 0 deletions
diff --git a/internal/openmeteo/openmeteo_test.go b/internal/openmeteo/openmeteo_test.go
new file mode 100644
index 0000000..758920c
--- /dev/null
+++ b/internal/openmeteo/openmeteo_test.go
@@ -0,0 +1,364 @@
+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) }