package weather import ( "encoding/xml" "net/http" "net/http/httptest" "strings" "testing" "time" "olexsmir.xyz/rss-tools/app" "olexsmir.xyz/x/is" ) const forecastFixture = `{ "timezone": "Europe/Kyiv", "current": { "time": "2026-05-05T07:30", "temperature_2m": 17.1, "apparent_temperature": 16.3 }, "daily": { "time": ["2026-05-05"], "temperature_2m_min": [13.2], "temperature_2m_max": [22.4], "weather_code": [3], "precipitation_probability_max": [70], "precipitation_sum": [9.2], "rain_sum": [5.1], "showers_sum": [4.1], "wind_speed_10m_max": [25.0], "wind_direction_10m_dominant": [315] }, "hourly": { "time": [ "2026-05-05T00:00", "2026-05-05T04:00", "2026-05-05T08:00", "2026-05-05T12:00", "2026-05-05T16:00", "2026-05-05T20:00" ], "temperature_2m": [15.1, 13.0, 14.1, 18.2, 21.4, 16.3], "weather_code": [2, 2, 3, 2, 80, 2], "precipitation_probability": [10, 20, 25, 35, 70, 45], "wind_speed_10m": [12.0, 14.0, 16.0, 20.0, 25.0, 18.0], "relative_humidity_2m": [85, 88, 82, 70, 60, 72] } }` const airFixture = `{ "current": { "us_aqi": 42, "pm2_5": 8.1, "pm10": 12.4, "ozone": 55.3, "nitrogen_dioxide": 7.0 } }` const geocodeFixture = `{ "display_name": "Kyiv, Kyiv Oblast, Ukraine", "address": { "city": "Kyiv", "state": "Kyiv Oblast", "country": "Ukraine" } }` func TestWeatherHandlerRendersMorningBriefing(t *testing.T) { forecastSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(forecastFixture)) })) defer forecastSrv.Close() airSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(airFixture)) })) defer airSrv.Close() geoSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(geocodeFixture)) })) defer geoSrv.Close() w := &weather{ client: forecastSrv.Client(), forecastURL: forecastSrv.URL, airQualityURL: airSrv.URL, geocodingURL: geoSrv.URL, } mux := http.NewServeMux() mux.HandleFunc("GET /weather", w.handler) req := httptest.NewRequest(http.MethodGet, "/weather?latitude=50.4501&longitude=30.5234", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) is.Equal(t, http.StatusOK, rr.Code) if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") { t.Fatalf("expected atom feed, got %q", got) } var feed app.AtomFeed is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) is.Equal(t, len(feed.Entries), 1) is.Equal(t, feed.Title, "Weather forecast for Kyiv") is.Equal(t, feed.Entries[0].Content.Type, "html") is.Equal(t, strings.HasPrefix(feed.Entries[0].Content.Value, "
"), true)
content := feed.Entries[0].Content.Value
if !strings.Contains(content, "+13° / +22° | Cloudy with showers in the afternoon") {
t.Fatalf("missing summary line in content:\n%s", content)
}
if !strings.Contains(content, "☂ 70% chance of rain (5-9 mm)") {
t.Fatalf("missing rain line in content:\n%s", content)
}
if !strings.Contains(content, "🌬 Wind: 12-25 km/h from NW") {
t.Fatalf("missing wind line in content:\n%s", content)
}
if !strings.Contains(content, "🌡 Now: +17° (feels like +16°)") {
t.Fatalf("missing feels-like line in content:\n%s", content)
}
if !strings.Contains(content, "💧 Humidity: 60-88%") {
t.Fatalf("missing humidity line in content:\n%s", content)
}
if !strings.Contains(content, "🌫 Air: AQI 42 (Good), PM2.5 8.1, PM10 12.4, O3 55.3, NO2 7.0") {
t.Fatalf("missing air quality line in content:\n%s", content)
}
for _, line := range []string{
"08:00 +14° ☁ Cloudy",
"12:00 +18° 🌥 Partly cloudy",
"16:00 +21° 🌧 Rain showers",
"20:00 +16° 🌥 Partly cloudy",
} {
if !strings.Contains(content, line) {
t.Fatalf("missing timeline line %q in content:\n%s", line, content)
}
}
}
func TestWeatherHandlerBadCoordinates(t *testing.T) {
w := &weather{
client: http.DefaultClient,
forecastURL: "http://example.test/forecast",
airQualityURL: "http://example.test/air",
}
mux := http.NewServeMux()
mux.HandleFunc("GET /weather", w.handler)
for _, route := range []string{
"/weather",
"/weather?latitude=abc&longitude=30.5",
"/weather?latitude=95&longitude=30.5",
"/weather?latitude=50.5&longitude=200",
} {
req := httptest.NewRequest(http.MethodGet, route, nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
is.Equal(t, http.StatusBadRequest, rr.Code)
}
}
func TestWeatherHandlerUpstreamFailure(t *testing.T) {
forecastSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusBadGateway)
}))
defer forecastSrv.Close()
airSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(airFixture))
}))
defer airSrv.Close()
w := &weather{
client: forecastSrv.Client(),
forecastURL: forecastSrv.URL,
airQualityURL: airSrv.URL,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /weather", w.handler)
req := httptest.NewRequest(http.MethodGet, "/weather?latitude=50.45&longitude=30.52", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
is.Equal(t, http.StatusBadGateway, rr.Code)
}
func TestWindDirectionFromDegrees(t *testing.T) {
is.Equal(t, windDirectionFromDegrees(0), "N")
is.Equal(t, windDirectionFromDegrees(45), "NE")
is.Equal(t, windDirectionFromDegrees(180), "S")
is.Equal(t, windDirectionFromDegrees(315), "NW")
}
func TestTimelineSummaryUsesRequestedHours(t *testing.T) {
forecast := forecastResponse{
Timezone: "Europe/Kyiv",
}
forecast.Hourly.Time = []string{
"2026-05-05T07:00",
"2026-05-05T11:00",
"2026-05-05T15:00",
"2026-05-05T19:00",
}
forecast.Hourly.Temperature = []float64{14.2, 18.4, 21.0, 16.7}
forecast.Hourly.WeatherCode = []int{3, 2, 80, 2}
loc, err := timeLoadLocation("Europe/Kyiv")
is.Err(t, err, nil)
lines := timelineSummary(forecast, "2026-05-05", loc)
is.Equal(t, len(lines), 4)
is.Equal(t, strings.HasPrefix(lines[0], "08:00"), true)
is.Equal(t, strings.HasPrefix(lines[1], "12:00"), true)
is.Equal(t, strings.HasPrefix(lines[2], "16:00"), true)
is.Equal(t, strings.HasPrefix(lines[3], "20:00"), true)
}
func timeLoadLocation(name string) (*time.Location, error) {
return time.LoadLocation(name)
}