all repos

rss-tools @ a5ac527

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

rss-tools/sources/twitch/twitch_test.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
add twtich souce, 10 days ago
1
package twitch
2
3
import (
4
	"context"
5
	"encoding/json"
6
	"io"
7
	"log/slog"
8
	"net/http"
9
	"net/http/httptest"
10
	"strings"
11
	"testing"
12
	"time"
13
14
	"olexsmir.xyz/rss-tools/app"
15
	"olexsmir.xyz/rss-tools/app/atom"
16
	"olexsmir.xyz/x/is"
17
)
18
19
func TestRegisterSkipsWhenNotConfigured(t *testing.T) {
20
	a := app.App{
21
		Config: &app.Config{},
22
		Logger: slog.Default(),
23
	}
24
25
	err := Register(&a)
26
	is.Err(t, err, nil)
27
}
28
29
func TestHandleTokenStatusCached(t *testing.T) {
30
	tf := &twitchfeed{
31
		clientID:     "client123",
32
		clientSecret: "secret",
33
		token:        "abc12345xyz67890",
34
		tokenExp:     time.Now().Add(24 * time.Hour),
35
	}
36
	req := httptest.NewRequest(http.MethodGet, "/twitch/__oauth2", nil)
37
	w := httptest.NewRecorder()
38
	tf.handleTokenStatus(w, req)
39
40
	resp := w.Result()
41
	body, _ := io.ReadAll(resp.Body)
42
43
	is.Equal(t, http.StatusOK, resp.StatusCode)
44
	is.Equal(t, "application/json", resp.Header.Get("Content-Type"))
45
46
	var info map[string]any
47
	is.Err(t, json.Unmarshal(body, &info), nil)
48
	is.Equal(t, true, info["client_id_set"].(bool))
49
	is.Equal(t, true, info["secret_configured"].(bool))
50
	is.Equal(t, true, info["token_cached"].(bool))
51
	is.Equal(t, float64(16), info["token_length"].(float64))
52
	is.Equal(t, false, info["expired"].(bool))
53
	is.Equal(t, "abc1...7890", info["preview"].(string))
54
}
55
56
func TestHandleTokenStatusNotConfigured(t *testing.T) {
57
	tf := &twitchfeed{clientID: "", clientSecret: ""}
58
	req := httptest.NewRequest(http.MethodGet, "/twitch/__oauth2", nil)
59
	w := httptest.NewRecorder()
60
	tf.handleTokenStatus(w, req)
61
62
	resp := w.Result()
63
	body, _ := io.ReadAll(resp.Body)
64
65
	var info map[string]any
66
	is.Err(t, json.Unmarshal(body, &info), nil)
67
	is.Equal(t, false, info["client_id_set"].(bool))
68
	is.Equal(t, false, info["token_cached"].(bool))
69
	_, hasPreview := info["preview"]
70
	is.Equal(t, false, hasPreview)
71
}
72
73
func TestHandleStreamsLive(t *testing.T) {
74
	twitchSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75
		is.Equal(t, "/helix/streams", r.URL.Path)
76
		is.Equal(t, "shroud", r.URL.Query().Get("user_login"))
77
		is.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
78
		is.Equal(t, "test-client", r.Header.Get("Client-Id"))
79
80
		resp := twitchStreamsResponse{
81
			Data: []twitchStream{{
82
				ID:           "12345",
83
				UserLogin:    "shroud",
84
				UserName:     "shroud",
85
				GameName:     "VALORANT",
86
				Title:        "ranked valorant",
87
				ViewerCount:  12345,
88
				StartedAt:    "2026-05-27T18:00:00Z",
89
				ThumbnailURL: "https://example.com/{width}x{height}.jpg",
90
			}},
91
		}
92
		w.Header().Set("Content-Type", "application/json")
93
		json.NewEncoder(w).Encode(resp)
94
	}))
95
	defer twitchSrv.Close()
96
97
	tf := &twitchfeed{
98
		clientID: "test-client",
99
		token:    "test-token",
100
		tokenExp: time.Now().Add(24 * time.Hour),
101
		baseURL:  twitchSrv.URL,
102
		client:   twitchSrv.Client(),
103
	}
104
	req := httptest.NewRequest(http.MethodGet, "/twitch/shroud", nil)
105
	req.SetPathValue("name", "shroud")
106
	w := httptest.NewRecorder()
107
108
	tf.handleStreams(w, req)
