From 0e55317cdb8869f8900be2394e7d132c0b3bf277 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 21 Jun 2026 11:23:17 +0300 Subject: [PATCH 1/4] Add optional Xquik search backend --- cmd/search.go | 32 ++++++-- internal/api/xquik.go | 154 +++++++++++++++++++++++++++++++++++++ internal/api/xquik_test.go | 64 +++++++++++++++ 3 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 internal/api/xquik.go create mode 100644 internal/api/xquik_test.go diff --git a/cmd/search.go b/cmd/search.go index 3a8d666..5efe37b 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/paolo/x-cli/internal/api" + "github.com/paolo/x-cli/internal/models" "github.com/paolo/x-cli/internal/output" "github.com/spf13/cobra" ) @@ -13,6 +14,7 @@ import ( var searchCount int var searchCursor string var searchType string +var searchBackend string var searchCmd = &cobra.Command{ Use: "search ", @@ -26,12 +28,7 @@ var searchCmd = &cobra.Command{ return fmt.Errorf("invalid search type: %s (use: top, latest, people, media)", searchType) } - client, err := api.NewClient() - if err != nil { - return err - } - - page, rawJSON, err := client.Search(context.Background(), query, product, searchCount, searchCursor) + page, rawJSON, err := runSearch(query, product) if err != nil { return fmt.Errorf("search '%s': %w", query, err) } @@ -48,5 +45,28 @@ func init() { searchCmd.Flags().IntVar(&searchCount, "count", 20, "Number of results") searchCmd.Flags().StringVar(&searchCursor, "cursor", "", "Pagination cursor") searchCmd.Flags().StringVar(&searchType, "type", "top", "Search type: top, latest, people, media") + searchCmd.Flags().StringVar(&searchBackend, "backend", "x", "Search backend: x or xquik") rootCmd.AddCommand(searchCmd) } + +func runSearch(query string, product string) (models.TimelinePage, []byte, error) { + switch searchBackend { + case "x": + client, err := api.NewClient() + if err != nil { + return models.TimelinePage{}, nil, err + } + return client.Search(context.Background(), query, product, searchCount, searchCursor) + case "xquik": + if searchType != "top" && searchType != "latest" { + return models.TimelinePage{}, nil, fmt.Errorf("xquik backend supports tweet search only") + } + client, err := api.NewXquikClientFromEnv() + if err != nil { + return models.TimelinePage{}, nil, err + } + return client.Search(context.Background(), query, product, searchCount, searchCursor) + default: + return models.TimelinePage{}, nil, fmt.Errorf("invalid backend: %s (use: x, xquik)", searchBackend) + } +} diff --git a/internal/api/xquik.go b/internal/api/xquik.go new file mode 100644 index 0000000..1edb50e --- /dev/null +++ b/internal/api/xquik.go @@ -0,0 +1,154 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/paolo/x-cli/internal/models" +) + +const defaultXquikBaseURL = "https://xquik.com/api/v1" + +type XquikClient struct { + apiKey string + baseURL string + httpClient *http.Client +} + +type xquikSearchResponse struct { + Tweets []xquikTweet `json:"tweets"` + Data []xquikTweet `json:"data"` + NextCursor string `json:"next_cursor"` + NextCursorCamel string `json:"nextCursor"` + NextToken string `json:"nextToken"` +} + +type xquikTweet struct { + ID string `json:"id"` + Text string `json:"text"` + CreatedAt string `json:"createdAt"` + Author xquikAuthor `json:"author"` +} + +type xquikAuthor struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` +} + +func NewXquikClientFromEnv() (*XquikClient, error) { + return NewXquikClient(os.Getenv("XQUIK_API_KEY"), os.Getenv("XQUIK_API_BASE_URL")) +} + +func NewXquikClient(apiKey string, baseURL string) (*XquikClient, error) { + trimmedKey := strings.TrimSpace(apiKey) + if trimmedKey == "" { + return nil, fmt.Errorf("xquik: XQUIK_API_KEY is required") + } + + trimmedBaseURL := strings.TrimSpace(baseURL) + if trimmedBaseURL == "" { + trimmedBaseURL = defaultXquikBaseURL + } + + return &XquikClient{ + apiKey: trimmedKey, + baseURL: strings.TrimRight(trimmedBaseURL, "/"), + httpClient: &http.Client{Timeout: 60 * time.Second}, + }, nil +} + +func (c *XquikClient) Search(ctx context.Context, query string, queryType string, count int, cursor string) (models.TimelinePage, []byte, error) { + if c == nil { + return models.TimelinePage{}, nil, fmt.Errorf("xquik: nil client") + } + values := url.Values{} + values.Set("q", query) + if queryType != "" { + values.Set("queryType", queryType) + } + if count > 0 { + values.Set("limit", fmt.Sprintf("%d", count)) + } + if cursor != "" { + values.Set("cursor", cursor) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/x/tweets/search?"+values.Encode(), nil) + if err != nil { + return models.TimelinePage{}, nil, fmt.Errorf("xquik: create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("x-api-key", c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return models.TimelinePage{}, nil, fmt.Errorf("xquik: request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return models.TimelinePage{}, nil, fmt.Errorf("xquik: read response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return models.TimelinePage{}, body, fmt.Errorf("xquik: API error (HTTP %d): %s", resp.StatusCode, truncate(string(body), 200)) + } + + var payload xquikSearchResponse + if err := json.Unmarshal(body, &payload); err != nil { + return models.TimelinePage{}, body, fmt.Errorf("xquik: decode search response: %w", err) + } + + tweets := payload.Tweets + if len(tweets) == 0 { + tweets = payload.Data + } + + page := models.TimelinePage{ + Tweets: make([]*models.Tweet, 0, len(tweets)), + NextCursor: firstNonEmpty(payload.NextCursor, payload.NextCursorCamel, payload.NextToken), + } + for _, tweet := range tweets { + page.Tweets = append(page.Tweets, tweet.toModel()) + } + + return page, body, nil +} + +func (tweet xquikTweet) toModel() *models.Tweet { + return &models.Tweet{ + ID: tweet.ID, + FullText: tweet.Text, + Author: models.UserSummary{ + ID: tweet.Author.ID, + ScreenName: tweet.Author.Username, + Name: tweet.Author.Name, + }, + CreatedAt: parseXquikTime(tweet.CreatedAt), + } +} + +func parseXquikTime(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{} + } + return parsed +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/api/xquik_test.go b/internal/api/xquik_test.go new file mode 100644 index 0000000..a112bb0 --- /dev/null +++ b/internal/api/xquik_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestXquikSearchMapsTweets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/x/tweets/search" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("q"); got != "golang" { + t.Fatalf("unexpected query: %s", got) + } + if got := r.URL.Query().Get("queryType"); got != "Latest" { + t.Fatalf("unexpected query type: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "2" { + t.Fatalf("unexpected limit: %s", got) + } + if got := r.URL.Query().Get("cursor"); got != "abc" { + t.Fatalf("unexpected cursor: %s", got) + } + if got := r.Header.Get("x-api-key"); got != "test-key" { + t.Fatalf("unexpected api key header: %s", got) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tweets":[{"id":"1","text":"hello","createdAt":"2026-01-02T03:04:05Z","author":{"id":"42","username":"ada","name":"Ada"}}],"next_cursor":"next"}`)) + })) + defer server.Close() + + client, err := NewXquikClient("test-key", server.URL) + if err != nil { + t.Fatalf("new client: %v", err) + } + + page, raw, err := client.Search(context.Background(), "golang", "Latest", 2, "abc") + if err != nil { + t.Fatalf("search: %v", err) + } + + if len(raw) == 0 { + t.Fatal("expected raw response") + } + if page.NextCursor != "next" { + t.Fatalf("unexpected cursor: %s", page.NextCursor) + } + if len(page.Tweets) != 1 { + t.Fatalf("unexpected tweet count: %d", len(page.Tweets)) + } + if page.Tweets[0].ID != "1" { + t.Fatalf("unexpected tweet id: %s", page.Tweets[0].ID) + } + if page.Tweets[0].FullText != "hello" { + t.Fatalf("unexpected tweet text: %s", page.Tweets[0].FullText) + } + if page.Tweets[0].Author.ScreenName != "ada" { + t.Fatalf("unexpected author username: %s", page.Tweets[0].Author.ScreenName) + } +} From 1eb19ca18051ea831dadbf2635185ead5680a38e Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 21 Jun 2026 11:34:16 +0300 Subject: [PATCH 2/4] Address Xquik backend review feedback --- cmd/search.go | 2 +- internal/api/xquik.go | 6 +++++- internal/api/xquik_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/cmd/search.go b/cmd/search.go index 5efe37b..95118b6 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -58,7 +58,7 @@ func runSearch(query string, product string) (models.TimelinePage, []byte, error } return client.Search(context.Background(), query, product, searchCount, searchCursor) case "xquik": - if searchType != "top" && searchType != "latest" { + if product != "Top" && product != "Latest" { return models.TimelinePage{}, nil, fmt.Errorf("xquik backend supports tweet search only") } client, err := api.NewXquikClientFromEnv() diff --git a/internal/api/xquik.go b/internal/api/xquik.go index 1edb50e..8b7b6ce 100644 --- a/internal/api/xquik.go +++ b/internal/api/xquik.go @@ -15,6 +15,7 @@ import ( ) const defaultXquikBaseURL = "https://xquik.com/api/v1" +const maxXquikResponseBytes = 4 * 1024 * 1024 type XquikClient struct { apiKey string @@ -94,10 +95,13 @@ func (c *XquikClient) Search(ctx context.Context, query string, queryType string } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxXquikResponseBytes+1)) if err != nil { return models.TimelinePage{}, nil, fmt.Errorf("xquik: read response: %w", err) } + if len(body) > maxXquikResponseBytes { + return models.TimelinePage{}, body[:maxXquikResponseBytes], fmt.Errorf("xquik: response exceeds %d bytes", maxXquikResponseBytes) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return models.TimelinePage{}, body, fmt.Errorf("xquik: API error (HTTP %d): %s", resp.StatusCode, truncate(string(body), 200)) } diff --git a/internal/api/xquik_test.go b/internal/api/xquik_test.go index a112bb0..fd63e9c 100644 --- a/internal/api/xquik_test.go +++ b/internal/api/xquik_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -62,3 +63,27 @@ func TestXquikSearchMapsTweets(t *testing.T) { t.Fatalf("unexpected author username: %s", page.Tweets[0].Author.ScreenName) } } + +func TestXquikSearchRejectsOversizedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(strings.Repeat("x", maxXquikResponseBytes+1))) + })) + defer server.Close() + + client, err := NewXquikClient("test-key", server.URL) + if err != nil { + t.Fatalf("new client: %v", err) + } + + _, raw, err := client.Search(context.Background(), "golang", "Latest", 2, "") + if err == nil { + t.Fatal("expected oversized response error") + } + if !strings.Contains(err.Error(), "response exceeds") { + t.Fatalf("unexpected error: %v", err) + } + if len(raw) != maxXquikResponseBytes { + t.Fatalf("unexpected raw response size: %d", len(raw)) + } +} From 798b48fe7a884686f178d117109d945406cd540e Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 21 Jun 2026 11:36:53 +0300 Subject: [PATCH 3/4] Address Xquik search review feedback --- cmd/search.go | 13 +++++++++- cmd/search_test.go | 20 +++++++++++++++ internal/api/xquik.go | 51 ++++++++++++++++++++++++++++++++------ internal/api/xquik_test.go | 14 ++++++++++- 4 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 cmd/search_test.go diff --git a/cmd/search.go b/cmd/search.go index 95118b6..b152545 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -35,12 +35,23 @@ var searchCmd = &cobra.Command{ output.PrintTweets(page.Tweets, jsonOutput, rawJSON) if !jsonOutput { - output.PrintCursor(fmt.Sprintf("x-cli search \"%s\"", query), page.NextCursor) + output.PrintCursor(searchPageCommand(query), page.NextCursor) } return nil }, } +func searchPageCommand(query string) string { + command := fmt.Sprintf("x-cli search \"%s\"", query) + if searchType != "top" { + command += fmt.Sprintf(" --type %s", searchType) + } + if searchBackend != "x" { + command += fmt.Sprintf(" --backend %s", searchBackend) + } + return command +} + func init() { searchCmd.Flags().IntVar(&searchCount, "count", 20, "Number of results") searchCmd.Flags().StringVar(&searchCursor, "cursor", "", "Pagination cursor") diff --git a/cmd/search_test.go b/cmd/search_test.go new file mode 100644 index 0000000..33335bd --- /dev/null +++ b/cmd/search_test.go @@ -0,0 +1,20 @@ +package cmd + +import "testing" + +func TestSearchPageCommandPreservesNonDefaultFlags(t *testing.T) { + originalType := searchType + originalBackend := searchBackend + t.Cleanup(func() { + searchType = originalType + searchBackend = originalBackend + }) + + searchType = "latest" + searchBackend = "xquik" + + command := searchPageCommand("golang") + if command != `x-cli search "golang" --type latest --backend xquik` { + t.Fatalf("unexpected command: %s", command) + } +} diff --git a/internal/api/xquik.go b/internal/api/xquik.go index 8b7b6ce..3077719 100644 --- a/internal/api/xquik.go +++ b/internal/api/xquik.go @@ -14,8 +14,10 @@ import ( "github.com/paolo/x-cli/internal/models" ) -const defaultXquikBaseURL = "https://xquik.com/api/v1" -const maxXquikResponseBytes = 4 * 1024 * 1024 +const ( + defaultXquikBaseURL = "https://xquik.com/api/v1" + maxXquikResponseBytes = 4 * 1024 * 1024 +) type XquikClient struct { apiKey string @@ -32,10 +34,17 @@ type xquikSearchResponse struct { } type xquikTweet struct { - ID string `json:"id"` - Text string `json:"text"` - CreatedAt string `json:"createdAt"` - Author xquikAuthor `json:"author"` + ID string `json:"id"` + Text string `json:"text"` + CreatedAt string `json:"createdAt"` + RetweetCount int `json:"retweetCount"` + LikeCount int `json:"likeCount"` + ReplyCount int `json:"replyCount"` + QuoteCount int `json:"quoteCount"` + BookmarkCount int `json:"bookmarkCount"` + ViewCount int `json:"viewCount"` + Media []xquikMedia `json:"media"` + Author xquikAuthor `json:"author"` } type xquikAuthor struct { @@ -44,6 +53,12 @@ type xquikAuthor struct { Name string `json:"name"` } +type xquikMedia struct { + Type string `json:"type"` + MediaURL string `json:"mediaUrl"` + URL string `json:"url"` +} + func NewXquikClientFromEnv() (*XquikClient, error) { return NewXquikClient(os.Getenv("XQUIK_API_KEY"), os.Getenv("XQUIK_API_BASE_URL")) } @@ -129,17 +144,37 @@ func (c *XquikClient) Search(ctx context.Context, query string, queryType string func (tweet xquikTweet) toModel() *models.Tweet { return &models.Tweet{ - ID: tweet.ID, - FullText: tweet.Text, + ID: tweet.ID, + FullText: tweet.Text, + RetweetCount: tweet.RetweetCount, + LikeCount: tweet.LikeCount, + ReplyCount: tweet.ReplyCount, + QuoteCount: tweet.QuoteCount, + BookmarkCount: tweet.BookmarkCount, + ViewCount: tweet.ViewCount, Author: models.UserSummary{ ID: tweet.Author.ID, ScreenName: tweet.Author.Username, Name: tweet.Author.Name, }, CreatedAt: parseXquikTime(tweet.CreatedAt), + Media: toModelMedia(tweet.Media), } } +func toModelMedia(items []xquikMedia) []models.Media { + media := make([]models.Media, 0, len(items)) + for _, item := range items { + url := firstNonEmpty(item.MediaURL, item.URL) + media = append(media, models.Media{ + Type: item.Type, + URL: url, + VideoURL: url, + }) + } + return media +} + func parseXquikTime(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { diff --git a/internal/api/xquik_test.go b/internal/api/xquik_test.go index fd63e9c..3443e9a 100644 --- a/internal/api/xquik_test.go +++ b/internal/api/xquik_test.go @@ -30,7 +30,7 @@ func TestXquikSearchMapsTweets(t *testing.T) { } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"tweets":[{"id":"1","text":"hello","createdAt":"2026-01-02T03:04:05Z","author":{"id":"42","username":"ada","name":"Ada"}}],"next_cursor":"next"}`)) + _, _ = w.Write([]byte(`{"tweets":[{"id":"1","text":"hello","createdAt":"2026-01-02T03:04:05Z","likeCount":5,"retweetCount":3,"replyCount":2,"viewCount":99,"media":[{"type":"photo","mediaUrl":"https://example.com/photo.jpg","url":"https://x.com/photo"}],"author":{"id":"42","username":"ada","name":"Ada"}}],"next_cursor":"next"}`)) })) defer server.Close() @@ -62,6 +62,18 @@ func TestXquikSearchMapsTweets(t *testing.T) { if page.Tweets[0].Author.ScreenName != "ada" { t.Fatalf("unexpected author username: %s", page.Tweets[0].Author.ScreenName) } + if page.Tweets[0].LikeCount != 5 { + t.Fatalf("unexpected like count: %d", page.Tweets[0].LikeCount) + } + if page.Tweets[0].RetweetCount != 3 { + t.Fatalf("unexpected retweet count: %d", page.Tweets[0].RetweetCount) + } + if len(page.Tweets[0].Media) != 1 { + t.Fatalf("unexpected media count: %d", len(page.Tweets[0].Media)) + } + if page.Tweets[0].Media[0].URL != "https://example.com/photo.jpg" { + t.Fatalf("unexpected media url: %s", page.Tweets[0].Media[0].URL) + } } func TestXquikSearchRejectsOversizedResponse(t *testing.T) { From 564495a2b5ab8ee3bfa62fb31247c6c0e933a628 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 21 Jun 2026 11:43:49 +0300 Subject: [PATCH 4/4] Address Xquik pagination review feedback --- cmd/search_test.go | 17 +++++++++++++++++ internal/api/xquik.go | 25 ++++++++++++++++++++----- internal/api/xquik_test.go | 23 +++++++++++++++++++---- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/cmd/search_test.go b/cmd/search_test.go index 33335bd..fd0bbc1 100644 --- a/cmd/search_test.go +++ b/cmd/search_test.go @@ -2,6 +2,23 @@ package cmd import "testing" +func TestSearchPageCommandOmitsDefaultFlags(t *testing.T) { + originalType := searchType + originalBackend := searchBackend + t.Cleanup(func() { + searchType = originalType + searchBackend = originalBackend + }) + + searchType = "top" + searchBackend = "x" + + command := searchPageCommand("golang") + if command != `x-cli search "golang"` { + t.Fatalf("unexpected command: %s", command) + } +} + func TestSearchPageCommandPreservesNonDefaultFlags(t *testing.T) { originalType := searchType originalBackend := searchBackend diff --git a/internal/api/xquik.go b/internal/api/xquik.go index 3077719..f4d916f 100644 --- a/internal/api/xquik.go +++ b/internal/api/xquik.go @@ -37,6 +37,10 @@ type xquikTweet struct { ID string `json:"id"` Text string `json:"text"` CreatedAt string `json:"createdAt"` + IsQuoteStatus bool `json:"isQuoteStatus"` + InReplyToID string `json:"inReplyToId"` + Language string `json:"language"` + Lang string `json:"lang"` RetweetCount int `json:"retweetCount"` LikeCount int `json:"likeCount"` ReplyCount int `json:"replyCount"` @@ -57,6 +61,9 @@ type xquikMedia struct { Type string `json:"type"` MediaURL string `json:"mediaUrl"` URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` + VideoURL string `json:"videoUrl"` } func NewXquikClientFromEnv() (*XquikClient, error) { @@ -152,24 +159,32 @@ func (tweet xquikTweet) toModel() *models.Tweet { QuoteCount: tweet.QuoteCount, BookmarkCount: tweet.BookmarkCount, ViewCount: tweet.ViewCount, + IsQuote: tweet.IsQuoteStatus, + Language: firstNonEmpty(tweet.Language, tweet.Lang), + InReplyTo: tweet.InReplyToID, + Media: mapXquikMedia(tweet.Media), Author: models.UserSummary{ ID: tweet.Author.ID, ScreenName: tweet.Author.Username, Name: tweet.Author.Name, }, CreatedAt: parseXquikTime(tweet.CreatedAt), - Media: toModelMedia(tweet.Media), } } -func toModelMedia(items []xquikMedia) []models.Media { +func mapXquikMedia(items []xquikMedia) []models.Media { + if len(items) == 0 { + return nil + } + media := make([]models.Media, 0, len(items)) for _, item := range items { - url := firstNonEmpty(item.MediaURL, item.URL) media = append(media, models.Media{ Type: item.Type, - URL: url, - VideoURL: url, + URL: firstNonEmpty(item.MediaURL, item.URL), + Width: item.Width, + Height: item.Height, + VideoURL: firstNonEmpty(item.VideoURL, item.URL), }) } return media diff --git a/internal/api/xquik_test.go b/internal/api/xquik_test.go index 3443e9a..3de70ba 100644 --- a/internal/api/xquik_test.go +++ b/internal/api/xquik_test.go @@ -30,7 +30,7 @@ func TestXquikSearchMapsTweets(t *testing.T) { } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"tweets":[{"id":"1","text":"hello","createdAt":"2026-01-02T03:04:05Z","likeCount":5,"retweetCount":3,"replyCount":2,"viewCount":99,"media":[{"type":"photo","mediaUrl":"https://example.com/photo.jpg","url":"https://x.com/photo"}],"author":{"id":"42","username":"ada","name":"Ada"}}],"next_cursor":"next"}`)) + _, _ = w.Write([]byte(`{"tweets":[{"id":"1","text":"hello","createdAt":"2026-01-02T03:04:05Z","lang":"en","likeCount":7,"retweetCount":3,"replyCount":2,"quoteCount":1,"viewCount":100,"bookmarkCount":4,"media":[{"type":"photo","mediaUrl":"https://pbs.twimg.com/media/example.jpg","width":640,"height":480}],"author":{"id":"42","username":"ada","name":"Ada"}}],"next_cursor":"next"}`)) })) defer server.Close() @@ -62,17 +62,32 @@ func TestXquikSearchMapsTweets(t *testing.T) { if page.Tweets[0].Author.ScreenName != "ada" { t.Fatalf("unexpected author username: %s", page.Tweets[0].Author.ScreenName) } - if page.Tweets[0].LikeCount != 5 { + if page.Tweets[0].LikeCount != 7 { t.Fatalf("unexpected like count: %d", page.Tweets[0].LikeCount) } if page.Tweets[0].RetweetCount != 3 { t.Fatalf("unexpected retweet count: %d", page.Tweets[0].RetweetCount) } + if page.Tweets[0].ReplyCount != 2 { + t.Fatalf("unexpected reply count: %d", page.Tweets[0].ReplyCount) + } + if page.Tweets[0].QuoteCount != 1 { + t.Fatalf("unexpected quote count: %d", page.Tweets[0].QuoteCount) + } + if page.Tweets[0].ViewCount != 100 { + t.Fatalf("unexpected view count: %d", page.Tweets[0].ViewCount) + } + if page.Tweets[0].BookmarkCount != 4 { + t.Fatalf("unexpected bookmark count: %d", page.Tweets[0].BookmarkCount) + } + if page.Tweets[0].Language != "en" { + t.Fatalf("unexpected language: %s", page.Tweets[0].Language) + } if len(page.Tweets[0].Media) != 1 { t.Fatalf("unexpected media count: %d", len(page.Tweets[0].Media)) } - if page.Tweets[0].Media[0].URL != "https://example.com/photo.jpg" { - t.Fatalf("unexpected media url: %s", page.Tweets[0].Media[0].URL) + if page.Tweets[0].Media[0].URL != "https://pbs.twimg.com/media/example.jpg" { + t.Fatalf("unexpected media URL: %s", page.Tweets[0].Media[0].URL) } }