rss-tools/sources/weather/weather_test.go (view raw)
| 1 | package weather |
| 2 | |
| 3 | import ( |
| 4 | "encoding/xml" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "olexsmir.xyz/rss-tools/app" |
| 12 | "olexsmir.xyz/x/is" |
| 13 | ) |
| 14 | |
| 15 | const forecastFixture = `{ |
| 16 | "timezone": "Europe/Kyiv", |
| 17 | "current": { |
| 18 | "time": "2026-05-05T07:30", |
| 19 | "temperature_2m": 17.1, |
| 20 | "apparent_temperature": 16.3 |
| 21 | }, |
| 22 | "daily": { |
| 23 | "time": ["2026-05-05"], |
| 24 | "temperature_2m_min": [13.2], |
| 25 | "temperature_2m_max": [22.4], |
| 26 | "weather_code": [3], |
| 27 | "precipitation_probability_max": [70], |
| 28 | "precipitation_sum": [9.2], |
| 29 | "rain_sum": [5.1], |
| 30 | "showers_sum": [4.1], |
| 31 | "wind_speed_10m_max": [25.0], |
| 32 | "wind_direction_10m_dominant": [315] |
| 33 | }, |
| 34 | "hourly": { |
| 35 | "time": [ |
| 36 | "2026-05-05T00:00", |
| 37 | "2026-05-05T04:00", |
| 38 | "2026-05-05T08:00", |
| 39 | "2026-05-05T12:00", |
| 40 | "2026-05-05T16:00", |
| 41 | "2026-05-05T20:00" |
| 42 | ], |
| 43 | "temperature_2m": [15.1, 13.0, 14.1, 18.2, 21.4, 16.3], |
| 44 | "weather_code": [2, 2, 3, 2, 80, 2], |
| 45 | "precipitation_probability": [10, 20, 25, 35, 70, 45], |
| 46 | "wind_speed_10m": [12.0, 14.0, 16.0, 20.0, 25.0, 18.0], |
| 47 | "relative_humidity_2m": [85, 88, 82, 70, 60, 72] |
| 48 | } |
| 49 | }` |
| 50 | |
| 51 | const airFixture = `{ |
| 52 | "current": { |
| 53 | "us_aqi": 42, |
| 54 | "pm2_5": 8.1, |
| 55 | "pm10": 12.4, |
| 56 | "ozone": 55.3, |
| 57 | "nitrogen_dioxide": 7.0 |
| 58 | } |
| 59 | }` |
| 60 | |
| 61 | const geocodeFixture = `{ |
| 62 | "display_name": "Kyiv, Kyiv Oblast, Ukraine", |
| 63 | "address": { |
| 64 | "city": "Kyiv", |
| 65 | "state": "Kyiv Oblast", |
| 66 | "country": "Ukraine" |
| 67 | } |
| 68 | }` |
| 69 | |
| 70 | func TestWeatherHandlerRendersWeatherBriefing(t *testing.T) { |
| 71 | forecastSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 72 | w.Header().Set("Content-Type", "application/json") |
| 73 | _, _ = w.Write([]byte(forecastFixture)) |
| 74 | })) |
| 75 | defer forecastSrv.Close() |
| 76 | |
| 77 | airSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 78 | w.Header().Set("Content-Type", "application/json") |
| 79 | _, _ = w.Write([]byte(airFixture)) |
| 80 | })) |
| 81 | defer airSrv.Close() |
| 82 | |
| 83 | geoSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 84 | w.Header().Set("Content-Type", "application/json") |
| 85 | _, _ = w.Write([]byte(geocodeFixture)) |
| 86 | })) |
| 87 | defer geoSrv.Close() |
| 88 | |
| 89 | w := &weather{ |
| 90 | client: forecastSrv.Client(), |
| 91 | forecastURL: forecastSrv.URL, |
| 92 | airQualityURL: airSrv.URL, |
| 93 | geocodingURL: geoSrv.URL, |
| 94 | } |
| 95 | |
| 96 | mux := http.NewServeMux() |
| 97 | mux.HandleFunc("GET /weather", w.handler) |
| 98 | |
| 99 | req := httptest.NewRequest(http.MethodGet, "/weather?latitude=50.4501&longitude=30.5234", nil) |
| 100 | rr := httptest.NewRecorder() |
| 101 | mux.ServeHTTP(rr, req) |
| 102 | |
| 103 | is.Equal(t, http.StatusOK, rr.Code) |
| 104 | if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") { |
| 105 | t.Fatalf("expected atom feed, got %q", got) |
| 106 | } |
| 107 | |
| 108 | raw := rr.Body.String() |
| 109 | var feed app.AtomFeed |
| 110 | is.Err(t, xml.NewDecoder(strings.NewReader(raw)).Decode(&feed), nil) |
| 111 | is.Equal(t, len(feed.Entries), 1) |
| 112 | is.Equal(t, feed.Title, "Weather forecast for Kyiv") |
| 113 | is.Equal(t, feed.Entries[0].Title, "Weather briefing") |
| 114 | is.Equal(t, feed.Entries[0].Content.Type, "xhtml") |
| 115 | |
| 116 | content := raw |
| 117 | if !strings.Contains(content, `<content type="xhtml">`) { |
| 118 | t.Fatalf("missing xhtml content wrapper in response:\n%s", content) |
| 119 | } |
| 120 | if !strings.Contains(content, "<article>") || !strings.Contains(content, "<h2>Overview</h2>") { |
| 121 | t.Fatalf("missing overview structure in content:\n%s", content) |
| 122 | } |
| 123 | if !strings.Contains(content, "<h2>Timeline</h2>") { |
| 124 | t.Fatalf("missing timeline structure in content:\n%s", content) |
| 125 | } |
| 126 | if !strings.Contains(content, "+13° / +22° | Cloudy with showers in the afternoon") { |
| 127 | t.Fatalf("missing summary line in content:\n%s", content) |
| 128 | } |
| 129 | if !strings.Contains(content, "☂ 70% chance of rain (5-9 mm)") { |
| 130 | t.Fatalf("missing rain line in content:\n%s", content) |
| 131 | } |
| 132 | if !strings.Contains(content, "🌬 Wind: 12-25 km/h from NW") { |
| 133 | t.Fatalf("missing wind line in content:\n%s", content) |
| 134 | } |
| 135 | if !strings.Contains(content, "🌡 Now: +17° (feels like +16°)") { |
| 136 | t.Fatalf("missing feels-like line in content:\n%s", content) |
| 137 | } |
| 138 | if !strings.Contains(content, "💧 Humidity: 60-88%") { |
| 139 | t.Fatalf("missing humidity line in content:\n%s", content) |
| 140 | } |
| 141 | if !strings.Contains(content, "🌫 Air: AQI 42 (Good), PM2.5 8.1, PM10 12.4, O3 55.3, NO2 7.0") { |
| 142 | t.Fatalf("missing air quality line in content:\n%s", content) |
| 143 | } |
| 144 | for _, line := range []string{ |
| 145 | "<strong>08:00</strong> +14° ☁ Cloudy", |
| 146 | "<strong>12:00</strong> +18° 🌥 Partly cloudy", |
| 147 | "<strong>16:00</strong> +21° 🌧 Rain showers", |
| 148 | "<strong>20:00</strong> +16° 🌥 Partly cloudy", |
| 149 | } { |
| 150 | if !strings.Contains(content, line) { |
| 151 | t.Fatalf("missing timeline line %q in content:\n%s", line, content) |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func TestWeatherHandlerBadCoordinates(t *testing.T) { |
| 157 | w := &weather{ |
| 158 | client: http.DefaultClient, |
| 159 | forecastURL: "http://example.test/forecast", |
| 160 | airQualityURL: "http://example.test/air", |
| 161 | } |
| 162 | |
| 163 | mux := http.NewServeMux() |
| 164 | mux.HandleFunc("GET /weather", w.handler) |
| 165 | |
| 166 | for _, route := range []string{ |
| 167 | "/weather", |
| 168 | "/weather?latitude=abc&longitude=30.5", |
| 169 | "/weather?latitude=95&longitude=30.5", |
| 170 | "/weather?latitude=50.5&longitude=200", |
| 171 | } { |
| 172 | req := httptest.NewRequest(http.MethodGet, route, nil) |
| 173 | rr := httptest.NewRecorder() |
| 174 | mux.ServeHTTP(rr, req) |
| 175 | is.Equal(t, http.StatusBadRequest, rr.Code) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | func TestWeatherHandlerUpstreamFailure(t *testing.T) { |
| 180 | forecastSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 181 | http.Error(w, "boom", http.StatusBadGateway) |
| 182 | })) |
| 183 | defer forecastSrv.Close() |
| 184 | |
| 185 | airSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 186 | w.Header().Set("Content-Type", "application/json") |
| 187 | _, _ = w.Write([]byte(airFixture)) |
| 188 | })) |
| 189 | defer airSrv.Close() |
| 190 | |
| 191 | w := &weather{ |
| 192 | client: forecastSrv.Client(), |
| 193 | forecastURL: forecastSrv.URL, |
| 194 | airQualityURL: airSrv.URL, |
| 195 | } |
| 196 | |
| 197 | mux := http.NewServeMux() |
| 198 | mux.HandleFunc("GET /weather", w.handler) |
| 199 | |
| 200 | req := httptest.NewRequest(http.MethodGet, "/weather?latitude=50.45&longitude=30.52", nil) |
| 201 | rr := httptest.NewRecorder() |
| 202 | mux.ServeHTTP(rr, req) |
| 203 | |
| 204 | is.Equal(t, http.StatusBadGateway, rr.Code) |
| 205 | } |
| 206 | |
| 207 | func TestWindDirectionFromDegrees(t *testing.T) { |
| 208 | is.Equal(t, windDirectionFromDegrees(0), "N") |
| 209 | is.Equal(t, windDirectionFromDegrees(45), "NE") |
| 210 | is.Equal(t, windDirectionFromDegrees(180), "S") |
| 211 | is.Equal(t, windDirectionFromDegrees(315), "NW") |
| 212 | } |
| 213 | |
| 214 | func TestTimelineSummaryUsesRequestedHours(t *testing.T) { |
| 215 | forecast := forecastResponse{ |
| 216 | Timezone: "Europe/Kyiv", |
| 217 | } |
| 218 | forecast.Hourly.Time = []string{ |
| 219 | "2026-05-05T07:00", |
| 220 | "2026-05-05T11:00", |
| 221 | "2026-05-05T15:00", |
| 222 | "2026-05-05T19:00", |
| 223 | } |
| 224 | forecast.Hourly.Temperature = []float64{14.2, 18.4, 21.0, 16.7} |
| 225 | forecast.Hourly.WeatherCode = []int{3, 2, 80, 2} |
| 226 | |
| 227 | loc, err := timeLoadLocation("Europe/Kyiv") |
| 228 | is.Err(t, err, nil) |
| 229 | lines := timelineSummary(forecast, "2026-05-05", loc) |
| 230 | |
| 231 | is.Equal(t, len(lines), 4) |
| 232 | is.Equal(t, strings.HasPrefix(lines[0], "08:00"), true) |
| 233 | is.Equal(t, strings.HasPrefix(lines[1], "12:00"), true) |
| 234 | is.Equal(t, strings.HasPrefix(lines[2], "16:00"), true) |
| 235 | is.Equal(t, strings.HasPrefix(lines[3], "20:00"), true) |
| 236 | } |
| 237 | |
| 238 | func timeLoadLocation(name string) (*time.Location, error) { |
| 239 | return time.LoadLocation(name) |
| 240 | } |