109
110
	resp := w.Result()
111
	body, _ := io.ReadAll(resp.Body)
112
113
	is.Equal(t, http.StatusOK, resp.StatusCode)
114
	is.Equal(t, "application/atom+xml; charset=utf-8", resp.Header.Get("Content-Type"))
115
116
	raw := string(body)
117
	if !strings.Contains(raw, `<title>shroud — ranked valorant</title>`) {
118
		t.Fatal("missing title in feed")
119
	}
120
	if !strings.Contains(raw, `<id>twitch-stream-12345</id>`) {
121
		t.Fatal("missing stream ID in feed")
122
	}
123
	if !strings.Contains(raw, `<link rel="alternate" href="https://twitch.tv/shroud"`) {
124
		t.Fatal("missing link in feed")
125
	}
126
	if !strings.Contains(raw, `&lt;b&gt;VALORANT&lt;/b&gt;`) {
127
		t.Fatal("missing game name in feed")
128
	}
129
	if !strings.Contains(raw, `12345 viewers`) {
130
		t.Fatal("missing viewer count in feed")
131
	}
132
	if !strings.Contains(raw, `640x360.jpg`) {
133
		t.Fatal("missing thumbnail in feed")
134
	}
135
}
136
137
func TestHandleStreamsOffline(t *testing.T) {
138
	twitchSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139
		json.NewEncoder(w).Encode(twitchStreamsResponse{Data: []twitchStream{}})
140
	}))
141
	defer twitchSrv.Close()
142
143
	tf := &twitchfeed{
144
		clientID: "test-client",
145
		token:    "test-token",
146
		tokenExp: time.Now().Add(24 * time.Hour),
147
		baseURL:  twitchSrv.URL,
148
		client:   twitchSrv.Client(),
149
	}
150
	req := httptest.NewRequest(http.MethodGet, "/twitch/offlineuser", nil)
151
	req.SetPathValue("name", "offlineuser")
152
	w := httptest.NewRecorder()
153
	tf.handleStreams(w, req)
154
155
	resp := w.Result()
156
	body, _ := io.ReadAll(resp.Body)
157
158
	is.Equal(t, http.StatusOK, resp.StatusCode)
159
	raw := string(body)
160
	if !strings.Contains(raw, `<feed`) {
161
		t.Fatal("expected feed element")
162
	}
163
	if !strings.Contains(raw, `<title>Twitch: offlineuser</title>`) {
164
		t.Fatal("expected feed title")
165
	}
166
	if strings.Contains(raw, `<entry>`) {
167
		t.Fatal("should have no entries for offline user")
168
	}
169
}
170
171
func TestHandleStreamsMissingName(t *testing.T) {
172
	tf := &twitchfeed{clientID: "test-client", clientSecret: "secret", token: "tok", tokenExp: time.Now().Add(24 * time.Hour)}
173
	req := httptest.NewRequest(http.MethodGet, "/twitch/", nil)
174
	w := httptest.NewRecorder()
175
	tf.handleStreams(w, req)
176
177
	resp := w.Result()
178
	is.Equal(t, http.StatusBadRequest, resp.StatusCode)
179
}
180
181
func TestEntryFromStreamTruncatesLongTitle(t *testing.T) {
182
	longTitle := strings.Repeat("x", 100)
183
	s := &twitchStream{
184
		ID:        "1",
185
		UserName:  "longname",
186
		Title:     longTitle,
187
		GameName:  "game",
188
		StartedAt: "2026-01-01T00:00:00Z",
189
	}
190
	entry := entryFromStream(s)
191
	if len(entry.Title) > 83 {
192
		t.Fatalf("title too long: %d chars", len(entry.Title))
193
	}
194
	if !strings.HasSuffix(entry.Title, "…") {
195
		t.Fatal("title should end with ellipsis")
196
	}
197
}
198
199
func TestEntryFromStreamFormatsContent(t *testing.T) {
200
	s := &twitchStream{
201
		ID:           "1",
202
		UserLogin:    "testuser",
203
		UserName:     "testuser",
204
		Title:        "test stream",
205
		GameName:     "Just Chatting",
206
		ViewerCount:  42,
207
		StartedAt:    "2026-01-01T00:00:00Z",
208
		ThumbnailURL: "https://example.com/{width}x{height}.jpg",
209
	}
210
	entry := entryFromStream(s)
211
	is.Equal(t, "twitch-stream-1", entry.ID)
212
	is.Equal(t, "testuser — test stream", entry.Title)
213
	is.Equal(t, atom.Time(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)), entry.Updated)
