package twitch
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"olexsmir.xyz/rss-tools/app"
"olexsmir.xyz/rss-tools/app/atom"
"olexsmir.xyz/x/is"
)
func TestRegisterSkipsWhenNotConfigured(t *testing.T) {
a := app.App{
Config: &app.Config{},
Logger: slog.Default(),
}
err := Register(&a)
is.Err(t, err, nil)
}
func TestHandleTokenStatusCached(t *testing.T) {
tf := &twitchfeed{
clientID: "client123",
clientSecret: "secret",
token: "abc12345xyz67890",
tokenExp: time.Now().Add(24 * time.Hour),
}
req := httptest.NewRequest(http.MethodGet, "/twitch/__oauth2", nil)
w := httptest.NewRecorder()
tf.handleTokenStatus(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
is.Equal(t, http.StatusOK, resp.StatusCode)
is.Equal(t, "application/json", resp.Header.Get("Content-Type"))
var info map[string]any
is.Err(t, json.Unmarshal(body, &info), nil)
is.Equal(t, true, info["client_id_set"].(bool))
is.Equal(t, true, info["secret_configured"].(bool))
is.Equal(t, true, info["token_cached"].(bool))
is.Equal(t, float64(16), info["token_length"].(float64))
is.Equal(t, false, info["expired"].(bool))
is.Equal(t, "abc1...7890", info["preview"].(string))
}
func TestHandleTokenStatusNotConfigured(t *testing.T) {
tf := &twitchfeed{clientID: "", clientSecret: ""}
req := httptest.NewRequest(http.MethodGet, "/twitch/__oauth2", nil)
w := httptest.NewRecorder()
tf.handleTokenStatus(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
var info map[string]any
is.Err(t, json.Unmarshal(body, &info), nil)
is.Equal(t, false, info["client_id_set"].(bool))
is.Equal(t, false, info["token_cached"].(bool))
_, hasPreview := info["preview"]
is.Equal(t, false, hasPreview)
}
func TestHandleStreamsLive(t *testing.T) {
twitchSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
is.Equal(t, "/helix/streams", r.URL.Path)
is.Equal(t, "shroud", r.URL.Query().Get("user_login"))
is.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
is.Equal(t, "test-client", r.Header.Get("Client-Id"))
resp := twitchStreamsResponse{
Data: []twitchStream{{
ID: "12345",
UserLogin: "shroud",
UserName: "shroud",
GameName: "VALORANT",
Title: "ranked valorant",
ViewerCount: 12345,
StartedAt: "2026-05-27T18:00:00Z",
ThumbnailURL: "https://example.com/{width}x{height}.jpg",
}},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer twitchSrv.Close()
tf := &twitchfeed{
clientID: "test-client",
token: "test-token",
tokenExp: time.Now().Add(24 * time.Hour),
baseURL: twitchSrv.URL,
client: twitchSrv.Client(),
}
req := httptest.NewRequest(http.MethodGet, "/twitch/shroud", nil)
req.SetPathValue("name", "shroud")
w := httptest.NewRecorder()
tf.handleStreams(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
is.Equal(t, http.StatusOK, resp.StatusCode)
is.Equal(t, "application/atom+xml; charset=utf-8", resp.Header.Get("Content-Type"))
raw := string(body)
if !strings.Contains(raw, `
shroud — ranked valorant`) {
t.Fatal("missing title in feed")
}
if !strings.Contains(raw, `twitch-stream-12345`) {
t.Fatal("missing stream ID in feed")
}
if !strings.Contains(raw, `Twitch: offlineuser`) {
t.Fatal("expected feed title")
}
if strings.Contains(raw, ``) {
t.Fatal("should have no entries for offline user")
}
}
func TestHandleStreamsMissingName(t *testing.T) {
tf := &twitchfeed{clientID: "test-client", clientSecret: "secret", token: "tok", tokenExp: time.Now().Add(24 * time.Hour)}
req := httptest.NewRequest(http.MethodGet, "/twitch/", nil)
w := httptest.NewRecorder()
tf.handleStreams(w, req)
resp := w.Result()
is.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestEntryFromStreamTruncatesLongTitle(t *testing.T) {
longTitle := strings.Repeat("x", 100)
s := &twitchStream{
ID: "1",
UserName: "longname",
Title: longTitle,
GameName: "game",
StartedAt: "2026-01-01T00:00:00Z",
}
entry := entryFromStream(s)
if len(entry.Title) > 83 {
t.Fatalf("title too long: %d chars", len(entry.Title))
}
if !strings.HasSuffix(entry.Title, "…") {
t.Fatal("title should end with ellipsis")
}
}
func TestEntryFromStreamFormatsContent(t *testing.T) {
s := &twitchStream{
ID: "1",
UserLogin: "testuser",
UserName: "testuser",
Title: "test stream",
GameName: "Just Chatting",
ViewerCount: 42,
StartedAt: "2026-01-01T00:00:00Z",
ThumbnailURL: "https://example.com/{width}x{height}.jpg",
}
entry := entryFromStream(s)
is.Equal(t, "twitch-stream-1", entry.ID)
is.Equal(t, "testuser — test stream", entry.Title)
is.Equal(t, atom.Time(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)), entry.Updated)
is.Equal(t, "html", entry.Content.Type)
if !strings.Contains(entry.Content.Body, "Just Chatting") {
t.Fatal("missing game name")
}
if !strings.Contains(entry.Content.Body, "42 viewers") {
t.Fatal("missing viewer count")
}
if !strings.Contains(entry.Content.Body, "https://example.com/640x360.jpg") {
t.Fatal("missing thumbnail")
}
is.Equal(t, "https://twitch.tv/testuser", entry.Link[0].Href)
}
func TestFetchStreamWithTokenNotLiveReturnsNil(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(twitchStreamsResponse{Data: []twitchStream{}})
}))
defer srv.Close()
tf := &twitchfeed{
clientID: "cid",
baseURL: srv.URL,
client: srv.Client(),
}
stream, err := tf.fetchStream(context.Background(), "tok", "nonexistent")
is.Err(t, err, nil)
if stream != nil {
t.Fatal("expected nil stream for offline user")
}
}
func TestAuthenticateCachesToken(t *testing.T) {
authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
is.Equal(t, "/oauth2/token", r.URL.Path)
is.Equal(t, "POST", r.Method)
is.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
err := r.ParseForm()
is.Err(t, err, nil)
is.Equal(t, "test-client", r.Form.Get("client_id"))
is.Equal(t, "test-secret", r.Form.Get("client_secret"))
is.Equal(t, "client_credentials", r.Form.Get("grant_type"))
json.NewEncoder(w).Encode(twitchAuthResponse{
AccessToken: "new-access-token",
ExpiresIn: 3600,
TokenType: "bearer",
})
}))
defer authSrv.Close()
tf := &twitchfeed{
clientID: "test-client",
clientSecret: "test-secret",
client: authSrv.Client(),
}
tf.baseURL = "http://doesntmatter" // not used for auth
// swap auth endpoint
savedBase := twitchAuthBase
twitchAuthBase = authSrv.URL
defer func() { twitchAuthBase = savedBase }()
tok, err := tf.authenticate(context.Background())
is.Err(t, err, nil)
is.Equal(t, "new-access-token", tok)
// token should be cached on struct
is.Equal(t, "new-access-token", tf.token)
if time.Now().After(tf.tokenExp) {
t.Fatal("expiry should be in the future")
}
}
func TestGetTokenUsesCachedWhenNotExpired(t *testing.T) {
tf := &twitchfeed{
token: "cached-token",
tokenExp: time.Now().Add(24 * time.Hour),
}
tok, err := tf.getToken(context.Background())
is.Err(t, err, nil)
is.Equal(t, "cached-token", tok)
}
func TestGetTokenFetchesNewWhenExpired(t *testing.T) {
authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(twitchAuthResponse{
AccessToken: "refreshed-token",
ExpiresIn: 3600,
TokenType: "bearer",
})
}))
defer authSrv.Close()
tf := &twitchfeed{
clientID: "cid",
clientSecret: "secret",
client: authSrv.Client(),
token: "stale-token",
tokenExp: time.Now().Add(-1 * time.Hour), // expired
}
savedBase := twitchAuthBase
twitchAuthBase = authSrv.URL
defer func() { twitchAuthBase = savedBase }()
tok, err := tf.getToken(context.Background())
is.Err(t, err, nil)
is.Equal(t, "refreshed-token", tok)
}