feat: add twitch graphql streamer resolution
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
// service.go defines the Twitch domain lookups the rewritten app currently needs.
|
||||
// It wraps the lower-level GraphQL client with small typed methods for the viewer
|
||||
// identity and configured streamer resolution used to populate the terminal UI.
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"parasocial/internal/gql"
|
||||
)
|
||||
|
||||
// GQLClient is the minimal GraphQL interface the Twitch service depends on.
|
||||
type GQLClient interface {
|
||||
Do(context.Context, gql.Request, any) error
|
||||
}
|
||||
|
||||
// Service exposes the Twitch lookups the current app needs.
|
||||
type Service struct {
|
||||
GQL GQLClient
|
||||
}
|
||||
|
||||
// Viewer is the authenticated Twitch account.
|
||||
type Viewer struct {
|
||||
ID string
|
||||
Login string
|
||||
}
|
||||
|
||||
// Channel is the resolved Twitch channel identity for one streamer login.
|
||||
type Channel struct {
|
||||
ID string
|
||||
Login string
|
||||
}
|
||||
|
||||
// StreamerStatus describes one row's resolution state in the UI.
|
||||
type StreamerStatus string
|
||||
|
||||
const (
|
||||
StreamerLoading StreamerStatus = "loading"
|
||||
StreamerReady StreamerStatus = "ready"
|
||||
StreamerError StreamerStatus = "error"
|
||||
)
|
||||
|
||||
// StreamerEntry is the UI-facing state for one configured streamer.
|
||||
type StreamerEntry struct {
|
||||
ConfigLogin string
|
||||
Login string
|
||||
ChannelID string
|
||||
Status StreamerStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// ErrStreamerNotFound is returned when Twitch has no channel for the requested login.
|
||||
var ErrStreamerNotFound = errors.New("streamer does not exist")
|
||||
|
||||
// LoadingStreamerEntries seeds UI state from the normalized config logins.
|
||||
func LoadingStreamerEntries(logins []string) []StreamerEntry {
|
||||
entries := make([]StreamerEntry, 0, len(logins))
|
||||
for _, login := range logins {
|
||||
entries = append(entries, StreamerEntry{
|
||||
ConfigLogin: login,
|
||||
Status: StreamerLoading,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// CurrentUser resolves the authenticated viewer through Twitch GraphQL.
|
||||
func (s *Service) CurrentUser(ctx context.Context) (*Viewer, error) {
|
||||
var data struct {
|
||||
CurrentUser *struct {
|
||||
ID string `json:"id"`
|
||||
Login string `json:"login"`
|
||||
} `json:"currentUser"`
|
||||
}
|
||||
if err := s.GQL.Do(ctx, gql.CurrentUser(), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data.CurrentUser == nil || data.CurrentUser.ID == "" || data.CurrentUser.Login == "" {
|
||||
return nil, fmt.Errorf("current user response missing id or login")
|
||||
}
|
||||
return &Viewer{
|
||||
ID: data.CurrentUser.ID,
|
||||
Login: data.CurrentUser.Login,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveStreamer resolves a configured streamer login into canonical Twitch identity.
|
||||
func (s *Service) ResolveStreamer(ctx context.Context, login string) (*Channel, error) {
|
||||
var data struct {
|
||||
User *struct {
|
||||
ID string `json:"id"`
|
||||
Login string `json:"login"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := s.GQL.Do(ctx, gql.GetIDFromLogin(login), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data.User == nil || data.User.ID == "" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrStreamerNotFound, login)
|
||||
}
|
||||
resolvedLogin := data.User.Login
|
||||
if resolvedLogin == "" {
|
||||
resolvedLogin = login
|
||||
}
|
||||
return &Channel{ID: data.User.ID, Login: resolvedLogin}, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"parasocial/internal/gql"
|
||||
)
|
||||
|
||||
type fakeGQL struct {
|
||||
requests []gql.Request
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (f *fakeGQL) Do(_ context.Context, request gql.Request, out any) error {
|
||||
f.requests = append(f.requests, request)
|
||||
return json.Unmarshal([]byte(f.data[request.OperationName]), out)
|
||||
}
|
||||
|
||||
func TestCurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeGQL{data: map[string]string{
|
||||
"CurrentUser": `{"currentUser":{"id":"7","login":"viewer"}}`,
|
||||
}}
|
||||
service := &Service{GQL: client}
|
||||
viewer, err := service.CurrentUser(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if viewer.ID != "7" || viewer.Login != "viewer" {
|
||||
t.Fatalf("viewer = %#v", viewer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStreamer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeGQL{data: map[string]string{
|
||||
"GetIDFromLogin": `{"user":{"id":"123","login":"streamer"}}`,
|
||||
}}
|
||||
service := &Service{GQL: client}
|
||||
channel, err := service.ResolveStreamer(context.Background(), "streamer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if channel.ID != "123" || channel.Login != "streamer" {
|
||||
t.Fatalf("channel = %#v", channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStreamerNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeGQL{data: map[string]string{
|
||||
"GetIDFromLogin": `{"user":null}`,
|
||||
}}
|
||||
service := &Service{GQL: client}
|
||||
_, err := service.ResolveStreamer(context.Background(), "missing")
|
||||
if !errors.Is(err, ErrStreamerNotFound) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user