package weather import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" ) func TestOpenMeteoProvider(t *testing.T) { var geoReqCount, weatherReqCount int mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/search" { geoReqCount++ name, lat, lon := "Moscow", 55.7558, 37.6173 json.NewEncoder(w).Encode(geoResponse{ Results: []geoResult{{ Name: &name, Latitude: &lat, Longitude: &lon, Country: "Russia", }}, }) return } if r.URL.Path == "/v1/forecast" { weatherReqCount++ json.NewEncoder(w).Encode(validForecast(22.5, 0, 3.2)) return } w.WriteHeader(http.StatusNotFound) })) defer mock.Close() p := NewOpenMeteoProvider() transport := &mockTransport{ geoURL: mock.URL + "/v1/search", forecastURL: mock.URL + "/v1/forecast", } p.SetClient(&http.Client{Transport: transport}) w, err := p.CurrentWeather(context.Background(), "Moscow") if err != nil { t.Fatalf("CurrentWeather: %v", err) } if w.Location != "Moscow" { t.Errorf("location = %q, want Moscow", w.Location) } if w.Temperature != 22.5 { t.Errorf("temperature = %f, want 22.5", w.Temperature) } if w.Condition != "ясно" { t.Errorf("condition = %q, want ясно", w.Condition) } } func validForecast(temperature float64, code int, wind float64) forecastResponse { return forecastResponse{CurrentWeather: ¤tWeather{ Temperature: &temperature, WMO: &code, WindSpeed: &wind, }} } func validGeo(name string, lat, lon float64) geoResponse { return geoResponse{Results: []geoResult{{Name: &name, Latitude: &lat, Longitude: &lon, Country: "Russia"}}} } type mockTransport struct { geoURL string forecastURL string } func (t *mockTransport) RoundTrip(r *http.Request) (*http.Response, error) { var targetURL string if r.URL.Host == "geocoding-api.open-meteo.com" { targetURL = t.geoURL + "?" + r.URL.RawQuery } else if r.URL.Host == "api.open-meteo.com" { targetURL = t.forecastURL + "?" + r.URL.RawQuery } else { return nil, fmt.Errorf("unexpected host: %s", r.URL.Host) } req, _ := http.NewRequestWithContext(r.Context(), r.Method, targetURL, nil) return http.DefaultTransport.RoundTrip(req) } // TestOpenMeteoProvider_ForecastHTTPError — a non-200 forecast reply must not // decode into a zero-value Weather{Temperature: 0, Condition: "неизвестно"} // reported as success (Vikunja #589). It must be a distinct error, not // ErrLocationUnknown, so the caller does not blame the owner's city. func TestOpenMeteoProvider_ForecastHTTPError(t *testing.T) { mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/search" { json.NewEncoder(w).Encode(validGeo("Moscow", 55.7558, 37.6173)) return } w.WriteHeader(http.StatusInternalServerError) })) defer mock.Close() p := NewOpenMeteoProvider() p.SetClient(&http.Client{Transport: &mockTransport{ geoURL: mock.URL + "/v1/search", forecastURL: mock.URL + "/v1/forecast", }}) w, err := p.CurrentWeather(context.Background(), "Moscow") if err == nil { t.Fatalf("CurrentWeather: want error, got Weather{%+v}", w) } if errors.Is(err, ErrLocationUnknown) { t.Fatalf("CurrentWeather: want a service error, got ErrLocationUnknown: %v", err) } if !strings.Contains(err.Error(), "500") { t.Errorf("CurrentWeather: error %q does not name the status", err.Error()) } } func TestOpenMeteoProviderRejectsIncompleteOrInvalidForecast(t *testing.T) { tests := []struct { name string body string }{ {name: "empty object", body: `{}`}, {name: "empty current weather", body: `{"current_weather":{}}`}, {name: "missing wind", body: `{"current_weather":{"temperature":12,"weathercode":1}}`}, {name: "temperature range", body: `{"current_weather":{"temperature":100,"weathercode":1,"windspeed":3}}`}, {name: "weather code range", body: `{"current_weather":{"temperature":12,"weathercode":100,"windspeed":3}}`}, {name: "wind range", body: `{"current_weather":{"temperature":12,"weathercode":1,"windspeed":-1}}`}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/search" { _ = json.NewEncoder(w).Encode(validGeo("Moscow", 55.7558, 37.6173)) return } _, _ = w.Write([]byte(tc.body)) })) defer mock.Close() p := NewOpenMeteoProvider() p.SetClient(&http.Client{Transport: &mockTransport{ geoURL: mock.URL + "/v1/search", forecastURL: mock.URL + "/v1/forecast", }}) if got, err := p.CurrentWeather(context.Background(), "Moscow"); err == nil { t.Fatalf("invalid forecast returned %+v", got) } }) } } func TestOpenMeteoProviderBoundsBothResponseBodies(t *testing.T) { for _, endpoint := range []string{"geocode", "forecast"} { t.Run(endpoint, func(t *testing.T) { mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { oversize := endpoint == "geocode" && r.URL.Path == "/v1/search" || endpoint == "forecast" && r.URL.Path == "/v1/forecast" if oversize { _, _ = w.Write([]byte(strings.Repeat("x", int(maxResponseBytes+1)))) return } if r.URL.Path == "/v1/search" { _ = json.NewEncoder(w).Encode(validGeo("Moscow", 55.7558, 37.6173)) } else { _ = json.NewEncoder(w).Encode(validForecast(12, 1, 3)) } })) defer mock.Close() p := NewOpenMeteoProvider() p.SetClient(&http.Client{Transport: &mockTransport{ geoURL: mock.URL + "/v1/search", forecastURL: mock.URL + "/v1/forecast", }}) if _, err := p.CurrentWeather(context.Background(), "Moscow"); err == nil || !strings.Contains(err.Error(), "response exceeds") { t.Fatalf("oversize %s error = %v", endpoint, err) } }) } } // TestOpenMeteoProvider_GeocodeHTTPError — a non-200 geocode reply must not be // read as "no such city" (ErrLocationUnknown). A 500 is a service outage, and // the owner hears a different sentence for each (Vikunja #589). func TestOpenMeteoProvider_GeocodeHTTPError(t *testing.T) { mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer mock.Close() p := NewOpenMeteoProvider() p.SetClient(&http.Client{Transport: &mockTransport{ geoURL: mock.URL + "/v1/search", forecastURL: mock.URL + "/v1/forecast", }}) _, err := p.CurrentWeather(context.Background(), "Уфе") if err == nil { t.Fatal("CurrentWeather: want error") } if errors.Is(err, ErrLocationUnknown) { t.Fatalf("CurrentWeather: want a service error, got ErrLocationUnknown: %v", err) } if !strings.Contains(err.Error(), "500") { t.Errorf("CurrentWeather: error %q does not name the status", err.Error()) } } // TestOpenMeteoProvider_GeocodeEmptyResult — a genuine 200 reply with no // results is still ErrLocationUnknown, distinct from a service failure. func TestOpenMeteoProvider_GeocodeEmptyResult(t *testing.T) { mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(geoResponse{Results: nil}) })) defer mock.Close() p := NewOpenMeteoProvider() p.SetClient(&http.Client{Transport: &mockTransport{ geoURL: mock.URL + "/v1/search", forecastURL: mock.URL + "/v1/forecast", }}) _, err := p.CurrentWeather(context.Background(), "Атлантида") if !errors.Is(err, ErrLocationUnknown) { t.Fatalf("CurrentWeather: want ErrLocationUnknown, got %v", err) } } func TestStubProvider(t *testing.T) { p := NewStubProvider() _, err := p.CurrentWeather(context.Background(), "Moscow") if err != ErrNotConfigured { t.Fatalf("StubProvider: want ErrNotConfigured, got %v", err) } } // TestLocationCandidates — he speaks the prepositional case and the geocoder // wants the nominative (Vikunja #421). // // The dictionary answers before the reversals now, so the nominative it knows // comes second and anything derived by hand follows (V-530). "Уфе" used to fall // under a four-rune floor and was asked as spoken, so "Уфа" was never tried. func TestLocationCandidates(t *testing.T) { cases := map[string][]string{ "Москве": {"Москве", "Москва", "Москв"}, "Казани": {"Казани", "Казань", "Казан"}, "Лондоне": {"Лондоне", "Лондон", "Лондона"}, "Тбилиси": {"Тбилиси", "Тбились", "Тбилис"}, "Berlin": {"Berlin"}, "Уфе": {"Уфе", "Уфа", "Уф"}, // The dictionary does not know it, so the soft-sign reversal answers. "Твери": {"Твери", "Тверь", "Твер"}, } for in, want := range cases { got := locationCandidates(in) if len(got) != len(want) { t.Errorf("locationCandidates(%q) = %v, want %v", in, got, want) continue } for i := range got { if got[i] != want[i] { t.Errorf("locationCandidates(%q) = %v, want %v", in, got, want) break } } } }