all repos

rss-tools @ 70f8cb6

get rss feed from sources that(i need and) dont provide one
4 files changed, 588 insertions(+), 8 deletions(-)
add twtich souce
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
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, `&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
        +}