all repos

rss-tools @ aeb1a59

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