package weather import ( "context" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // testdata holds real Open-Meteo responses for Ghent. func fakeOpenMeteo(t *testing.T, calls *atomic.Int32) *httptest.Server { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) switch r.URL.Path { case "/v1/forecast": if lat := r.URL.Query().Get("latitude"); lat != "51.05" { t.Errorf("latitude sent = %q, want it rounded to 51.05", lat) } http.ServeFile(w, r, "testdata/forecast.json") case "/v1/search": http.ServeFile(w, r, "testdata/geocode.json") } })) t.Cleanup(srv.Close) return srv } func TestForecast(t *testing.T) { var calls atomic.Int32 srv := fakeOpenMeteo(t, &calls) c := &Client{ForecastURL: srv.URL + "/v1/forecast"} f, err := c.Forecast(context.Background(), 51.0512345, 3.7212345) if err != nil { t.Fatal(err) } if len(f.Hours) != 24 || f.Now.Time.IsZero() || f.Sunset.Before(f.Now.Time) { t.Errorf("forecast = now %v, %d hours, sunset %v", f.Now.Time, len(f.Hours), f.Sunset) } if _, off := f.Now.Time.Zone(); off != 2*3600 { t.Errorf("zone offset = %d, want Brussels summer time", off) } c.Forecast(context.Background(), 51.05, 3.72) if calls.Load() != 1 { t.Errorf("second call within 15 min should hit the cache, made %d requests", calls.Load()) } } func TestGeocode(t *testing.T) { var calls atomic.Int32 srv := fakeOpenMeteo(t, &calls) p, err := (&Client{GeocodeURL: srv.URL + "/v1/search"}).Geocode(context.Background(), "Gent") if err != nil || p.Name != "Ghent, Belgium" || p.Lat < 51 || p.Lat > 51.1 { t.Errorf("geocode = %+v, %v", p, err) } } func TestCompassAndIcons(t *testing.T) { for deg, want := range map[int]string{0: "N", 44: "NE", 276: "W", 350: "N", -90: "W"} { if got := Compass(deg); got != want { t.Errorf("Compass(%d) = %s, want %s", deg, got, want) } } if (Hour{Code: 0, IsDay: false}).Icon() != "🌙" || (Hour{Code: 63, IsDay: false}).Icon() != "🌧️" { t.Error("night icons wrong") } } // busy answers 503 like an overloaded Open-Meteo, and serves the fallbacks. func busy(t *testing.T, openMeteoCalls *atomic.Int32) *httptest.Server { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/forecast", "/v1/search": openMeteoCalls.Add(1) http.Error(w, `{"reason":"The service is overloaded","error":true}`, http.StatusServiceUnavailable) case "/metno": if r.Header.Get("User-Agent") == "" { http.Error(w, "403", http.StatusForbidden) return } http.ServeFile(w, r, "testdata/metno.json") case "/nominatim": http.ServeFile(w, r, "testdata/nominatim.json") } })) t.Cleanup(srv.Close) return srv } func TestForecastFallsBackToMetNo(t *testing.T) { var calls atomic.Int32 srv := busy(t, &calls) c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: srv.URL + "/metno", now: func() time.Time { return time.Date(2026, 10, 11, 17, 10, 0, 0, time.UTC) }} f, err := c.Forecast(context.Background(), 51.05, 3.72) if err != nil { t.Fatal(err) } if calls.Load() != 2 { t.Errorf("open-meteo tried %d times, want 2 (one retry)", calls.Load()) } if f.Source != "MET Norway" || !f.GustsEstimated || len(f.Hours) != 24 { t.Errorf("source %q, estimated gusts %v, %d hours", f.Source, f.GustsEstimated, len(f.Hours)) } h := f.Hours[0] if h.PrecipProb != -1 || h.Chance() != "–" || h.Gusts <= h.Wind || h.Temp == 0 { t.Errorf("first hour = %+v", h) } if !f.Hours[0].Time.Equal(time.Date(2026, 10, 11, 17, 0, 0, 0, time.UTC)) { t.Errorf("first hour at %v, want 17:00 UTC", f.Hours[0].Time) } if !h.IsDay || f.Hours[1].IsDay { // sunset in Ghent is 19:01 CEST t.Errorf("19:00 CEST should be day, 20:00 night: %v %v", h.IsDay, f.Hours[1].IsDay) } } func TestStaleForecastBeatsNothing(t *testing.T) { var calls atomic.Int32 srv := busy(t, &calls) c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: "-"} if _, err := c.Forecast(context.Background(), 51.05, 3.72); err == nil { t.Fatal("expected an error with every source down and no cache") } c.cache = map[string]cached{"51.05,3.72": {Forecast{Source: "Open-Meteo"}, time.Now().Add(-time.Hour)}} f, err := c.Forecast(context.Background(), 51.05, 3.72) if err != nil || !strings.HasPrefix(f.Source, "Open-Meteo, from ") { t.Errorf("stale forecast = %q, %v", f.Source, err) } } func TestGeocodeFallsBackToNominatim(t *testing.T) { var calls atomic.Int32 srv := busy(t, &calls) p, err := (&Client{GeocodeURL: srv.URL + "/v1/search", NominatimURL: srv.URL + "/nominatim"}).Geocode(context.Background(), "Gent") if err != nil || !strings.HasSuffix(p.Name, ", Belgium") || p.Lat < 51 || p.Lat > 51.1 { t.Errorf("geocode = %+v, %v", p, err) } } func TestSunTimesMatchOpenMeteo(t *testing.T) { // Open-Meteo says 08:01 / 19:01 CEST for Ghent on 11 Oct 2026. cest := time.FixedZone("CEST", 2*3600) rise, set := sunTimes(time.Date(2026, 10, 11, 12, 0, 0, 0, time.UTC), 51.05, 3.72) for _, c := range []struct { got, want time.Time }{{rise, time.Date(2026, 10, 11, 8, 1, 0, 0, cest)}, {set, time.Date(2026, 10, 11, 19, 1, 0, 0, cest)}} { if d := c.got.Sub(c.want); d < -3*time.Minute || d > 3*time.Minute { t.Errorf("got %v, want %v", c.got.In(cest), c.want) } } } func TestSymbolCode(t *testing.T) { for sym, want := range map[string]int{ "clearsky_night": 0, "partlycloudy_day": 2, "heavyrainshowers_day": 82, "lightrainshowers_night": 80, "rain": 63, "lightsleet": 66, "heavyrainandthunder": 95, "snowshowers_day": 85, "somethingnew": 3, } { if got := symbolCode(sym); got != want { t.Errorf("symbolCode(%q) = %d, want %d", sym, got, want) } } }