Skip to content

Commit

Permalink
Improve Twitter tests (#293)
Browse files Browse the repository at this point in the history
  • Loading branch information
pajlada authored Mar 26, 2022
1 parent 563a776 commit 937c2ec
Show file tree
Hide file tree
Showing 9 changed files with 545 additions and 186 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- Dev: Improve Wikipedia tests. (#286)
- Dev: Improve Imgur tests. (#289)
- Dev: Improve migration tests. (#290)
- Dev: Improve Twitter tests. (#293)

## 1.2.3

Expand Down
84 changes: 2 additions & 82 deletions internal/resolvers/twitter/api.go
Original file line number Diff line number Diff line change
@@ -1,27 +1,11 @@
package twitter

import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"

"github.com/Chatterino/api/pkg/humanize"
"github.com/Chatterino/api/pkg/resolver"
)

func getTweetIDFromURL(url *url.URL) string {
match := tweetRegexp.FindAllStringSubmatch(url.Path, -1)
if len(match) > 0 && len(match[0]) == 2 {
return match[0][1]
}
return ""
}

func buildTweetTooltip(tweet *TweetApiResponse) *tweetTooltipData {
data := &tweetTooltipData{}
data.Text = tweet.Text
Expand All @@ -32,10 +16,10 @@ func buildTweetTooltip(tweet *TweetApiResponse) *tweetTooltipData {

// TODO: what time format is this exactly? can we move to humanize a la CreationDteRFC3339?
timestamp, err := time.Parse("Mon Jan 2 15:04:05 -0700 2006", tweet.Timestamp)
data.Timestamp = humanize.CreationDateTime(timestamp)
if err != nil {
log.Println(err.Error())
data.Timestamp = ""
} else {
data.Timestamp = humanize.CreationDateTime(timestamp)
}

if len(tweet.Entities.Media) > 0 {
Expand All @@ -46,14 +30,6 @@ func buildTweetTooltip(tweet *TweetApiResponse) *tweetTooltipData {
return data
}

func getUserNameFromUrl(url *url.URL) string {
match := twitterUserRegexp.FindAllStringSubmatch(url.String(), -1)
if len(match) > 0 && len(match[0]) > 0 {
return match[0][1]
}
return ""
}

func buildTwitterUserTooltip(user *TwitterUserApiResponse) *twitterUserTooltipData {
data := &twitterUserTooltipData{}
data.Name = user.Name
Expand All @@ -64,59 +40,3 @@ func buildTwitterUserTooltip(user *TwitterUserApiResponse) *twitterUserTooltipDa

return data
}

func getTweetByID(id, bearer string) (*TweetApiResponse, error) {
endpointUrl := fmt.Sprintf("https://api.twitter.com/1.1/statuses/show.json?id=%s&tweet_mode=extended", id)
extraHeaders := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", bearer),
}
resp, err := resolver.RequestGETWithHeaders(endpointUrl, extraHeaders)
if err != nil {
return nil, err
}

defer resp.Body.Close()

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("%d", resp.StatusCode)
}

var tweet *TweetApiResponse
err = json.NewDecoder(resp.Body).Decode(&tweet)
if err != nil {
return nil, errors.New("unable to process response")
}

return tweet, nil
}

func getUserByName(userName, bearer string) (*TwitterUserApiResponse, error) {
endpointUrl := fmt.Sprintf("https://api.twitter.com/1.1/users/show.json?screen_name=%s", userName)
extraHeaders := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", bearer),
}
resp, err := resolver.RequestGETWithHeaders(endpointUrl, extraHeaders)
if err != nil {
return nil, err
}

defer resp.Body.Close()

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("%d", resp.StatusCode)
}

var user *TwitterUserApiResponse
err = json.NewDecoder(resp.Body).Decode(&user)
if err != nil {
return nil, errors.New("unable to process response")
}

/* By default, Twitter returns a low resolution image.
* This modification removes "_normal" to get the original sized image, based on Twitter's API documentation:
* https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners
*/
user.ProfileImageUrl = strings.Replace(user.ProfileImageUrl, "_normal", "", 1)

return user, nil
}
116 changes: 116 additions & 0 deletions internal/resolvers/twitter/data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package twitter

import (
"encoding/json"
"net/http"
"net/http/httptest"

"github.com/go-chi/chi/v5"
)

var (
users = map[string]*TwitterUserApiResponse{}
tweets = map[string]*TweetApiResponse{}
)

func init() {
users["pajlada"] = &TwitterUserApiResponse{
Name: "PAJLADA",
Username: "pajlada",
Description: "Cool memer",
Followers: 69,
ProfileImageUrl: "https://pbs.twimg.com/profile_images/1385924241619628033/fW7givJA_400x400.jpg",
}

// Tweet with no entities
tweets["1507648130682077194"] = &TweetApiResponse{
Text: "Digging a hole",
User: APIUser{
Name: "PAJLADA",
Username: "pajlada",
},
Likes: 69,
Retweets: 420,
Timestamp: "Sat Mar 26 17:15:50 +0200 2022",
}

// Tweet with entities
tweets["1506968434134953986"] = &TweetApiResponse{
Text: "",
User: APIUser{
Name: "PAJLADA",
Username: "pajlada",
},
Likes: 69,
Retweets: 420,
Timestamp: "Sat Mar 26 17:15:50 +0200 2022",
Entities: APIEntities{
Media: []APIEntitiesMedia{
{
Url: "https://pbs.twimg.com/media/FOnTzeQWUAMU6L1?format=jpg&name=medium",
},
},
},
}

// Tweet with poorly formatted timestamp
tweets["1505121705290874881"] = &TweetApiResponse{
Text: "Bad timestamp",
User: APIUser{
Name: "PAJLADA",
Username: "pajlada",
},
Likes: 420,
Retweets: 69,
Timestamp: "asdasd",
}
}

func testServer() *httptest.Server {
r := chi.NewRouter()
r.Get("/1.1/users/show.json", func(w http.ResponseWriter, r *http.Request) {
screenName := r.URL.Query().Get("screen_name")

var response *TwitterUserApiResponse
var ok bool

w.Header().Set("Content-Type", "application/json")

if screenName == "bad" {
w.Write([]byte("xD"))
} else if screenName == "500" {
http.Error(w, http.StatusText(500), 500)
return
} else if response, ok = users[screenName]; !ok {
http.Error(w, http.StatusText(404), 404)
return
}

b, _ := json.Marshal(&response)

w.Write(b)
})
r.Get("/1.1/statuses/show.json", func(w http.ResponseWriter, r *http.Request) {
tweetID := r.URL.Query().Get("id")

var response *TweetApiResponse
var ok bool

w.Header().Set("Content-Type", "application/json")

if tweetID == "bad" {
w.Write([]byte("xD"))
} else if tweetID == "500" {
http.Error(w, http.StatusText(500), 500)
return
} else if response, ok = tweets[tweetID]; !ok {
http.Error(w, http.StatusText(404), 404)
return
}

b, _ := json.Marshal(&response)

w.Write(b)
})
return httptest.NewServer(r)
}
9 changes: 6 additions & 3 deletions internal/resolvers/twitter/initialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ const (
)

var (
tweetRegexp = regexp.MustCompile(`(?i)\/.*\/status(?:es)?\/([^\/\?]+)`)
twitterUserRegexp = regexp.MustCompile(`(?i)twitter\.com\/([^\/\?\s]+)(\/?$|(\?).*)`)
tweetRegexp = regexp.MustCompile(`^/.*\/status(?:es)?\/([^\/\?]+)`)
twitterUserRegexp = regexp.MustCompile(`^/([^\/\?\s]+)(?:\/?$|\?.*)$`)

/* These routes refer to non-user pages. If the capture group in twitterUserRegexp
matches any of these names, we must not resolve it as a Twitter user link.
Expand Down Expand Up @@ -67,5 +67,8 @@ func Initialize(ctx context.Context, cfg config.APIConfig, pool db.Pool, resolve
return
}

*resolvers = append(*resolvers, NewTwitterResolver(ctx, cfg, pool))
const userEndpointURLFormat = "https://api.twitter.com/1.1/users/show.json?screen_name=%s"
const tweetEndpointURLFormat = "https://api.twitter.com/1.1/statuses/show.json?id=%s&tweet_mode=extended"

*resolvers = append(*resolvers, NewTwitterResolver(ctx, cfg, pool, userEndpointURLFormat, tweetEndpointURLFormat))
}
39 changes: 39 additions & 0 deletions internal/resolvers/twitter/initialize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package twitter

import (
"context"
"testing"

"github.com/Chatterino/api/internal/logger"
"github.com/Chatterino/api/pkg/config"
"github.com/Chatterino/api/pkg/resolver"
"github.com/pashagolub/pgxmock"

qt "github.com/frankban/quicktest"
)

func TestInitialize(t *testing.T) {
ctx := logger.OnContext(context.Background(), logger.NewTest())
c := qt.New(t)

pool, err := pgxmock.NewPool()
c.Assert(err, qt.IsNil)

c.Run("No credentials", func(c *qt.C) {
cfg := config.APIConfig{}
customResolvers := []resolver.Resolver{}
c.Assert(customResolvers, qt.HasLen, 0)
Initialize(ctx, cfg, pool, &customResolvers)
c.Assert(customResolvers, qt.HasLen, 0)
})

c.Run("Credentials", func(c *qt.C) {
cfg := config.APIConfig{
TwitterBearerToken: "test",
}
customResolvers := []resolver.Resolver{}
c.Assert(customResolvers, qt.HasLen, 0)
Initialize(ctx, cfg, pool, &customResolvers)
c.Assert(customResolvers, qt.HasLen, 1)
})
}
37 changes: 17 additions & 20 deletions internal/resolvers/twitter/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ func (r *TwitterResolver) Check(ctx context.Context, url *url.URL) (context.Cont
return ctx, false
}

isTweet := tweetRegexp.MatchString(url.String())
if isTweet {
tweetMatch := tweetRegexp.FindStringSubmatch(url.Path)
if len(tweetMatch) == 2 && len(tweetMatch[1]) > 0 {
return ctx, true
}

Expand All @@ -34,11 +34,11 @@ func (r *TwitterResolver) Check(ctx context.Context, url *url.URL) (context.Cont
to a valid user page. We therefore need to check the captured name against a list
of known non-user pages.
*/
m := twitterUserRegexp.FindAllStringSubmatch(url.String(), -1)
if len(m) == 0 || len(m[0]) == 0 {
m := twitterUserRegexp.FindStringSubmatch(url.Path)
if len(m) == 0 || len(m[1]) == 0 {
return ctx, false
}
userName := m[0][1]
userName := strings.ToLower(m[1])

_, notAUser := nonUserPages[userName]
isTwitterUser := !notAUser
Expand All @@ -47,41 +47,38 @@ func (r *TwitterResolver) Check(ctx context.Context, url *url.URL) (context.Cont
}

func (r *TwitterResolver) Run(ctx context.Context, url *url.URL, req *http.Request) ([]byte, error) {
if tweetRegexp.MatchString(url.String()) {
tweetID := getTweetIDFromURL(url)
if tweetID == "" {
return resolver.NoLinkInfoFound, nil
}
tweetMatch := tweetRegexp.FindStringSubmatch(url.Path)
if len(tweetMatch) == 2 && len(tweetMatch[1]) > 0 {
tweetID := tweetMatch[1]

return r.tweetCache.Get(ctx, tweetID, req)
}

if twitterUserRegexp.MatchString(url.String()) {
userMatch := twitterUserRegexp.FindStringSubmatch(url.Path)
if len(userMatch) == 2 && len(userMatch[1]) > 0 {
// We always use the lowercase representation in order
// to avoid making redundant requests.
userName := strings.ToLower(getUserNameFromUrl(url))
if userName == "" {
return resolver.NoLinkInfoFound, nil
}
userName := strings.ToLower(userMatch[1])

return r.userCache.Get(ctx, userName, req)
}

// TODO: Return "do not handle" here?
return resolver.NoLinkInfoFound, nil
return nil, resolver.ErrDontHandle
}

func (r *TwitterResolver) Name() string {
return "twitter"
}

func NewTwitterResolver(ctx context.Context, cfg config.APIConfig, pool db.Pool) *TwitterResolver {
func NewTwitterResolver(ctx context.Context, cfg config.APIConfig, pool db.Pool, userEndpointURLFormat, tweetEndpointURLFormat string) *TwitterResolver {
tweetLoader := &TweetLoader{
bearerKey: cfg.TwitterBearerToken,
bearerKey: cfg.TwitterBearerToken,
endpointURLFormat: tweetEndpointURLFormat,
}

userLoader := &UserLoader{
bearerKey: cfg.TwitterBearerToken,
bearerKey: cfg.TwitterBearerToken,
endpointURLFormat: userEndpointURLFormat,
}

r := &TwitterResolver{
Expand Down
Loading

0 comments on commit 937c2ec

Please sign in to comment.