Files
Maven/internal/weather/openmeteo_test.go
T
claude e8ece874b1 weather: check HTTP status before decoding open-meteo replies (V-589)
Neither CurrentWeather nor geocodeOne checked resp.StatusCode, so a
non-200 forecast reply decoded into a zero-value struct reported as a
real 0-degree answer, and a non-200 geocode reply decoded into an empty
result list and was reported as ErrLocationUnknown — blaming the owner
for a service outage. Both now check http.StatusOK first, matching the
sibling kiwix and websearch clients, and return a wrapped error naming
the status instead.

Adds httptest coverage for a 500 from the forecast endpoint, a 500 from
the geocode endpoint, and a genuine empty geocode result, asserting each
takes a different path.
2026-08-06 02:47:24 +04:00

201 lines
6.3 KiB
Go

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++
json.NewEncoder(w).Encode(geoResponse{
Results: []geoResult{{
Name: "Moscow", Latitude: 55.7558, Longitude: 37.6173, Country: "Russia",
}},
})
return
}
if r.URL.Path == "/v1/forecast" {
weatherReqCount++
json.NewEncoder(w).Encode(forecastResponse{
CurrentWeather: currentWeather{
Temperature: 22.5,
WMO: 0,
WindSpeed: 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)
}
}
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(geoResponse{
Results: []geoResult{{Name: "Moscow", Latitude: 55.7558, Longitude: 37.6173, Country: "Russia"}},
})
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())
}
}
// 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
}
}
}
}