Repository: nicklaw5/helix
Pull request #174 (2023-02-04 11:12:42)

feat: channels followed & followers
- add new followed channels endpoint
- add new channel followers endpoint
- add unit tests
- go fmt fixes

- https://dev.twitch.tv/docs/api/reference/#get-followed-channels
- https://dev.twitch.tv/docs/api/reference/#get-channel-followers


Patch:
diff --git a/channels.go b/channels.go
index 70c636d..097dd30 100644
--- a/channels.go
+++ b/channels.go
@@ -36,6 +36,62 @@ type SearchChannelsResponse struct {
 	Data ManySearchChannels
 }
 
+type GetChannelFollowsParams struct {
+	BroadcasterID string `query:"broadcaster_id"` // required
+	UserID        string `query:"user_id"`
+	First         int    `query:"first"` // max 100
+	After         string `query:"after"`
+}
+
+// SearchChannelsResponse is the response from SearchChannels
+type GetChannelFollowersResponse struct {
+	ResponseCommon
+	Data ManyChannelFollows
+}
+
+// ManySearchChannels is the response data from SearchChannels
+type ManyChannelFollows struct {
+	Channels   []ChannelFollow `json:"data"`
+	Pagination Pagination      `json:"pagination"`
+	Total      int             `json:"total"`
+}
+
+// Channel describes a follow of a channel
+type ChannelFollow struct {
+	UserID    string `json:"user_id"`
+	Username  string `json:"user_name"`
+	UserLogin string `json:"user_login"`
+	Followed  Time   `json:"followed_at"`
+}
+
+type GetFollowedChannelParams struct {
+	BroadcasterID string `query:"broadcaster_id"`
+	UserID        string `query:"user_id"` // required
+	First         int    `query:"first"`   // max 100
+	After         string `query:"after"`
+}
+
+// SearchChannelsResponse is the response from SearchChannels
+type GetFollowedChannelResponse struct {
+	ResponseCommon
+	Data ManyFollowedChannels
+}
+
+// ManySearchChannels is the response data from SearchChannels
+type ManyFollowedChannels struct {
+	FollowedChannels []FollowedChannel `json:"data"`
+	Pagination       Pagination        `json:"pagination"`
+	Total            int64             `json:"total"`
+}
+
+// Channel describes a followed channel
+type FollowedChannel struct {
+	BroadcasterID   string `json:"broadcaster_id"`
+	BroadcasterName string `json:"broadcaster_name"`
+	BroadcaserLogin string `json:"broadcaster_login"`
+	Followed        Time   `json:"followed_at"`
+}
+
 // SearchChannels searches for Twitch channels based on the given search
 // parameters. Unlike GetStreams, this can also return offline channels.
 func (c *Client) SearchChannels(params *SearchChannelsParams) (*SearchChannelsResponse, error) {
@@ -115,3 +171,39 @@ func (c *Client) EditChannelInformation(params *EditChannelInformationParams) (*
 
 	return channels, nil
 }
+
+// GetChannelFollows Gets a list of users that follow the specified broadcaster.
+// You can also use this endpoint to see whether a specific user follows the broadcaster..
+// requires moderator:read:followers
+func (c *Client) GetChannelFollows(params *GetChannelFollowsParams) (*GetChannelFollowersResponse, error) {
+	resp, err := c.get("/channels/followers", &ManyChannelFollows{}, params)
+	if err != nil {
+		return nil, err
+	}
+
+	channelFollows := &GetChannelFollowersResponse{}
+	resp.HydrateResponseCommon(&channelFollows.ResponseCommon)
+	channelFollows.Data.Total = resp.Data.(*ManyChannelFollows).Total
+	channelFollows.Data.Channels = resp.Data.(*ManyChannelFollows).Channels
+	channelFollows.Data.Pagination = resp.Data.(*ManyChannelFollows).Pagination
+
+	return channelFollows, nil
+}
+
+// GetFollowedChannels Gets a list of broadcasters that the specified user follows.
+// You can also use this endpoint to see whether a user follows a specific broadcaster.
+// requires user:read:follows
+func (c *Client) GetFollowedChannels(params *GetFollowedChannelParams) (*GetFollowedChannelResponse, error) {
+	resp, err := c.get("/channels/followed", &ManyFollowedChannels{}, params)
+	if err != nil {
+		return nil, err
+	}
+
+	followedChannels := &GetFollowedChannelResponse{}
+	resp.HydrateResponseCommon(&followedChannels.ResponseCommon)
+	followedChannels.Data.Total = resp.Data.(*ManyFollowedChannels).Total
+	followedChannels.Data.FollowedChannels = resp.Data.(*ManyFollowedChannels).FollowedChannels
+	followedChannels.Data.Pagination = resp.Data.(*ManyFollowedChannels).Pagination
+
+	return followedChannels, nil
+}
diff --git a/moderation.go b/moderation.go
index b841abb..93a57d4 100644
--- a/moderation.go
+++ b/moderation.go
@@ -329,7 +329,7 @@ func (c *Client) GetModerators(params *GetModeratorsParams) (*ModeratorsResponse
 	if err != nil {
 		return nil, err
 	}
-  
+
 	moderators := &ModeratorsResponse{}
 	resp.HydrateResponseCommon(&moderators.ResponseCommon)
 	moderators.Data.Moderators = resp.Data.(*ManyModerators).Moderators
diff --git a/webhooks.go b/webhooks.go
index 074d902..bb99878 100644
--- a/webhooks.go
+++ b/webhooks.go
@@ -120,12 +120,12 @@ func GetWebhookTopicFromRequest(req *http.Request) WebhookTopic {
 // For example, say we receive a "User Follows" webhook event from Twitch.
 // Its "Link" header value look likes the following:
 //
-// 		<https://api.twitch.tv/helix/webhooks/hub>; rel="hub", <https://api.twitch.tv/helix/users/follows?first=1&from_id=111116&to_id=22222>; rel="self"
+//	<https://api.twitch.tv/helix/webhooks/hub>; rel="hub", <https://api.twitch.tv/helix/users/follows?first=1&from_id=111116&to_id=22222>; rel="self"
 //
 // From which GetWebhookTopicValuesFromRequest will return a map with the
 // values of from_id and to_id:
 //
-// 		map[from_id:111116 to_id:22222]
+//	map[from_id:111116 to_id:22222]
 //
 // This is particularly useful for webhooks events that do not have a distinguishable
 // JSON payload, such as the "Stream Changed" down event.


Test patch:
diff --git a/channels_test.go b/channels_test.go
index a1a041b..464c4c2 100644
--- a/channels_test.go
+++ b/channels_test.go
@@ -381,3 +381,147 @@ func TestEditChannelInformation(t *testing.T) {
 		t.Error("expected error does match return error")
 	}
 }
+
+func TestChannelFollows(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		statusCode int
+		options    *Options
+		params     *GetChannelFollowsParams
+		respBody   string
+	}{
+		{
+			http.StatusOK,
+			&Options{ClientID: "my-client-id"},
+			&GetChannelFollowsParams{
+				BroadcasterID: "123",
+			},
+			`{ "total": 8, "data": [{ "user_id": "11111", "user_name": "UserDisplayName", "user_login": "userloginname", "followed_at": "2022-05-24T22:22:08Z" }], "pagination": { "cursor": "eyJiIjpudWxsLCJhIjp7Ik9mZnNldCI6NX19" } }`,
+		},
+		{
+			http.StatusBadRequest,
+			&Options{ClientID: "my-client-id"},
+			&GetChannelFollowsParams{
+				BroadcasterID: "",
+			},
+			`{"error":"Bad Request","status":400,"message":"the broadcaster id was not provided"}`,
+		},
+	}
+
+	for _, testCase := range testCases {
+		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
+
+		resp, err := c.GetChannelFollows(testCase.params)
+		if err != nil {
+			t.Error(err)
+		}
+
+		// Test Bad Request Responses
+		if resp.StatusCode == http.StatusBadRequest {
+			broadcasterIDErrStr := "the broadcaster id was not provided"
+
+			if resp.ErrorMessage != broadcasterIDErrStr {
+				t.Errorf("expected error message to be \"%s\", got \"%s\"", broadcasterIDErrStr, resp.ErrorMessage)
+				continue
+			}
+		}
+
+		if resp.StatusCode != testCase.statusCode {
+			t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode)
+		}
+	}
+
+	// Test with HTTP Failure
+	options := &Options{
+		ClientID: "my-client-id",
+		HTTPClient: &badMockHTTPClient{
+			newMockHandler(0, "", nil),
+		},
+	}
+	c := &Client{
+		opts: options,
+		ctx:  context.Background(),
+	}
+
+	_, err := c.GetChannelFollows(&GetChannelFollowsParams{})
+	if err == nil {
+		t.Error("expected error but got nil")
+	}
+
+	if err.Error() != "Failed to execute API request: Oops, that's bad :(" {
+		t.Error("expected error does match return error")
+	}
+}
+
+func TestFollowedChannels(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		statusCode int
+		options    *Options
+		params     *GetFollowedChannelParams
+		respBody   string
+	}{
+		{
+			http.StatusOK,
+			&Options{ClientID: "my-client-id"},
+			&GetFollowedChannelParams{
+				UserID: "123",
+			},
+			`{ "total": 8, "data": [{ "broadcaster_id": "11111", "broadcaster_login": "userloginname", "broadcaster_name": "UserDisplayName", "followed_at": "2022-05-24T22:22:08Z" }], "pagination": { "cursor": "eyJiIjpudWxsLCJhIjp7Ik9mZnNldCI6NX19" } }`,
+		},
+		{
+			http.StatusBadRequest,
+			&Options{ClientID: "my-client-id"},
+			&GetFollowedChannelParams{
+				UserID: "",
+			},
+			`{"error":"Bad Request","status":400,"message":"the user id was not provided"}`,
+		},
+	}
+
+	for _, testCase := range testCases {
+		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
+
+		resp, err := c.GetFollowedChannels(testCase.params)
+		if err != nil {
+			t.Error(err)
+		}
+
+		// Test Bad Request Responses
+		if resp.StatusCode == http.StatusBadRequest {
+			broadcasterIDErrStr := "the user id was not provided"
+
+			if resp.ErrorMessage != broadcasterIDErrStr {
+				t.Errorf("expected error message to be \"%s\", got \"%s\"", broadcasterIDErrStr, resp.ErrorMessage)
+				continue
+			}
+		}
+
+		if resp.StatusCode != testCase.statusCode {
+			t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode)
+		}
+	}
+
+	// Test with HTTP Failure
+	options := &Options{
+		ClientID: "my-client-id",
+		HTTPClient: &badMockHTTPClient{
+			newMockHandler(0, "", nil),
+		},
+	}
+	c := &Client{
+		opts: options,
+		ctx:  context.Background(),
+	}
+
+	_, err := c.GetFollowedChannels(&GetFollowedChannelParams{})
+	if err == nil {
+		t.Error("expected error but got nil")
+	}
+
+	if err.Error() != "Failed to execute API request: Oops, that's bad :(" {
+		t.Error("expected error does match return error")
+	}
+}