4 files changed,
588 insertions(+),
8 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-05-27 19:48:37 +0300
Authored at:
2026-05-27 19:31:17 +0300
Change ID:
rxwvskkuknnnyqwtzrwkomzrxqmsvpxz
Parent:
db0b936
jump to
| M | app/config.go |
| M | main.go |
| A | sources/twitch/twitch.go |
| A | sources/twitch/twitch_test.go |
M
app/config.go
··· 6 6 ) 7 7 8 8 type Config struct { 9 - Port int `json:"port"` 10 - AuthToken string `json:"auth_token"` 11 - TGUserID int64 `json:"tg_userid"` 12 - TGToken string `json:"tg_token"` 13 - MoviefeedAPIKey string `json:"moviefeed_api_key"` 14 - MoviefeedShows []string `json:"moviefeed_shows"` 15 - MusicArtists []string `json:"music_artists"` 16 - MusicMaxAgeDays int `json:"music_max_age_days"` 9 + Port int `json:"port"` 10 + AuthToken string `json:"auth_token"` 11 + TGUserID int64 `json:"tg_userid"` 12 + TGToken string `json:"tg_token"` 13 + MoviefeedAPIKey string `json:"moviefeed_api_key"` 14 + MoviefeedShows []string `json:"moviefeed_shows"` 15 + MusicArtists []string `json:"music_artists"` 16 + MusicMaxAgeDays int `json:"music_max_age_days"` 17 + TwitchClientID string `json:"twitch_client_id"` 18 + TwitchClientSecret string `json:"twitch_client_secret"` 17 19 } 18 20 19 21 func NewConfig(fpath string) (*Config, error) {
M
main.go
··· 11 11 "olexsmir.xyz/rss-tools/sources/moviefeed" 12 12 "olexsmir.xyz/rss-tools/sources/musicfeed" 13 13 "olexsmir.xyz/rss-tools/sources/telegram" 14 + "olexsmir.xyz/rss-tools/sources/twitch" 14 15 "olexsmir.xyz/rss-tools/sources/weather" 15 16 "olexsmir.xyz/rss-tools/sources/ztoe" 16 17 ) ··· 45 46 _ = moviefeed.Register(app) 46 47 _ = musicfeed.Register(app) 47 48 _ = weather.Register(app) 49 + _ = twitch.Register(app) 48 50 49 51 return app.Start(ctx) 50 52 }
A
sources/twitch/twitch.go
··· 1 +package twitch 2 + 3 +import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "html" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "net/url" 12 + "strings" 13 + "sync" 14 + "time" 15 + 16 + "olexsmir.xyz/rss-tools/app" 17 + "olexsmir.xyz/rss-tools/app/atom" 18 +) 19 + 20 +var defaultHTTPClient = &http.Client{Timeout: 10 * time.Second} 21 + 22 +const ( 23 + twitchAPIBase = "https://api.twitch.tv" 24 + tokenExpiryBuffer = 5 * time.Minute 25 +) 26 + 27 +var twitchAuthBase = "https://id.twitch.tv" 28 + 29 +type twitchStream struct { 30 + ID string `json:"id"` 31 + UserLogin string `json:"user_login"` 32 + UserName string `json:"user_name"` 33 + GameName string `json:"game_name"` 34 + Title string `json:"title"` 35 + ViewerCount int `json:"viewer_count"` 36 + StartedAt string `json:"started_at"` 37 + ThumbnailURL string `json:"thumbnail_url"` 38 +} 39 + 40 +type twitchStreamsResponse struct { 41 + Data []twitchStream `json:"data"` 42 +} 43 + 44 +type twitchAuthResponse struct { 45 + AccessToken string `json:"access_token"` 46 + ExpiresIn int `json:"expires_in"` 47 + TokenType string `json:"token_type"` 48 +} 49 + 50 +type twitchfeed struct { 51 + clientID string 52 + clientSecret string 53 + baseURL string 54 + client *http.Client 55 + 56 + mu sync.Mutex 57 + token string 58 + tokenExp time.Time 59 +} 60 + 61 +func Register(a *app.App) error { 62 + if a.Config.TwitchClientID == "" || a.Config.TwitchClientSecret == "" { 63 + a.Logger.Info("twitch: client_id or client_secret not set, skipping") 64 + return nil 65 + } 66 + 67 + tf := &twitchfeed{ 68 + clientID: a.Config.TwitchClientID, 69 + clientSecret: a.Config.TwitchClientSecret, 70 + baseURL: twitchAPIBase, 71 + client: defaultHTTPClient, 72 + } 73 + 74 + a.Route("GET /twitch/__oauth2", tf.handleTokenStatus) 75 + a.Route("GET /twitch/{name}", tf.handleStreams) 76 + 77 + a.Logger.Info("twitch source registered") 78 + return nil 79 +} 80 + 81 +func (tf *twitchfeed) handleStreams(w http.ResponseWriter, r *http.Request) { 82 + name := r.PathValue("name") 83 + if name == "" || name == "__oauth2" { 84 + http.Error(w, "missing streamer name", http.StatusBadRequest) 85 + return 86 + } 87 + 88 + stream, err := tf.do(r.Context(), name) 89 + if err != nil { 90 + slog.Error("twitch: fetch failed", "user", name, "err", err) 91 + http.Error(w, err.Error(), http.StatusInternalServerError) 92 + return 93 + } 94 + 95 + feed := atom.NewFeed("Twitch: "+name, "twitch:"+name) 96 + if stream != nil { 97 + feed.Add(entryFromStream(stream)) 98 + } 99 + if err := feed.Render(w); err != nil { 100 + http.Error(w, err.Error(), http.StatusInternalServerError) 101 + } 102 +} 103 + 104 +func (tf *twitchfeed) handleTokenStatus(w http.ResponseWriter, r *http.Request) { 105 + tf.mu.Lock() 106 + tok := tf.token 107 + exp := tf.tokenExp 108 + tf.mu.Unlock() 109 + 110 + info := map[string]any{ 111 + "client_id_set": tf.clientID != "", 112 + "secret_configured": tf.clientSecret != "", 113 + "token_cached": tok != "", 114 + } 115 + if tok != "" { 116 + info["token_length"] = len(tok) 117 + info["expires_at"] = exp.Format(time.RFC3339) 118 + info["expired"] = time.Now().After(exp) 119 + if len(tok) > 8 { 120 + info["preview"] = tok[:4] + "..." + tok[len(tok)-4:] 121 + } 122 + } 123 + w.Header().Set("Content-Type", "application/json") 124 + json.NewEncoder(w).Encode(info) 125 +} 126 + 127 +func (tf *twitchfeed) do(ctx context.Context, login string) (*twitchStream, error) { 128 + tok, err := tf.getToken(ctx) 129 + if err != nil { 130 + return nil, fmt.Errorf("auth: %w", err) 131 + } 132 + 133 + stream, err := tf.fetchStream(ctx, tok, login) 134 + if err != nil { 135 + return nil, err 136 + } 137 + return stream, nil 138 +} 139 + 140 +func (tf *twitchfeed) getToken(ctx context.Context) (string, error) { 141 + tf.mu.Lock() 142 + hasToken := tf.token != "" 143 + expired := time.Now().After(tf.tokenExp) 144 + tf.mu.Unlock() 145 + 146 + if hasToken && !expired { 147 + return tf.token, nil 148 + } 149 + 150 + return tf.authenticate(ctx) 151 +} 152 + 153 +func (tf *twitchfeed) authenticate(ctx context.Context) (string, error) { 154 + v := url.Values{ 155 + "client_id": {tf.clientID}, 156 + "client_secret": {tf.clientSecret}, 157 + "grant_type": {"client_credentials"}, 158 + } 159 + 160 + resp, err := tf.client.PostForm(twitchAuthBase+"/oauth2/token", v) 161 + if err != nil { 162 + return "", fmt.Errorf("token request: %w", err) 163 + } 164 + defer resp.Body.Close() 165 + 166 + if resp.StatusCode != http.StatusOK { 167 + body, _ := io.ReadAll(resp.Body) 168 + return "", fmt.Errorf("token endpoint: %s %s", resp.Status, body) 169 + } 170 + 171 + var auth twitchAuthResponse 172 + if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil { 173 + return "", fmt.Errorf("token decode: %w", err) 174 + } 175 + 176 + tf.mu.Lock() 177 + tf.token = auth.AccessToken 178 + tf.tokenExp = time.Now().Add(time.Duration(auth.ExpiresIn)*time.Second - tokenExpiryBuffer) 179 + tf.mu.Unlock() 180 + 181 + slog.Info("twitch: new token obtained", "expires_in", auth.ExpiresIn) 182 + return auth.AccessToken, nil 183 +} 184 + 185 +func (tf *twitchfeed) fetchStream(ctx context.Context, token, login string) (*twitchStream, error) { 186 + u := tf.baseURL + "/helix/streams?user_login=" + login 187 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) 188 + if err != nil { 189 + return nil, fmt.Errorf("creating request: %w", err) 190 + } 191 + req.Header.Set("Authorization", "Bearer "+token) 192 + req.Header.Set("Client-Id", tf.clientID) 193 + 194 + resp, err := tf.client.Do(req) 195 + if err != nil { 196 + return nil, fmt.Errorf("fetching streams: %w", err) 197 + } 198 + defer resp.Body.Close() 199 + 200 + if resp.StatusCode == http.StatusUnauthorized { 201 + tf.mu.Lock() 202 + tf.token = "" 203 + tf.mu.Unlock() 204 + return nil, fmt.Errorf("twitch API: %s", resp.Status) 205 + } 206 + if resp.StatusCode != http.StatusOK { 207 + return nil, fmt.Errorf("twitch API: %s", resp.Status) 208 + } 209 + 210 + var result twitchStreamsResponse 211 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 212 + return nil, fmt.Errorf("decoding response: %w", err) 213 + } 214 + if len(result.Data) == 0 { 215 + return nil, nil 216 + } 217 + return &result.Data[0], nil 218 +} 219 + 220 +func entryFromStream(s *twitchStream) *atom.Entry { 221 + title := fmt.Sprintf("%s — %s", s.UserName, s.Title) 222 + if len(title) > 80 { 223 + title = title[:80] + "…" 224 + } 225 + 226 + thumbnail := strings.NewReplacer("{width}", "640", "{height}", "360").Replace(s.ThumbnailURL) 227 + 228 + startedAt, _ := time.Parse(time.RFC3339, s.StartedAt) 229 + 230 + content := fmt.Sprintf( 231 + "<p>Playing <b>%s</b> · %d viewers</p>", 232 + html.EscapeString(s.GameName), s.ViewerCount, 233 + ) 234 + if thumbnail != "" { 235 + content += fmt.Sprintf(`<p><img src="%s" alt="%s stream"/></p>`, 236 + html.EscapeString(thumbnail), html.EscapeString(s.UserName)) 237 + } 238 + 239 + login := s.UserLogin 240 + if login == "" { 241 + login = s.UserName 242 + } 243 + 244 + return &atom.Entry{ 245 + ID: "twitch-stream-" + s.ID, 246 + Title: title, 247 + Content: atom.NewText(content, "html"), 248 + Updated: atom.Time(startedAt), 249 + Link: []atom.Link{ 250 + {Rel: "alternate", Href: "https://twitch.tv/" + login}, 251 + }, 252 + } 253 +}
A
sources/twitch/twitch_test.go
··· 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, `<b>VALORANT</b>`) { 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 +}