Repository: sivchari/gotwtr (go)

Issue:
Implement Trends endpoints

Pull request description:
feat: implement Trends API endpoint (GET /2/trends/by/woeid/{woeid})
- Added trendsByWOEIDURL endpoint constant
- Added Trends interface with TrendsByWOEID method
- Created trends.go with Trend and TrendsByWOEIDResponse structures
- Created trends_lookup.go with trendsByWOEID implementation
- Added TrendsByWOEID client method to client.go
- Added comprehensive tests covering success, error, and validation cases

Implements the only available Trends endpoint in X API v2:
- GET /2/trends/by/woeid/{woeid} - Returns trending topics for a specific location

close #205

Patch:
diff --git a/client.go b/client.go
index df96238..077a3ce 100644
--- a/client.go
+++ b/client.go
@@ -148,6 +148,11 @@ type CommunityNotes interface {
 	CreateCommunityNote(ctx context.Context, body *CreateCommunityNoteBody) (*CreateCommunityNoteResponse, error)
 }
 
+type Trends interface {
+	// Trends
+	TrendsByWOEID(ctx context.Context, woeid string) (*TrendsByWOEIDResponse, error)
+}
+
 // Twtr is a main interface for all Twitter API calls.
 type Twtr interface {
 	OAuth
@@ -158,6 +163,7 @@ type Twtr interface {
 	Compliances
 	DirectMessages
 	CommunityNotes
+	Trends
 }
 
 type client struct {
@@ -630,3 +636,8 @@ func (c *Client) SearchNotesWritten(ctx context.Context, opt ...*SearchNotesWrit
 func (c *Client) CreateCommunityNote(ctx context.Context, body *CreateCommunityNoteBody) (*CreateCommunityNoteResponse, error) {
 	return createCommunityNote(ctx, c.client, body)
 }
+
+// TrendsByWOEID returns trending topics for a specific location using Where On Earth ID (WOEID).
+func (c *Client) TrendsByWOEID(ctx context.Context, woeid string) (*TrendsByWOEIDResponse, error) {
+	return trendsByWOEID(ctx, c.client, woeid)
+}
diff --git a/endpoint.go b/endpoint.go
index 032d612..693e10f 100644
--- a/endpoint.go
+++ b/endpoint.go
@@ -138,3 +138,8 @@ const (
 	searchNotesWrittenURL          = "https://api.x.com/2/notes/search/notes_written"
 	createCommunityNoteURL         = "https://api.x.com/2/notes"
 )
+
+const (
+	// Trends
+	trendsByWOEIDURL = "https://api.x.com/2/trends/by/woeid/%v"
+)
diff --git a/trends.go b/trends.go
new file mode 100644
index 0000000..4928aff
--- /dev/null
+++ b/trends.go
@@ -0,0 +1,16 @@
+package gotwtr
+
+// Trend represents a trending topic
+type Trend struct {
+	TrendName  string `json:"trend_name"`
+	TweetCount int    `json:"tweet_count,omitempty"`
+}
+
+// TrendsByWOEIDResponse represents the response for trends by WOEID lookup
+type TrendsByWOEIDResponse struct {
+	Trends []*Trend            `json:"data,omitempty"`
+	Errors []*APIResponseError `json:"errors,omitempty"`
+	Title  string              `json:"title,omitempty"`
+	Detail string              `json:"detail,omitempty"`
+	Type   string              `json:"type,omitempty"`
+}
\ No newline at end of file
diff --git a/trends_lookup.go b/trends_lookup.go
new file mode 100644
index 0000000..b9855e6
--- /dev/null
+++ b/trends_lookup.go
@@ -0,0 +1,46 @@
+package gotwtr
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+)
+
+func trendsByWOEID(ctx context.Context, c *client, woeid string) (*TrendsByWOEIDResponse, error) {
+	if woeid == "" {
+		return nil, errors.New("trends by woeid: woeid parameter is required")
+	}
+
+	ep := fmt.Sprintf(trendsByWOEIDURL, woeid)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
+	if err != nil {
+		return nil, fmt.Errorf("trends by woeid new request with ctx: %w", err)
+	}
+
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("trends by woeid response: %w", err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	var tr TrendsByWOEIDResponse
+	if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
+		return nil, fmt.Errorf("trends by woeid decode: %w", err)
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		return &tr, &HTTPError{
+			APIName: "trends by woeid",
+			Status:  resp.Status,
+			URL:     req.URL.String(),
+		}
+	}
+
+	return &tr, nil
+}
\ No newline at end of file


Test patch:
diff --git a/trends_test.go b/trends_test.go
new file mode 100644
index 0000000..6add32c
--- /dev/null
+++ b/trends_test.go
@@ -0,0 +1,150 @@
+package gotwtr_test
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+
+	"github.com/sivchari/gotwtr"
+)
+
+func Test_trendsByWOEID(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		ctx    context.Context
+		client *http.Client
+		woeid  string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    *gotwtr.TrendsByWOEIDResponse
+		wantErr bool
+	}{
+		{
+			name: "200 ok the request was successful",
+			args: args{
+				ctx:   context.Background(),
+				woeid: "1",
+				client: mockHTTPClient(func(request *http.Request) *http.Response {
+					data := `{
+						"data": [
+							{
+								"trend_name": "Europe",
+								"tweet_count": 232408
+							},
+							{
+								"trend_name": "Isak",
+								"tweet_count": 2956
+							},
+							{
+								"trend_name": "#MondayMotivation"
+							}
+						]
+					}`
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Header:     http.Header{"Content-Type": []string{"application/json"}},
+						Body:       io.NopCloser(strings.NewReader(data)),
+					}
+				}),
+			},
+			want: &gotwtr.TrendsByWOEIDResponse{
+				Trends: []*gotwtr.Trend{
+					{
+						TrendName:  "Europe",
+						TweetCount: 232408,
+					},
+					{
+						TrendName:  "Isak",
+						TweetCount: 2956,
+					},
+					{
+						TrendName: "#MondayMotivation",
+					},
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "404 not found - invalid WOEID",
+			args: args{
+				ctx:   context.Background(),
+				woeid: "999999999",
+				client: mockHTTPClient(func(request *http.Request) *http.Response {
+					data := `{
+						"title": "Not Found Error",
+						"detail": "Could not find trends for WOEID: 999999999",
+						"type": "https://api.x.com/2/problems/resource-not-found"
+					}`
+					return &http.Response{
+						StatusCode: http.StatusNotFound,
+						Header:     http.Header{"Content-Type": []string{"application/json"}},
+						Body:       io.NopCloser(strings.NewReader(data)),
+					}
+				}),
+			},
+			want: &gotwtr.TrendsByWOEIDResponse{
+				Title:  "Not Found Error",
+				Detail: "Could not find trends for WOEID: 999999999",
+				Type:   "https://api.x.com/2/problems/resource-not-found",
+			},
+			wantErr: true,
+		},
+		{
+			name: "empty woeid",
+			args: args{
+				ctx:    context.Background(),
+				woeid:  "",
+				client: http.DefaultClient,
+			},
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name: "rate limit exceeded",
+			args: args{
+				ctx:   context.Background(),
+				woeid: "1",
+				client: mockHTTPClient(func(request *http.Request) *http.Response {
+					data := `{
+						"title": "Too Many Requests",
+						"detail": "Too Many Requests",
+						"type": "https://api.x.com/2/problems/usage-capped"
+					}`
+					return &http.Response{
+						StatusCode: http.StatusTooManyRequests,
+						Header:     http.Header{"Content-Type": []string{"application/json"}},
+						Body:       io.NopCloser(strings.NewReader(data)),
+					}
+				}),
+			},
+			want: &gotwtr.TrendsByWOEIDResponse{
+				Title:  "Too Many Requests",
+				Detail: "Too Many Requests",
+				Type:   "https://api.x.com/2/problems/usage-capped",
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("key", gotwtr.WithHTTPClient(tt.args.client))
+			got, err := c.TrendsByWOEID(tt.args.ctx, tt.args.woeid)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("client.TrendsByWOEID() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if diff := cmp.Diff(tt.want, got); diff != "" {
+				t.Errorf("client.TrendsByWOEID() mismatch (-want +got):\n%s", diff)
+				return
+			}
+		})
+	}
+}
\ No newline at end of file