all repos

rss-tools @ 50b546dbf3e0cf36ff5e6d5a75916206a125213e

get rss feed from sources that(i need and) dont provide one

rss-tools/sources/weather/weather_test.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
add weather source, 1 month ago
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 TestWeatherHandlerRendersMorningBriefing(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
	var feed app.AtomFeed
109
	is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil)
110
	is.Equal(t, len(feed.Entries), 1)
111
	is.Equal(t, feed.Title, "Weather forecast for Kyiv")
112
	is.Equal(t, feed.Entries[0].Content.Type, "html")
113
	is.Equal(t, strings.HasPrefix(feed.Entries[0].Content.Value, "<pre>"), true)
114
115
	content := feed.Entries[0].Content.Value
116
	if !strings.Contains(content, "+13° / +22°  |  Cloudy with showers in the afternoon") {
117
		t.Fatalf("missing summary line in content:\n%s", content)
118
	}
119
	if !strings.Contains(content, "☂ 70% chance of rain (5-9 mm)") {
120
		t.Fatalf("missing rain line in content:\n%s", content)
121
	}
122
	if !strings.Contains(content, "🌬 Wind: 12-25 km/h from NW") {
123
		t.Fatalf("missing wind line in content:\n%s", content)
124
	}
125
	if !strings.Contains(content, "🌡 Now: +17° (feels like +16°)") {
126
		t.Fatalf("missing feels-like line in content:\n%s", content)
127
	}
128
	if !strings.Contains(content, "💧 Humidity: 60-88%") {
129
		t.Fatalf("missing humidity line in content:\n%s", content)
130
	}
131
	if !strings.Contains(content, "🌫 Air: AQI 42 (Good), PM2.5 8.1, PM10 12.4, O3 55.3, NO2 7.0") {
132
		t.Fatalf("missing air quality line in content:\n%s", content)
133
	}
134
	for _, line := range []string{
135
		"08:00  +14°  ☁ Cloudy",
136
		"12:00  +18°  🌥 Partly cloudy",
137
		"16:00  +21°  🌧 Rain showers",
138
		"20:00  +16°  🌥 Partly cloudy",
139
	} {
140
		if !strings.Contains(content, line) {
141
			t.Fatalf("missing timeline line %q in content:\n%s", line, content)
142
		}
143
	}
144
}
145
146
func TestWeatherHandlerBadCoordinates(t *testing.T) {
147
	w := &weather{
148
		client:        http.DefaultClient,
149
		forecastURL:   "http://example.test/forecast",
150
		airQualityURL: "http://example.test/air",
151
	}
152
153
	mux := http.NewServeMux()
154
	mux.HandleFunc("GET /weather", w.handler)
155
156
	for _, route := range []string{
157
		"/weather",
158
		"/weather?latitude=abc&longitude=30.5",
159
		"/weather?latitude=95&longitude=30.5",
160
		"/weather?latitude=50.5&longitude=200",
161
	} {
162
		req := httptest.NewRequest(http.MethodGet, route, nil)
163
		rr := httptest.NewRecorder()
164
		mux.ServeHTTP(rr, req)
165
		is.Equal(t, http.StatusBadRequest, rr.Code)
166
	}
167
}
168
169
func TestWeatherHandlerUpstreamFailure(t *testing.T) {
170
	forecastSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
171
		http.Error(w, "boom", http.StatusBadGateway)
172
	}))
173
	defer forecastSrv.Close()
174
175
	airSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
176
		w.Header().Set("Content-Type", "application/json")
177
		_, _ = w.Write([]byte(airFixture))
178
	}))
179
	defer airSrv.Close()
180
181
	w := &weather{
182
		client:        forecastSrv.Client(),
183
		forecastURL:   forecastSrv.URL,
184
		airQualityURL: airSrv.URL,
185
	}
186
187
	mux := http.NewServeMux()
188
	mux.HandleFunc("GET /weather", w.handler)
189
190
	req := httptest.NewRequest(http.MethodGet, "/weather?latitude=50.45&longitude=30.52", nil)
191
	rr := httptest.NewRecorder()
192
	mux.ServeHTTP(rr, req)
193
194
	is.Equal(t, http.StatusBadGateway, rr.Code)
195
}
196
197
func TestWindDirectionFromDegrees(t *testing.T) {
198
	is.Equal(t, windDirectionFromDegrees(0), "N")
199
	is.Equal(t, windDirectionFromDegrees(45), "NE")
200
	is.Equal(t, windDirectionFromDegrees(180), "S")
201
	is.Equal(t, windDirectionFromDegrees(315), "NW")
202
}
203
204
func TestTimelineSummaryUsesRequestedHours(t *testing.T) {
205
	forecast := forecastResponse{
206
		Timezone: "Europe/Kyiv",
207
	}
208
	forecast.Hourly.Time = []string{
209
		"2026-05-05T07:00",
210
		"2026-05-05T11:00",
211
		"2026-05-05T15:00",
212
		"2026-05-05T19:00",
213
	}
214
	forecast.Hourly.Temperature = []float64{14.2, 18.4, 21.0, 16.7}
215
	forecast.Hourly.WeatherCode = []int{3, 2, 80, 2}
216
217
	loc, err := timeLoadLocation("Europe/Kyiv")
218
	is.Err(t, err, nil)
219
	lines := timelineSummary(forecast, "2026-05-05", loc)
220
221
	is.Equal(t, len(lines), 4)
222
	is.Equal(t, strings.HasPrefix(lines[0], "08:00"), true)
223
	is.Equal(t, strings.HasPrefix(lines[1], "12:00"), true)
224
	is.Equal(t, strings.HasPrefix(lines[2], "16:00"), true)
225
	is.Equal(t, strings.HasPrefix(lines[3], "20:00"), true)
226
}
227
228
func timeLoadLocation(name string) (*time.Location, error) {
229
	return time.LoadLocation(name)
230
}