214
	is.Equal(t, "html", entry.Content.Type)
215
	if !strings.Contains(entry.Content.Body, "Just Chatting") {
216
		t.Fatal("missing game name")
217
	}
218
	if !strings.Contains(entry.Content.Body, "42 viewers") {
219
		t.Fatal("missing viewer count")
220
	}
221
	if !strings.Contains(entry.Content.Body, "https://example.com/640x360.jpg") {
222
		t.Fatal("missing thumbnail")
223
	}
224
	is.Equal(t, "https://twitch.tv/testuser", entry.Link[0].Href)
225
}
226
227
func TestFetchStreamWithTokenNotLiveReturnsNil(t *testing.T) {
228
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
229
		json.NewEncoder(w).Encode(twitchStreamsResponse{Data: []twitchStream{}})
230
	}))
231
	defer srv.Close()
232
233
	tf := &twitchfeed{
234
		clientID: "cid",
235
		baseURL:  srv.URL,
236
		client:   srv.Client(),
237
	}
238
	stream, err := tf.fetchStream(context.Background(), "tok", "nonexistent")
239
	is.Err(t, err, nil)
240
	if stream != nil {
241
		t.Fatal("expected nil stream for offline user")
242
	}
243
}
244
245
func TestAuthenticateCachesToken(t *testing.T) {
246
	authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247
		is.Equal(t, "/oauth2/token", r.URL.Path)
248
		is.Equal(t, "POST", r.Method)
249
		is.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
250
251
		err := r.ParseForm()
252
		is.Err(t, err, nil)
253
		is.Equal(t, "test-client", r.Form.Get("client_id"))
254
		is.Equal(t, "test-secret", r.Form.Get("client_secret"))
255
		is.Equal(t, "client_credentials", r.Form.Get("grant_type"))
256
257
		json.NewEncoder(w).Encode(twitchAuthResponse{
258
			AccessToken: "new-access-token",
259
			ExpiresIn:   3600,
260
			TokenType:   "bearer",
261
		})
262
	}))
263
	defer authSrv.Close()
264
265
	tf := &twitchfeed{
266
		clientID:     "test-client",
267
		clientSecret: "test-secret",
268
		client:       authSrv.Client(),
269
	}
270
	tf.baseURL = "http://doesntmatter" // not used for auth
271
272
	// swap auth endpoint
273
	savedBase := twitchAuthBase
274
	twitchAuthBase = authSrv.URL
275
	defer func() { twitchAuthBase = savedBase }()
276
277
	tok, err := tf.authenticate(context.Background())
278
	is.Err(t, err, nil)
279
	is.Equal(t, "new-access-token", tok)
280
281
	// token should be cached on struct
282
	is.Equal(t, "new-access-token", tf.token)
283
	if time.Now().After(tf.tokenExp) {
284
		t.Fatal("expiry should be in the future")
285
	}
286
}
287
288
func TestGetTokenUsesCachedWhenNotExpired(t *testing.T) {
289
	tf := &twitchfeed{
290
		token:    "cached-token",
291
		tokenExp: time.Now().Add(24 * time.Hour),
292
	}
293
	tok, err := tf.getToken(context.Background())
294
	is.Err(t, err, nil)
295
	is.Equal(t, "cached-token", tok)
296
}
297
298
func TestGetTokenFetchesNewWhenExpired(t *testing.T) {
299
	authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
300
		json.NewEncoder(w).Encode(twitchAuthResponse{
301
			AccessToken: "refreshed-token",
302
			ExpiresIn:   3600,
303
			TokenType:   "bearer",
304
		})
305
	}))
306
	defer authSrv.Close()
307
308
	tf := &twitchfeed{
309
		clientID:     "cid",
310
		clientSecret: "secret",
311
		client:       authSrv.Client(),
312
		token:        "stale-token",
313
		tokenExp:     time.Now().Add(-1 * time.Hour), // expired
314
	}
315
316
	savedBase := twitchAuthBase
317
	twitchAuthBase = authSrv.URL
318
	defer func() { twitchAuthBase = savedBase }()
319
320
	tok, err := tf.getToken(context.Background())
321
	is.Err(t, err, nil)
322
	is.Equal(t, "refreshed-token", tok)
323
}