diff options
Diffstat (limited to 'internal/openmeteo/openmeteo_test.go')
| -rw-r--r-- | internal/openmeteo/openmeteo_test.go | 364 |
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) } |
